Java虚拟机是怎么实现synchronized的?

本文已收录GitHub,更有互联网大厂面试真题,面试攻略,高效学习资料等java

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

当声明 synchronized 代码块时,编译而成的字节码将包含 monitorenter 和 monitorexit指令。这两种指令均会消耗操做数栈上的一个引用类型的元素(也就是 synchronized 关键字括号里的引用),做为所要加锁解锁的锁对象。github

public void foo(Object lock) {
    synchronized (lock) {
        lock.hashCode();
    }
}
//上面的Java代码将编译为下面的字节码
public void foo(java.lang.Object);
Code:
0: aload_1
1: dup
2: astore_2
3: monitorenter
4: aload_1
5: invokevirtual java/lang/Object.hashCode:()I
8: pop
9: aload_2
10: monitorexit
11: goto          19
14: astore_3
15: aload_2
16: monitorexit
17: aload_3
18: athrow
19: return
Exception table:
from    to  target type
4    11    14   any14    17    14   any

我在文稿中贴了一段包含 synchronized 代码块的 Java 代码,以及它所编译而成的字节码。你可能会留意到,上面的字节码中包含一个 monitorenter 指令以及多个 monitorexit指令。这是由于 Java 虚拟机须要确保所得到的锁在正常执行路径,以及异常执行路径上都可以被解锁。面试

你能够根据我在介绍异常处理时介绍过的知识,对照字节码和异常处理表来构造全部可能的执行路径,看看在执行了 monitorenter 指令以后,是否都有执行 monitorexit 指令。算法

当用 synchronized 标记方法时,你会看到字节码中方法的访问标记包括ACC_SYNCHRONIZED。该标记表示在进入该方法时,Java 虚拟机须要进行monitorenter 操做。而在退出该方法时,不论是正常返回,仍是向调用者抛异常,Java 虚拟机均须要进行 monitorexit 操做。编程

public synchronized void foo(Object lock) {
    lock.hashCode();
}
//上面的Java代码将编译为下面的字节码
public synchronized void foo(java.lang.Object);
descriptor: (Ljava/lang/Object;
)V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZEDCode:
stack=1, locals=2, args_size=2
0: aload_1
1: invokevirtual java/lang/Object.hashCode:()I4: pop
5: return

这里 monitorenter 和 monitorexit 操做所对应的锁对象是隐式的。对于实例方法来讲,这两个操做对应的锁对象是 this;对于静态方法来讲,这两个操做对应的锁对象则是所在类的 Class 实例。安全

关于 monitorenter 和 monitorexit 的做用,咱们能够抽象地理解为每一个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。ide

当执行 monitorenter 时,若是目标锁对象的计数器为 0,那么说明它没有被其余线程所持有。在这个状况下,Java 虚拟机会将该锁对象的持有线程设置为当前线程,而且将其计数器加 1。布局

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

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

之因此采用这种计数器的方式,是为了容许同一个线程重复获取同一把锁。举个例子,若是一个 Java 类中拥有多个 synchronized 方法,那么这些方法之间的相互调用,不论是直接的仍是间接的,都会涉及对同一把锁的重复加锁操做。所以,咱们须要设计这么一个可重入的特性,来避免编程里的隐式约束。

说完抽象的锁算法,下面咱们便来介绍 HotSpot 虚拟机中具体的锁实现。

重量级锁

重量级锁是 Java 虚拟机中最为基础的锁实现。在这种状态下,Java 虚拟机会阻塞加锁失败的线程,而且在目标锁被释放的时候,唤醒这些线程。

Java 线程的阻塞以及唤醒,都是依靠操做系统来完成的。举例来讲,对于符合 posix 接口的操做系统(如 macOS 和绝大部分的 Linux),上述操做是经过 pthread 的互斥锁(mutex)来实现的。此外,这些操做将涉及系统调用,须要从操做系统的用户态切换至内核态,其开销很是之大。

为了尽可能避免昂贵的线程阻塞、唤醒操做,Java 虚拟机会在线程进入阻塞状态以前,以及被唤醒后竞争不到锁的状况下,进入自旋状态,在处理器上空跑而且轮询锁是否被释放。若是此时锁刚好被释放了,那么当前线程便无须进入阻塞状态,而是直接得到这把锁。

与线程阻塞相比,自旋状态可能会浪费大量的处理器资源。这是由于当前线程仍处于运行情况,只不过跑的是无用指令。它指望在运行无用指令的过程当中,锁可以被释放出来。

咱们能够用等红绿灯做为例子。Java 线程的阻塞至关于熄火停车,而自旋状态至关于怠速停车。若是红灯的等待时间很是长,那么熄火停车相对省油一些;若是红灯的等待时间很是短,好比说咱们在 synchronized 代码块里只作了一个整型加法,那么在短期内锁确定会被释放出来,所以怠速停车更加合适。

然而,对于 Java 虚拟机来讲,它并不能看到红灯的剩余时间,也就没办法根据等待时间的长短来选择自旋仍是阻塞。Java 虚拟机给出的方案是自适应自旋,根据以往自旋等待时是否可以得到锁,来动态调整自旋的时间(循环数目)。

就咱们的例子来讲,若是以前不熄火等到了绿灯,那么此次不熄火的时间就长一点;若是以前不熄火没等到绿灯,那么此次不熄火的时间就短一点。

自旋状态还带来另一个反作用,那即是不公平的锁机制。处于阻塞状态的线程,并无办法马上竞争被释放的锁。然而,处于自旋状态的线程,则颇有可能优先得到这把锁。

轻量级锁

你可能见到过深夜的十字路口,四个方向都闪黄灯的状况。因为深夜十字路口的车辆来往可能比较少,若是还设置红绿灯交替,那么颇有可能出现四个方向仅有一辆车在等红灯的状况。

所以,红绿灯可能被设置为闪黄灯的状况,表明车辆能够自由经过,可是司机须要注意观察(我的理解,实际意义请咨询交警部门)。

Java 虚拟机也存在着相似的情形:多个线程在不一样的时间段请求同一把锁,也就是说没有锁竞争。针对这种情形,Java 虚拟机采用了轻量级锁,来避免重量级锁的阻塞以及唤醒。在介绍轻量级锁的原理以前,咱们先来了解一下 Java 虚拟机是怎么区分轻量级锁和重量级锁的。

在对象内存布局那一篇中我曾经介绍了对象头中的标记字段(mark word)。它的最后两位便被用来表示该对象的锁状态。其中,00 表明轻量级锁,01 表明无锁(或偏向锁),10 表明重量级锁,11 则跟垃圾回收算法的标记有关。

当进行加锁操做时,Java 虚拟机会判断是否已是重量级锁。若是不是,它会在当前线程的当前栈桢中划出一块空间,做为该锁的锁记录,而且将锁对象的标记字段复制到该锁记录中。

而后,Java 虚拟机会尝试用 CAS(compare-and-swap)操做替换锁对象的标记字段。这里解释一下,CAS 是一个原子操做,它会比较目标地址的值是否和指望值相等,若是相等,则替换为一个新的值。

假设当前锁对象的标记字段为 X…XYZ,Java 虚拟机会比较该字段是否为 X…X01。若是是,则替换为刚才分配的锁记录的地址。因为内存对齐的缘故,它的最后两位为 00。此时,该线程已成功得到这把锁,能够继续执行了。

若是不是 X…X01,那么有两种可能。第一,该线程重复获取同一把锁。此时,Java 虚拟机会将锁记录清零,以表明该锁被重复获取。第二,其余线程持有该锁。此时,Java 虚拟机会将这把锁膨胀为重量级锁,而且阻塞当前线程。

当进行解锁操做时,若是当前锁记录(你能够将一个线程的全部锁记录想象成一个栈结构,每次加锁压入一条锁记录,解锁弹出一条锁记录,当前锁记录指的即是栈顶的锁记录)的值为 0,则表明重复进入同一把锁,直接返回便可。

不然,Java 虚拟机会尝试用 CAS 操做,比较锁对象的标记字段的值是否为当前锁记录的地址。若是是,则替换为锁记录中的值,也就是锁对象本来的标记字段。此时,该线程已经成功释放这把锁。

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

偏向锁

若是说轻量级锁针对的状况很乐观,那么接下来的偏向锁针对的状况则更加乐观:从始至终只有一个线程请求某一把锁。

这就比如你在私家庄园里装了个红绿灯,而且庄园里只有你在开车。偏向锁的作法即是在红绿灯处识别来车的车牌号。若是匹配到你的车牌号,那么直接亮绿灯。

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

在接下来的运行过程当中,每当有线程请求这把锁,Java 虚拟机只需判断锁对象标记字段中:最后三位是否为 101,是否包含当前线程的地址,以及 epoch 值是否和锁对象的类的epoch 值相同。若是都知足,那么当前线程持有该偏向锁,能够直接返回。

这里的 epoch 值是一个什么概念呢?

咱们先从偏向锁的撤销讲起。当请求加锁的线程和锁对象标记字段保持的线程地址不匹配时(并且 epoch 值相等,如若不等,那么当前线程能够将该锁重偏向至本身),Java 虚拟机须要撤销该偏向锁。这个撤销过程很是麻烦,它要求持有偏向锁的线程到达安全点,再将偏向锁替换成轻量级锁。

若是某一类锁对象的总撤销数超过了一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRebiasThreshold,默认为 20),那么 Java 虚拟机会宣布这个类的偏向锁失效。

具体的作法即是在每一个类中维护一个 epoch 值,你能够理解为第几代偏向锁。当设置偏向锁时,Java 虚拟机须要将该 epoch 值复制到锁对象的标记字段中。

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

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

若是总撤销数超过另外一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRevokeThreshold,默认值为 40),那么 Java 虚拟机会认为这个类已经再也不适合偏向锁。此时,Java 虚拟机会撤销该类实例的偏向锁,而且在以后的加锁过程当中直接为该类实例设置轻量级锁。

总结

本文介绍了 Java 虚拟机中 synchronized 关键字的实现,按照代价由高至低可分为重量级锁、轻量级锁和偏向锁三种。

重量级锁会阻塞、唤醒请求加锁的线程。它针对的是多个线程同时竞争同一把锁的状况。Java 虚拟机采起了自适应自旋,来避免线程在面对很是小的 synchronized 代码块时,仍会被阻塞、唤醒的状况。

轻量级锁采用 CAS 操做,将锁对象的标记字段替换为一个指针,指向当前线程栈上的一块空间,存储着锁对象本来的标记字段。它针对的是多个线程在不一样时间段申请同一把锁的状况。

偏向锁只会在第一次请求时采用 CAS 操做,在锁对象的标记字段中记录下当前线程的地址。在以后的运行过程当中,持有该偏向锁的线程的加锁操做将直接返回。它针对的是锁仅会被同一线程持有的状况。

本文的实践环节,咱们来验证一个坊间传闻:调用 Object.hashCode() 会关闭该对象的偏向锁。

你能够采用参数 -XX:+PrintBiasedLockingStatistics 来打印各种锁的个数。因为 C2 使用的是另一个参数 -XX:+PrintPreciseBiasedLockingStatistics,所以你能够限制 Java 虚拟机仅使用 C1 来即时编译(对应参数 -XX:TieredStopAtLevel=1)。

相关文章
相关标签/搜索