java 中的 synchronized 运行
在 Java 中,我们经常用 synchronized 关键字对程序进行加锁。无论是一个代码块还是静态方法或者实例方法,都可以直接用 synchronized 声明。
当声明 synchronized 代码块时,编译的字节码将包含 monitorenter 和 monitorexit 指令。这两种指令均会消耗操作数栈上的一个引用类型的元素,作为所要加锁解锁的锁对象。
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 any
14 17 14 any
上述代码以及字节码中,包含了一个 monitorenter 指令以及多个 monitorexit 指令。这是因为 Java 虚拟机需要确保所获得的锁在正常执行以及异常执行的路径上都能够被解锁。
关于 monitorenter 和 monitorexit 的作用,可以抽象理解为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
当执行 monitorenter 时,如果目标对象的计数器为 0,那么说明它没有加锁。这个时候,Java 虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加 1。如果目标对象的计数器不为 0,判断该锁对象的持有线程是否为当前线程,如果说,则计数器加 1。否则需要等待,直至持有线程释放该锁。
当执行 monitorexit 时,Java 虚拟机则需要将对象的计数器减 1。当计数器值为 0 时,代表该锁已经被释放掉了。
之所以采用这种计数器的方式,是为了允许同一线程重复获取同一把锁。例如:一个 Java 类中拥有多个 synchronized 方法,那么这些方法之间互相调用,无论直接或间接,都会涉及对同一把锁的重复加锁操作。
接下来总结 HotSpot 虚拟机中具体的锁实现。
重量级锁
重量级锁是 Java 虚拟机中最为基础的锁实现。这种情况下,Java 虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。
Java 线程的阻塞以及唤醒,都是依靠操作系统来完成的。这些操作涉及系统的调用,需要从操作系统的用户态切换至内核态,其开销非常之大。为了尽量避免昂贵的线程阻塞,唤醒操作,Java 虚拟机会在线程进入阻塞状态之前,以及被唤醒后竞争不到锁的情况下,进入自旋状态,在处理器上空跑并且伦旭锁是否被释放。与阻塞状态相比,自旋状态会浪费大量的处理器资源。
举例:以等红绿灯为例,Java 线程的阻塞相当于熄火停车,自旋状态相当于怠速停车。如果红灯时间长,那么熄火停车更胜油。如果红灯时间段,怠速停车更加适合。
对于 Java 虚拟机来说,并不能看到红灯的剩余时间(不能明确知道线程保持自旋状态多久可以加锁)。这时,Java 虚拟机给出可一种自适应的方案,根据以往自旋等待时是否获得锁,来动态调整自旋的时间。
举例:上次没熄火就等到了绿灯,这次就把怠速停车的时间设置久一点。上次没熄火没有等到绿灯,这次就把怠速停车时间设置短一点。
自旋状态还有一个副作用,那便是不公平的锁机制。处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。而处于自选状态的线程,则可能有限获得这把锁。
轻量级锁
深夜的十字路口,车辆来往很少,可能会出现一个路口一辆车在等红绿灯,这样的话车辆通行效率太低。于是,路口的灯设置成黄灯,过往车辆通过路口时注意避让,最后保证依次通过。
Java 虚拟机也存在类似的情形:多个线程在不同的时间段请求通一把锁,不存在锁竞争。针对这种情形,Java 虚拟机采用了轻量级锁,来避免重量级锁的阻塞以及唤醒。
Java 虚拟机是这样区分轻量级锁和重量级锁的。在对象头中的标记字段,最后两位用来表示该对象的锁状态。00 代表轻量级锁,01 代表无锁(或偏向锁),10 代表重量级锁,11 代表跟垃圾回收算法的标记有关。
当加锁时,Java 虚拟机会判断是否是重量锁。如果不死,会在当前线程的当前栈帧中划出一块空间,作为锁的锁记录,并且将锁对象的标记字段复制到该锁记录中。
之后,Java 虚拟机会尝试用 CAS 操作替换锁对象的标记字段。CAS 是一个原子操作,它会比较目标地址的值是否和期望值相等,如果相等,则替换为一个新的值。
举例:当前锁对象的标记字段为 X-XYZ,Java 迅疾会比较该字段是否为 X-X01。如果是,则替换为刚才分配的锁记录的地址。此时,该线程已成功获得这把锁,可以继续执行了。如果不是 X-X01,那么分两种情况:第一,该线程重复获取通一把锁。此时,Java 虚拟机会将锁记录清零,以代表该锁被重复获取。第二,其他线程持有该锁。此时,Java 虚拟机将把锁膨胀为重量级锁,并且阻塞当前线程。
当解锁时,如果当前锁记录的值为 0,则代表重复进入同一把锁,直接返回即可。否则,Java 虚拟机会尝试用 CAS 操作,比较锁对象的标记字段的值是否为当前锁记录的地址。如果是,则替换为锁记录中的值,也就是锁对象原本的标记字段。此时,该线程成功释放这把锁。如果不是,则意味着这把锁已经膨胀为重量级锁。此时 Java 虚拟机会进入重量级锁的释放过程,唤醒因竞争该锁二倍阻塞了的线程。
偏向锁
轻量级锁针对的是乐观的情况,而偏向锁针对就是更加乐观的情况:从始至终只有一个线程请求某一把锁。
如同红路灯路口一直是红灯,当看到你的车来的时候,红灯才会变成绿灯,其他车一概都是红灯,禁止通行。
当加锁的时候,如果该锁对象支持偏向锁,那么 Java 虚拟机会通过 CAS 操作,将当前线程的地址记录在锁对象的标记字段之中,并且标记字段的最后三位设置为 101。
接下来的运行过程中,每当有线程请求这把锁,Java 虚拟机只需判断锁兑现标记的字段中,最后三位是否为 101,是否包含当前线程的地址,以及 epoch 值是否和锁对象的类的 epoch 值相同。如果满足,那么当前线程持有该偏向锁,可以直接返回。
什么是 epoch
先从偏向锁的撤销讲起。当请求加锁的线程和锁对象标价字段保持的线程地址不匹配时,Java 虚拟机需要撤销该偏向锁。这个撤销过程要求持有偏向锁的线程到达安全点,再讲偏向锁替换成轻量级锁。
如果某一类锁对象的总撤销数超过了一个阈值(相关参数的值为:20),那么 Java 虚拟机会宣布这个类的偏向锁失效。
具体的做法,在每一个类中维护一个 epoch 值,可以理解为第几代偏向锁。当设置偏向锁时,Java 虚拟机需要将该 epoch 值复制到锁对象的标记字段中。
在宣布某个类的偏向锁失效是,Java 虚拟机实则将该类的 epoch 值加 1,表示之前那一代的偏向锁已经失效。而新设置的偏向锁则需要复制新的 epoch 值。
为了保证当前持有偏向锁并且已加锁的线程不至于因此丢锁,Java 虚拟机需要遍历所有线程的 Java 栈,找出该类已加锁的实例,并且将他们标记字段中的 epoch 值加 1。该操作需要线程出游安全点状态。如果总撤销数超过另一个阈值(40),此后的加锁过程中直接为该类实例设置轻量级锁。
总结
本文创作灵感来源于 极客时间 郑雨迪老师的《深入拆解 Java 虚拟机》课程,通过课后反思以及借鉴各位学友的发言总结,现整理出自己的知识架构,以便日后温故知新,查漏补缺。
关注本人公众号,第一时间获取最新文章发布,每日更新一篇技术文章。
原文地址:https://www.cnblogs.com/yuepenglei/p/10331327.html