【嗅探底层】你知道Synchronized做用是同步加锁,可你知道它在JVM中是如何实现的吗?


​本文系公众号石杉的架构笔记的读者投稿算法

做者:李瑞杰安全

目前任职于阿里巴巴,资深JVM研究人员架构


友情提示:性能

本文内容涉及JVM底层,文章烧脑,请谨慎阅读!学习


咱们能够利用synchronized关键字来对程序进行加锁。它既能够用来声明一个synchronized代码块,也能够直接标记静态方法或者实例方法。优化

当谈到synchronized时,咱们有必要了解字节码中的monitorenter和monitorexit指令。this

这两种指令均会消耗操做数栈上的一个引用类型的元素(也就是 synchronized关键字括号里的引用),做为所要加锁解锁的锁对象。操作系统

下面咱们将深刻了解Synchronized在JVM底层的实现原理。线程

考察如下的代码:设计

查看这个代码编译后的字节码,我就直接用下面这张图解释了。

ps:截图截得不太好,下面有点没截到,你们凑合看看:


你可能会留意到,上面的字节码中包含一个 monitorenter 指令以及多个 monitorexit 指令。

这是由于 Java 虚拟机须要确保所得到的锁在正常执行路径以及异常执行路径上都可以被解锁。

你们能够看个人注释,本身思考一下,应该都能看懂。

应该注意,若是用synchronized标记方法,你会看到字节码中方法的访问标记包括ACC_SYNCHRONIZED。

该标记表示在进入该方法时,Java 虚拟机须要进行monitorenter操做。

而在退出该方法时,不论是正常返回,仍是向调用者抛异常,Java 虚拟机均须要进行monitorexit操做。

用两张图一看就懂。



能够看到,在0号字节码处就返回了。

这里有人可能问了,这里没有调用monitorenter和monitorexit指令啊?怎么实现的加锁?

要注意,这里monitorenter 和 monitorexit 操做所对应的锁对象是隐式的。

对于实例方法来讲,这两个操做对应的锁对象是 this;对于静态方法来讲,这两个操做对应的锁对象则是所在类的Class实例。


咱们先来介绍Synchronized的重入的实现机理。


能够认为每一个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。

当执行monitorenter时,若是目标锁对象的计数器为0,那么说明它没有被其余线程所持有。

Java虚拟机会将该锁对象的持有线程设置为当前线程,而且将其计数器加1。

在目标锁对象的计数器不为 0 的状况下,若是锁对象的持有线程是当前线程,那么 Java 虚拟机能够将其计数器加1,不然须要等待,直至持有线程释放该锁。

当执行monitorexit时,Java虚拟机则需将锁对象的计数器减1。计数器为0,表明锁已被释放。


这就是锁的重入的实现机理。


说完了这个实现机理,咱们来探究具体的锁实现。

首先谈谈重量级锁,重量级锁是 Java 虚拟机中最为基础的锁实现。

在这种状态下,Java 虚拟机会阻塞加锁失败的线程,而且在目标锁被释放的时候,唤醒这些线程。在Linux中,这是经过pthread库的互斥锁来实现的。

此外,这些操做将涉及系统调用,须要从操做系统的用户态切换至内核态,其开销很是之大。

为了尽可能避免昂贵的线程阻塞、唤醒操做,Java虚拟机会在线程进入阻塞状态以前,以及被唤醒后竞争不到锁的状况下,进入自旋状态,在处理器上空跑而且轮询锁是否被释放。

若是此时锁刚好被释放了,那么当前线程便无须进入阻塞状态,而是直接得到这把锁。

下面我将介绍自适应自旋的概念,刚才说了自旋是什么,可是自旋很耗费资源,因此咱们能够根据以往自旋等待时是否可以得到锁,来动态调整自旋的时间(循环数目)。

因此Synchronized是否公平这个问题能够休矣,为何呢?

处于阻塞状态的线程,并无办法马上竞争被释放的锁。然而,处于自旋状态的线程,则颇有可能优先得到这把锁。因此Synchronized不是公平的

咱们再介绍轻量级锁,针对多个线程在不一样的时间段请求同一把锁,也就是说没有锁竞争。

针对这种情形,Java 虚拟机采用了轻量级锁,来避免重量级锁的阻塞以及唤醒。

在介绍轻量级锁的原理以前,咱们先来了解一下Java虚拟机是怎么区分轻量级锁和重量级锁的。

简单的说,对象头中有一个标记字段。它的最后两位便被用来表示该对象的锁状态,其中:

  • 00表明轻量级锁

  • 01表明无锁(或偏向锁)

  • 10表明重量级锁

  • 11则跟垃圾回收算法的标记有关。


当进行加锁操做时,Java虚拟机会判断是否已是重量级锁。

若是不是,它会在当前线程的当前栈桢中划出一块空间,做为该锁的锁记录,而且将锁对象的标记字段复制到该锁记录中。

而后,Java 虚拟机会尝试用 CAS 操做替换锁对象的标记字段。

各位有兴趣能够了解一下JVM的CAS在X86机器上的实现,是汇编指令lock cmpxhcg

这里我简单介绍一下,CAS 是一个原子操做,它会比较目标地址的值是否和指望值相等,若是相等,则替换为一个新的值。

假设当前锁对象的标记字段为 X…XYZ,Java 虚拟机会比较该字段是否为 X…X01。

若是是,则替换为刚才分配的锁记录的地址。因为内存对齐的缘故,它的最后两位为 00。此时,该线程已成功得到这把锁,能够继续执行了。

若是不是 X…X01,那么有两种可能:

  • 第一,该线程重复获取同一把锁。此时,Java 虚拟机会将0加入锁记录,以表明该锁被重复获取。

  • 第二,其余线程持有该锁。此时,Java 虚拟机会将这把锁膨胀为重量级锁,而且阻塞当前线程。

你能够将一个线程的全部锁记录想象成一个栈结构,每次加锁压入一条锁记录,解锁弹出一条锁记录,当前锁记录指的即是栈顶的锁记录。

当进行解锁操做时,若是当前锁记录的值为 0,则表明重复进入同一把锁,直接返回便可。

若当前锁记录不是0,Java 虚拟机会尝试用 CAS 操做,比较锁对象的标记字段的值是否为当前锁记录的地址。

若是是,则替换为锁记录中的值,也就是锁对象本来的标记字段。此时,该线程已经成功释放这把锁。

若是不是,则意味着这把锁已经被膨胀为重量级锁。此时,Java 虚拟机会进入重量级锁的释放过程,唤醒因竞争该锁而被阻塞了的线程。

下面咱们介绍偏向锁,偏向锁针对的是从始至终只有一个线程请求某一把锁。是轻量级锁的更进一步的乐观状况。

在线程进行加锁时,若是该锁对象支持偏向锁,那么 Java 虚拟机会经过 CAS 操做,将当前线程的地址记录在锁对象的标记字段之中,而且将标记字段的最后三位设置为 101。

这里介绍一下epoch的概念,每一个类中维护一个epoch值,你能够理解为这个类全部实例对象的第几代偏向锁。

当设置偏向锁时,Java 虚拟机须要将该epoch值复制到锁对象的标记字段中。咱们规定,你加的偏向锁的代数高,是能够把代数低的PK下去的。


接下来我给你讲的过程,你就知道为何要这么设计了。


咱们先从偏向锁的撤销讲起。

当请求加锁的线程和锁对象标记字段保持的线程地址不匹配时(并且epoch即代数必须相等,如若不等,那么当前线程能够将该锁重偏向至本身,由于新的epoch的代数确定要高于之前的代数),Java 虚拟机须要撤销该偏向锁。

这个撤销过程很是麻烦,它要求持有偏向锁的线程到达安全点,再将偏向锁替换成轻量级锁。

在宣布某个类的偏向锁失效时,Java 虚拟机实则将该类的epoch值加 1,表示以前那一代的偏向锁已经失效。而新设置的偏向锁则须要使用类中的最新epoch代数来加锁。

为了保证当前持有偏向锁而且已加锁的线程不至于所以丢锁,Java虚拟机须要遍历全部线程的Java栈,找出该类已加锁的实例,而且将它们标记字段中的 epoch值加1。该操做须要全部线程处于安全点状态。

因此有专家近年来提出,偏向锁在锁竞争激烈的状况下,非但不能优化性能,反而可能伤害应用性能。

若是总撤销数超过另外一个阈值(对应 Java 虚拟机参数-XX:BiasedLockingBulkRevokeThreshold,默认值为 40),那么 Java 虚拟机会认为这个类已经再也不适合偏向锁。

此时,Java 虚拟机会撤销该类实例的偏向锁,而且在以后的加锁过程当中直接为该类实例设置轻量级锁。


END


欢迎长按下图关注公众号:石杉的架构笔记!

公众号后台回复资料,获取做者独家秘制学习资料

石杉的架构笔记,BAT架构经验倾囊相授

相关文章
相关标签/搜索