偏向撤销
在真正讲解偏向撤销之前,需要和大家明确一个概念——偏向锁撤销和偏向锁释放是两码事
- 撤销:笼统的说就是多个线程竞争导致不能再使用偏向模式的时候,主要是告知这个锁对象不能再用偏向模式
- 释放:和你的常规理解一样,对应的就是 synchronized 方法的退出或 synchronized 块的结束
何为偏向撤销?
从偏向状态撤回原有的状态,也就是将 MarkWord 的第 3 位(是否偏向撤销)的值,
从 1 变回 0
如果只是一个线程获取锁,再加上「偏心」的机制,是没有理由撤销偏向的,所以偏向的撤销只能发生在有竞争的情况下
想要撤销偏向锁,还不能对持有偏向锁的线程有影响,所以就要等待持有偏向锁的线程到达一个 safepoint 安全点
(这里的安全点是 JVM 为了保证在垃圾回收的过程中引用关系不会发生变化设置的一种安全状态,在这个状态上会暂停所有线程工作), 在这个安全点会挂起获得偏向锁的线程
在这个安全点,线程可能还是处在不同状态的,先说结论(因为源码就是这么写的,可能有疑惑的地方会在后面解释)
- 线程不存活或者活着的线程但退出了同步块,很简单,直接撤销偏向就好了
- 活着的线程但仍在同步块之内,那就要升级成轻量级锁
这个和 epoch 貌似还是没啥关系,因为这还不是全部场景。偏向锁是特定场景下提升程序效率的方案,可并不代表程序员写的程序都满足这些特定场景,比如这些场景(在开启偏向锁的前提下):
- 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作
- 明知有多线程竞争(生产者/消费者队列),还要使用偏向锁,也会导致各种撤销
很显然,这两种场景肯定会导致偏向撤销的,一个偏向撤销的成本无所谓,大量偏向撤销的成本是不能忽视的。那怎么办?既不想禁用偏向锁,还不想忍受大量撤销偏向增加的成本,这种方案就是设计一个有阶梯的底线
批量重偏向(bulk rebias)
这是第一种场景的快速解决方案,以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器 +1
,当这个值达到重偏向阈值(默认20)时:
BiasedLockingBulkRebiasThreshold = 20
JVM 就认为该class的偏向锁有问题,因此会进行批量重偏向, 它的实现方式就用到了我们上面说的 epoch
Epoch
,如其含义「纪元」一样,就是一个时间戳。每个 class 对象会有一个对应的epoch
字段,每个处于偏向锁状态对象的mark word
中也有该字段,其初始值为创建该对象时 class 中的epoch
的值(此时二者是相等的)。每次发生批量重偏向时,就将该值加1,同时遍历JVM中所有线程的栈
- 找到该 class 所有正处于加锁状态的偏向锁对象,将其
epoch
字段改为新值
- class 中不处于加锁状态的偏向锁对象(没被任何线程持有,但之前是被线程持有过的,这种锁对象的 markword 肯定也是有偏向的),保持
epoch
字段值不变
这样下次获得锁时,发现当前对象的epoch
值和class的epoch
,本着今朝不问前朝事 的原则(上一个纪元),那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过 CAS 操作将其mark word
的线程 ID 改成当前线程 ID,这也算是一定程度的优化,毕竟没升级锁;
如果 epoch
都一样,说明没有发生过批量重偏向, 如果 markword
有线程ID,还有其他锁来竞争,那锁自然是要升级的(如同前面举的例子 epoch=0)
批量重偏向是第一阶梯底线,还有第二阶梯底线
批量撤销(bulk revoke)
当达到重偏向阈值后,假设该 class 计数器继续增长,当其达到批量撤销的阈值后(默认40)时,
BiasedLockingBulkRevokeThreshold = 40
JVM就认为该 class 的使用场景存在多线程竞争,会标记该 class 为不可偏向。之后对于该 class 的锁,直接走轻量级锁的逻辑
这就是第二阶梯底线,但是在第一阶梯到第二阶梯的过渡过程中,也就是在彻底禁用偏向锁之前,还给一次改过自新的机会,那就是另外一个计时器:
BiasedLockingDecayTime = 25000
- 如果在距离上次批量重偏向发生的 25 秒之内,并且累计撤销计数达到40,就会发生批量撤销(偏向锁彻底 game over)
- 如果在距离上次批量重偏向发生超过 25 秒之外,那么就会重置在
[20, 40)
内的计数, 再给次机会
大家有兴趣可以写代码测试一下临界点,观察锁对象 markword
的变化
至此,整个偏向锁的工作流程可以用一张图表示:
到此,你应该对偏向锁有个基本的认识了,但是我心中的好多疑问还没有解除,咱们继续看:
HashCode 哪去了
上面场景一,无锁状态,对象头中没有 hashcode;偏向锁状态,对象头还是没有 hashcode,那我们的 hashcode 哪去了?
首先要知道,hashcode 不是创建对象就帮我们写到对象头中的,而是要经过第一次调用 Object::hashCode()
或者System::identityHashCode(Object)
才会存储在对象头中的。第一次生成的 hashcode后,该值应该是一直保持不变的,但偏向锁又是来回更改锁对象的 markword,必定会对 hashcode 的生成有影响,那怎么办呢?,我们来用代码验证:
场景一
public static void main(String[] args) throws InterruptedException { // 睡眠 5s Thread.sleep(5000); Object o = new Object(); log.info("未生成 hashcode,MarkWord 为:"); log.info(ClassLayout.parseInstance(o).toPrintable()); o.hashCode(); log.info("已生成 hashcode,MarkWord 为:"); log.info(ClassLayout.parseInstance(o).toPrintable()); synchronized (o){ log.info(("进入同步块,MarkWord 为:")); log.info(ClassLayout.parseInstance(o).toPrintable()); } }
来看运行结果
结论就是:即便初始化为可偏向状态的对象,一旦调用Object::hashCode()
或者System::identityHashCode(Object)
,进入同步块就会直接使用轻量级锁
场景二
假如已偏向某一个线程,然后生成 hashcode,然后同一个线程又进入同步块,会发生什么呢?来看代码:
public static void main(String[] args) throws InterruptedException { // 睡眠 5s Thread.sleep(5000); Object o = new Object(); log.info("未生成 hashcode,MarkWord 为:"); log.info(ClassLayout.parseInstance(o).toPrintable()); synchronized (o){ log.info(("进入同步块,MarkWord 为:")); log.info(ClassLayout.parseInstance(o).toPrintable()); } o.hashCode(); log.info("生成 hashcode"); synchronized (o){ log.info(("同一线程再次进入同步块,MarkWord 为:")); log.info(ClassLayout.parseInstance(o).toPrintable()); } }
查看运行结果:
结论就是:同场景一,会直接使用轻量级锁
场景三
那假如对象处于已偏向状态,在同步块中调用了那两个方法会发生什么呢?继续代码验证:
public static void main(String[] args) throws InterruptedException { // 睡眠 5s Thread.sleep(5000); Object o = new Object(); log.info("未生成 hashcode,MarkWord 为:"); log.info(ClassLayout.parseInstance(o).toPrintable()); synchronized (o){ log.info(("进入同步块,MarkWord 为:")); log.info(ClassLayout.parseInstance(o).toPrintable()); o.hashCode(); log.info("已偏向状态下,生成 hashcode,MarkWord 为:"); log.info(ClassLayout.parseInstance(o).toPrintable()); } }
来看运行结果:
结论就是:如果对象处在已偏向状态,生成 hashcode 后,就会直接升级成重量级锁
最后用书中的一段话来描述 锁和hashcode 之前的关系
调用 Object.wait() 方法会发生什么?
Object 除了提供了上述 hashcode 方法,还有 wait()
方法,这也是我们在同步块中常用的,那这会对锁产生哪些影响呢?来看代码:
public static void main(String[] args) throws InterruptedException { // 睡眠 5s Thread.sleep(5000); Object o = new Object(); log.info("未生成 hashcode,MarkWord 为:"); log.info(ClassLayout.parseInstance(o).toPrintable()); synchronized (o) { log.info(("进入同步块,MarkWord 为:")); log.info(ClassLayout.parseInstance(o).toPrintable()); log.info("wait 2s"); o.wait(2000); log.info(("调用 wait 后,MarkWord 为:")); log.info(ClassLayout.parseInstance(o).toPrintable()); } }
查看运行结果:
结论就是,wait 方法是互斥量(重量级锁)独有的,一旦调用该方法,就会升级成重量级锁(这个是面试可以说出的亮点内容哦)
最后再继续丰富一下锁对象变化图:
告别偏向锁
看到这个标题你应该是有些慌,为啥要告别偏向锁,因为维护成本有些高了,来看 Open JDK 官方声明,JEP 374: Deprecate and Disable Biased Locking,相信你看上面的文字说明也深有体会
这个说明的更新时间距离现在很近,在 JDK15 版本就已经开始了
一句话解释就是维护成本太高
最终就是,JDK 15 之前,偏向锁默认是 enabled,从 15 开始,默认就是 disabled,除非显示的通过 UseBiasedLocking 开启
其中在 quarkus 上的一篇文章说明的更加直接
偏向锁给 JVM 增加了巨大的复杂性,只有少数非常有经验的程序员才能理解整个过程,维护成本很高,大大阻碍了开发新特性的进程(换个角度理解,你掌握了,是不是就是那少数有经验的程序员了呢?哈哈)
总结
偏向锁可能就这样的走完了它的一生,有些同学可能直接发问,都被 deprecated 了,JDK都 17 了,还讲这么多干什么?
- java 任它发,我用 Java8,这是很多主流的状态,至少你用的版本没有被 deprecated
- 面试还是会被经常问到
- 万一哪天有更好的设计方案,“偏向锁”又以新的形式回来了呢,了解变化才能更好理解背后设计
- 奥卡姆剃刀原理,我们现实中的优化也一样,如果没有必要不要增加实体,如果增加的内容带来很大的成本,不如大胆的废除掉,接受一点落差
之前对于偏向锁我也只是单纯的理论认知,但是为了写这篇文章,我翻阅了很多资料,包括也重新查看 Hotspot 源码,说的这些内容也并不能完全说明偏向锁的整个流程细节,还需要大家具体实践追踪查看,这里给出源码的几个关键入口,方便大家追踪:
- 偏向锁释放入口:http://hg.openjdk.java.net/jd...
文中有疑问的地方欢迎留言讨论,有错误的地方还请大家帮忙指正
灵魂追问
- 轻量级和重量级锁,hashcode 存在了什么位置?