Skip to main content

moregeek program

#yyds干货盘点# 浅析 synchronized 底层实现与锁相关 | Java-多极客编程

引言



一切的最开始都是源自一系列为什么?


  • 为什么加了锁 synchronized 关键字,就可以实现同步?
  • synchronized 底层到底做了什么优化?
  • Java 中的各种锁及锁膨胀?
  • 用户态、内核态与上下文切换到底是什么鬼?
  • 什么叫自旋锁,它与 CAS 的关系?
  • 对象头是什么玩意,什么又是 MarkWord ?

概述


synchronizrd 是开发中解决同步问题中最常见,也是最简单的一种方法。从最开始学习并发编程,我们都知道,只要加上这个 synchronizrd 关键字,就可以很大程度上轻松解决同步问题。相应的,从原理上来讲,其也是比较重的一种操作,特别是 jdk1.5 时候,相比 JUC 中的 Lock 锁,一定程度上逊色不少。但随着jdk1.6对 synchronized 的优化后,synchronizrd 并不会显得那么重,相比使用 Lock 而言,其的性能大多数情况下也可以接近 Lock


本文的主旨就是对 synchronized 的原理进行探秘,从而完成对各种锁的了解与学习。


synchronizrd 常用的作用有三个:



  • 原子性


    即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行;




  • 可见性


    可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值;




  • 有序性


    防止编译器和处理对指令进行重排序,即也就是抑制指令重排序,;



解析


synchronziedjvm 里的实现都是基于进入和退出 Monitor 对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过 成对 的 MonitorEnterMonitorExit 指令来实现。具体如下图对比:


示例代码


image-20210621222332704


字节码对比:


image-20210621222644407


同步方法 ,从上图字节码来看,方法的同步并没有通过指令 monitorentermonitorexit 来实现,而是直接在方法中增加了 synchronzied 修饰。更底层实现上而言,其常量池中多了 ACC_SYNCHRONIZED 标识符,JVM 就是根据该标识符来实现方法的同步:当方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor ,获取成功之后才能执行方法体,方法执行完后再释放 monitor 。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。


对于 同步代码块 而言,synchronzied 的底层实现中,MonitorEnter 指令插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象 Monitor 的所有权,即尝试获得该对象的锁,而 monitorExit 指令则插入在方法结束处和异常处,JVM 保证每个 MonitorEnter 必须有对应的 MonitorExit



在上面,说到了 synchronized 的在字节码上的实现,那对于虚拟机而言,synchronzied 锁的标志到底放哪了呢?说到这个问题,我们不得不提 对象头 这个概念。


对象头


什么是对象头,对象头干了什么?


如果看过垃圾回收机制,那么可能知道这个玩意。对象头其就相当于一个名片,它包含了对象的一个基本信息.



注意:对象头中不一定包含数组长度,如果这个对象不是数组 ✋



整个对象头由两个部分组成,分别是 KlassPointMark Word


KlassPoint


当我们 new 出一个类时,虚拟机如何得知它是哪个类呢, 这时候就是通过上述 KlassPoint ,其指向了类元数据 (mteaData) 的信息。


元数据



在计算机中,有各种 [元] 数据。比如文件有元数据,网页有元标签。 这个说法来自希腊语,表示关于。所以 文件中的元数据,即为关于文件的数据,类的元数据即为类信息的一个原始标签。也即为描述这个类的信息



Mark Word


用于存储不同状态信息,是会随着时间点而改变。一般而言默认数据是存储对象的 HashCode 等信息,而我们本篇的主题 synchronzied 正是在其里存储。如下图所示


长度 内容 说明
32/64 bit Mark Word 存储对象的 hasCode 或 锁信息等
32/64 bit Class Metadata Address 存储到对象类型数据的指针
32 / 32bit Array length 数组的长度(如果当前对象是数组)
锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志位
无锁状态 对象的hashCode 对象分代年龄 0 01

Markword的信息会随着时间不断改变,比如发生gc时,内部gc 标记为null。


image-20210526233425655


而我们本篇的主题 synchronized锁状态 也存在与 MarkWord 中,在对象运行变化的过程中,锁的状态存在4种变化状态,即 无锁状态偏向锁状态轻量级锁状态重量级锁状态 。它会随着竞争情况逐渐升级,锁可以升级但不能降级,主要目的是为了提高获得锁和释放锁的效率。



那为什么要存在着几种🔐呢?或者还没看明白缘由?请接着下面继续看?👇



上下文切换


在jdk1.6之后,synchronizrd 得到了优化,而添加各种锁的目的都是为了避免直接加锁而导致的上下文切换从而引发的耗时浪费。


如果不了解上下文切换,这样说可能听着有点懵,我们先从基础讲一下:



什么是上下文切换?


1.在单处理器时期,操作系统就能处理多线程并发任务,处理器给每个线程分配CPU时间片,线程在CPU时间片内执行任务


  • CPU时间片是CPU分配给每个线程执行的时间段,一般为几十毫秒

2.时间片决定了一个线程可以连续占用处理器运行的时长


  • 当一个线程的时间片用完,或者因自身原因被迫暂停运行,此时另一个线程会被操作系统选中来占用处理器
  • 上下文切换(Context Switch):一个线程被暂停剥夺使用权,另一个线程被选中开始或者继续运行的过程
  • 切出:一个线程被剥夺处理器的使用权而被暂停运行
  • 切入:一个线程被选中占用处理器开始运行或者继续运行
  • 切出切入的过程中,操作系统需要保存和恢复相应的进度信息,这个进度信息就是上下文\

3. 上下文的内容


  • 寄存器的存储内容:CPU寄存器负责存储已经、正在和将要执行的任务
  • 程序计数器存储的指令内容:程序计数器负责存储CPU正在执行的指令位置、即将执行的下一条指令的位置

4.当CPU数量远远不止1个的情况下,操作系统将CPU轮流分配给线程任务,此时的上下文切换会变得更加频繁


  • 并且存在跨CPU的上下文切换,更加昂贵

内容摘录自:Java性能之线程上下文切换究极解析



所以,当我们某个资源使用 synchronizrd 进行加锁时:


  1. 当线程A获取了锁,线程B在获取时将会被阻塞,也即是 BLOCKED 状态,此时线程B暂停被操作系统 切出 ,操作系统会保存此时的上下文;
  2. 当线程A释放了锁,此时假设线程B获取到了锁,线程B 从 BLOCKED 进入 RUNNABLE 状态,即线程重新唤醒,此时线程将获取上次操作系统保存的上下文继续执行。

上述的过程中线程B执行了 两次 上下文切换,每一次上下文切换的过程为 3~5微秒 ,而cpu执行一条指令只需要 0.6ns ,所以如果加锁后只是执行几条普通指令,如某个变量的自增或者其他,那么上下文切换将对性能产生极大影响,所以在jdk1.6以后,synchronizrd 得到了优化,新增了几种锁,以及不同情况下的状态变化,以避免直接重量级锁产生的性能损耗。


常见的锁


自旋锁


如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。


但是线程自旋是需要消耗 CPU 的,说白了就是让 CPU 在做无用功,线程不能一直占用 CPU 自旋做无用功,所以需要设定一个自旋等待的最大时间。


如果持有锁的线程时间超过自旋等待的最大时间扔没有释放锁,就会导致其他争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。


优缺点


自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗。


但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行代码块,这时候就不适合使用自旋锁,因为自旋锁在获取锁前一直都是占用cpu不断做尝试,线程自旋产生的消耗大于线程阻塞挂起操作的消耗。导致其他需要cpu的线程无法获取到cpu,从而造成了cpu的浪费。


自旋锁时间阈值


自旋锁的目的是为了占着 CPU 的资源不释放,等到获取到锁立即进行处理。


如何选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能,因此自旋次数非常重要。


自适应自旋锁


JVM 对于自旋次数的选择, jdk 1.5 默认为 10次 ,在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本上认为是一个线程上下文切换的时间是最佳的时间。


比如线程A 获取了一把锁后,当它释放这把锁之后,线程B成功获得了这把锁后,此时,线程A再次申请获取锁,由于此时线程B还没有释放锁,所以线程A只能自旋等待,但是虚拟机认为:由于线程A刚刚获得过这把锁,那么虚拟机会认为线程A这次自旋也是有可能会再次成功获得该把锁,所以会延长线程A的自旋次数。


对于一个锁,一个线程自旋之后,获取锁成功概率不大,那么以后这个线程要获取该锁时,是有可能直接忽略掉自旋过程,直接升级为重量级锁,以免空循环等待浪费资源。



偏向锁


背景


实际开发中,大多数情况下不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入偏向锁,从而减少不必要的 CAS 操作。


概括


偏向锁,顾名思义,它会偏向第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁,从而减少加锁/解锁 的一些 CAS 操作(比如等待队列中的 CAS 操作(CLH队列锁) ) 。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会挂起,JVM 会消除它身上的偏向锁,并将锁恢复到标准的轻量级锁。它通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。


偏向锁升级过程


<img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/63c56a28408e44b480913b65d48de512~tplv-k3u1fbpfcp-zoom-1.image" alt="image-20210622230610649" style="zoom:50%;" />


  1. 访问 Mark Word 中偏向锁的标识是否被设置成 1 ,锁标志位 是否为 01 ,确认其为可偏向状态;
  2. 如果是可偏向状态,则测试 线程ID 是否指向当前线程,如果是,进入步骤5,否则进入步骤3;
  3. 如果 线程ID 并未指向当前线程,则通过 CAS 操作竞争锁。如果竞争成功,则将 Mark Word线程ID 设置为当前线程ID ,然后执行 步骤5 ; 如果竞争失败,则执行步骤 4;
  4. 如果 CAS 获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
  5. 执行同步代码。

偏向锁的释放


偏向锁的撤销在步骤4中已经提过。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待 全局安全点(即在这个时间点上没有字节码正在执行),它会首先暂停拥有锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为 "01" ) 或轻量级锁(标志位为 "00" )的状态。


适用场景


始终只有一个线程在执行代码块,在它没有执行完释放锁之前,没有其他线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏量锁的时候会导致 stop the word(stw) 操作。


在有锁的竞争时,偏向锁会做很多额外操作,尤其是撤销偏向锁的时候会导致进入安全点,而安全点会导致 stw,导致性能下降。


Stop the word是什么?



指在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外),是Java中一种全局暂停现象,类似于应用程序发生了停顿,没有任何响应。




轻量级锁


轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;轻量级锁的目的是减少无实际竞争情况下,使用重量级锁的性能消耗。比如系统调用引起的内核态与用户态切换,线程阻塞造成的线程切换等。


轻量级锁的加锁过程:


在代码进入同步块的时候,如果同步对象锁状态无锁状态且不允许进行偏向(锁标志位为"01"状态,是否为偏量锁为"0"),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录 (Lock Record) 的空间,用于存储锁对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word


拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock record 里的 owner 指针指向 object mark word 。 如果更新成功,则执行上述偏向锁中的步骤4,否则执行步骤5。


如果这个更新操作成功,那么这个线程就拥有了该对象的锁,并且对象 MarkWord 的锁标志设置为 "00" ,即表示此对象处于轻量级锁锁定状态。


如果这个更新操作失败了,虚拟机首先会检查对象 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,当竞争线程尝试占用轻量级失败多次之后,轻量级锁就会膨胀为重量级锁,重量级线程指针指向竞争线程,竞争线程也会阻塞,等待轻量级线程释放锁后唤醒它。同时锁标志的状态值变为 "10" ,如图下 Mark Word 中存储的就是指向重量级锁(互斥量) 的指针,后面等待锁的线程也要进入阻塞状态。



重量级锁


轻量级锁不断自旋膨胀之后,就会升级为重量级锁。重量级锁时依赖对象内部的 monitor 锁来实现,而 moitor 又依赖系统的 MutexLock(互斥锁) 来实现,所以重量级锁也被称为 互斥锁


为什么说重量级锁开销比较大?


当系统检查到锁时重量级锁时,会把正在等待获取锁的线程进行阻塞,被阻塞的线程不会消耗 cpu ,但是阻塞和唤醒线程,都需要操作系统来处理,这就需要从用户态转换到内核态,而从用户态到内核态的切换吗,需要通过系统调用来完成。


系统调用的过程中会发生 cpu 上下文切换,一次系统调用的过程,需要发生 两次 上下文切换。而这个过程很多时候比同步代码块所需时间还长。


不同锁之间的比较


优势 劣势 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比,仅存在纳秒级的差距。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块的场景。
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度。 始终得不到锁竞争的线程会使用自旋,从而消耗cpu,到达一定自旋次数,将膨胀为重量级锁 追求响应时间。同步块执行速度非常快。
重量级锁 线程不使用自旋,不耗费cpu 线程阻塞,响应时间缓慢 追求吞吐量。同步块执行速度较长。

总结


到了这里,我们知道了为什么 synchronized 关键字的底层实现以及锁的状态变化过程。说实话,这些对于一个Android 开发而言,可能很难有应用场景,但是于我个人而言,终于解释了曾经哪些隐晦的 为什么,以及一些边界概念。知道的越多,不知道的越多。


感谢


深度分析:锁升级过程和锁状态,看完这篇你就懂了!


Java性能之线程上下文切换究极解析


享学课堂-synchronized-Mark


关于我


Hello,我是 Petterp ,一个在帝都修炼的Android工程师。


如果您觉得文章对您有价值,欢迎👏🏻,也欢迎关注我的 Github


如果您看完之后觉得差了点意思,也欢迎留言给我。

©著作权归作者所有:来自51CTO博客作者Petterpx的原创作品,请联系作者获取转载授权,否则将追究法律责任
#yyds干货盘点# 浅析 synchronized 底层实现与锁相关 | Java
https://blog.51cto.com/petterp/5052184

【Flutter 专题】77 图解历史 Android Native 项目接入 Flutter Module #yyds干货盘点#-多极客编程

      随着 Flutter 的逐渐发展与完善,与 Native 结合使用的场景越来越多;小菜今天尝试将一个历史的 Android Native 项目接入 Flutter Module; AndroidX       Flutter 的发展很迅速,大部分插件均适配 AndroidX,为了今后开发的便利性,小菜优先由如下版本升级适配 AndroidX; minSdkVersion 17 targ

#yyds干货盘点#Android C++系列:Linux Socket编程(二)网络套接字函数-多极客编程

​1. socket#include <sys/types.h> /* See NOTES */ #include <sys/socket.h>int socket(int domain, int type, int protocol);domain:AF_INET 这是大多数用来产生socket的协议,使用TCP或UDP来传输,用IPv4的地址AF_INET6 与上面类似

#yyds干货盘点# notifyItemRemoved导致的数组下标越界问题-多极客编程

我们一般在RecyclerView列表中移除某条Item时会调用 notifyItemRemoved 方法,其还附带了相应的移除动画。使用虽然简单,却埋了一个大坑; 当你调用 notifyItemRemoved 之后,如果你移除的刚好是倒数第二个数据,此时点击最后一条数据,就会惊讶的发现当前点击的下标居然没变,然后抛出数组越界的错误。为什么呢,原因如下: 众所周知,RecyclerView更新数

Android技术分享| 【Android 自定义View】多人视频通话控件-多极客编程

【Android 自定义View】多人视频通话控件 *以上图片截自微信等待中界面 等待中界面 上图是微信多人视频通话时未接通的界面状态,可见每个人的 View 中大致需包含了以下元素。 头像 昵称 Loading View 视频 View 音频状态 icon 所以,我们先写好每个人的布局。如下 <merge xmlns:android="http://schemas.android.co

#yyds干货盘点#Android C++系列:Linux Socket编程(一)预备知识-多极客编程

​1. 网络字节序我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分。网络数据流同样有大端小端之分,那么如何定义网络数据流的地址呢?发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此,网络数据流的地址应这样规定:先发出的数据

#yyds干货盘点#Android C++系列:Linux常用函数和工具-多极客编程

​1. 时间函数1.1 文件访问时间#include <sys/types.h>#include <utime.h>int utime (const char *name, const struct utimebuf *t); 返回:若成功则为 0,若出错则为- 1如果times是一个空指针,则存取时间和修改时间两者都设置为当前时间;如果times是非空指针,则存取时间和修

【Flutter 专题】77 图解历史 Android Native 项目接入 Flutter Module #yyds干货盘点#-多极客编程

      随着 Flutter 的逐渐发展与完善,与 Native 结合使用的场景越来越多;小菜今天尝试将一个历史的 Android Native 项目接入 Flutter Module; AndroidX       Flutter 的发展很迅速,大部分插件均适配 AndroidX,为了今后开发的便利性,小菜优先由如下版本升级适配 AndroidX; minSdkVersion 17 targ

#yyds干货盘点#Android C++系列:Linux Socket编程(二)网络套接字函数-多极客编程

​1. socket#include <sys/types.h> /* See NOTES */ #include <sys/socket.h>int socket(int domain, int type, int protocol);domain:AF_INET 这是大多数用来产生socket的协议,使用TCP或UDP来传输,用IPv4的地址AF_INET6 与上面类似

#yyds干货盘点# notifyItemRemoved导致的数组下标越界问题-多极客编程

我们一般在RecyclerView列表中移除某条Item时会调用 notifyItemRemoved 方法,其还附带了相应的移除动画。使用虽然简单,却埋了一个大坑; 当你调用 notifyItemRemoved 之后,如果你移除的刚好是倒数第二个数据,此时点击最后一条数据,就会惊讶的发现当前点击的下标居然没变,然后抛出数组越界的错误。为什么呢,原因如下: 众所周知,RecyclerView更新数

Android技术分享| 【Android 自定义View】多人视频通话控件-多极客编程

【Android 自定义View】多人视频通话控件 *以上图片截自微信等待中界面 等待中界面 上图是微信多人视频通话时未接通的界面状态,可见每个人的 View 中大致需包含了以下元素。 头像 昵称 Loading View 视频 View 音频状态 icon 所以,我们先写好每个人的布局。如下 <merge xmlns:android="http://schemas.android.co

#yyds干货盘点#Android C++系列:Linux Socket编程(一)预备知识-多极客编程

​1. 网络字节序我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分。网络数据流同样有大端小端之分,那么如何定义网络数据流的地址呢?发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此,网络数据流的地址应这样规定:先发出的数据

#yyds干货盘点#Android C++系列:Linux常用函数和工具-多极客编程

​1. 时间函数1.1 文件访问时间#include <sys/types.h>#include <utime.h>int utime (const char *name, const struct utimebuf *t); 返回:若成功则为 0,若出错则为- 1如果times是一个空指针,则存取时间和修改时间两者都设置为当前时间;如果times是非空指针,则存取时间和修