synchronized 底层了解一下...
时间:2023-03-13 18:30:00
背景
在 JDK1.5 之前,面对 Java 并发问题, synchronized 一招鲜解决方案:
普通同步法锁定当前实例对象
静态同步法锁定当前类别 Class 对象
同步块,锁定括号中配置的对象
以同步块为例:
publicvoidtest(){ synchronized(object){ i ; } }
经过javap -v
编译后的如下:
monitorenter
编译后将指令插入同步代码块的开始位置;monitorexit
插入到方法结束和异常位置(实际隐藏try-finally),每个对象都有一个 monitor 与之相关,当一个线程执行时 monitorenter 当指时,对象对应的指令将获得monitor
获得对象锁的所有权
当另一个线程执行到同步块时,因为它没有对应monitor
所有权将被阻塞。此时,控制权只能交给操作系统,从user mode
切换到kernel mode
, 线程调度和线程状态变化由操作系统负责, 这两种模式下需要频繁切换(上下文转换)。这种寻找核心的行为有点竞争是非常糟糕的,会造成很多成本,所以每个人都称之为重量级锁,自然效率也很低,这给许多童鞋留下了根深蒂固的印象 ——synchronized与其他同步机制相比,关键词性能差
锁的演变
来到 JDK1.6.如何优化锁的轻量级?答案是:
轻量级锁:CPU CAS
如果 CPU 通过简单的 CAS 加锁/释放锁可以处理,这样就不会有上下文切换,自然比重量级锁轻很多。但当竞争激烈时,CAS 再多尝试也是浪费 CPU,权衡,最好升级为重量级锁,阻止线程排队竞争,也有轻量级锁升级为重量级锁的过程
在追求极致的道路上,程序员是永无止境的,HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,同一个线程反复获取锁,如果还按照轻量级锁的方式获取锁(CAS),也有一定的成本。如何降低成本?
偏向锁
偏向锁实际上是锁对象的潜意识「偏心」访问相同的线程,让锁对象记住线程 ID,如果线程再次获得锁,则显示身份 ID 直接拿到锁就好,是一种load-and-test
与过程相比 CAS 自然而轻量级
然而,在多线程环境中,不可能只在同一线程中获得锁。其他线程也需要工作。如果有多个线程竞争,就会有锁升级的过程
这里可以先想一想:偏向锁能绕过轻量级锁,直接升级到重量级锁吗?
都是同一个锁对象,却有多种锁状态,其目的显而易见:
占用的资源越少,程序执行速度越快
偏向锁,轻量锁,两者都不会调用系统互斥(Mutex Lock),只是为了提高性能,多出两种锁状态,这样可以在不同的场景下采取最合适的策略,所以可以总结一下:
偏向锁:在没有竞争的情况下,只有一条线程进入临界区,使用偏向锁
轻量级锁:多线程可交替进入临界区,采用轻量级锁
重量级锁:多线程同时进入临界区,交给操作系统互斥处理
在这里,我们应该了解整体框架,但仍然会有很多问题:
锁在哪里?存储线程 ID 才可以识别同一个线程的?
如何过渡整个升级过程?
要理解这些问题,首先要知道 Java 对象头的结构
认识 Java 对象头
根据常规理解,识别线程 ID 需要一组 mapping 如果映射关系是单独维护的,则应该完成 mapping 考虑线程安全。奥卡姆剃刀的原理,Java 一切都是对象,对象可以用作锁,而不是单独维护 mapping 关系不如集中维护锁的信息 Java 对象本身上
Java 对象头最多由三部分组成:
MarkWord
ClassMetadata Address
Array Length (如果对象是数组)
其中Markword
这是保持锁状态的关键。对象锁状态可从偏向锁升级为轻量级锁,再升级为重量级锁。再加上最初的无锁状态,可以理解为 4 状态。如果你想在一个对象中表达这么多信息,你自然需要使用它。位
存储,在 64 位操作系统是这样存储的(注意颜色标记),想看具体注释的可以看 hotspot(1.8) 源码文件path/hotspot/src/share/vm/oops/markOop.hpp
第 30 行
有了这些基本信息,我们只需要弄清楚,MarkWord 锁信息是如何变化的?
认识偏向锁
简单看上图,还是很抽象的。作为程序员,我们喜欢用代码说话,贴心。 openjdk 官方网站提供了可以查看对象内存布局的工具 JOL (java object layout)
Maven Package
org.openjdk.jol jol-core 0.14
Gradle Package
implementation'org.openjdk.jol:jol-core:0.14'
接下来,让我们通过代码深入了解偏向锁
注意:
上图(从左到右) 代表
高位 -> 低位
JOL 输出结果(从左到右)代表
低位 -> 高位
看测试代码
场景1
publicstaticvoidmain(String[]args){ Objecto=newObject(); log.info("未进入同步块,MarkWord 为:"); &nbp;log.info(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
log.info(("进入同步块,MarkWord 为:"));
log.info(ClassLayout.parseInstance(o).toPrintable());
}
}
来看输出结果:
上面我们用到的 JOL 版本为 0.14
, 带领大家快速了解一下位具体值,接下来我们就要用 0.16
版本查看输出结果,因为这个版本给了我们更友好的说明,同样的代码,来看输出结果:
看到这个结果,你应该是有疑问的,JDK 1.6 之后默认是开启偏向锁的,为什么初始化的代码是无锁状态,进入同步块产生竞争就绕过偏向锁直接变成轻量级锁了呢?
虽然默认开启了偏向锁,但是开启有延迟,大概 4s。原因是 JVM 内部的代码有很多地方用到了synchronized,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,所以就有了延迟策略
我们可以通过参数 -XX:BiasedLockingStartupDelay=0
将延迟改为0,但是不建议这么做。我们可以通过一张图来理解一下目前的情况:
场景2
那我们就代码延迟 5 秒来创建对象,来看看偏向是否生效
public static void main(String[] args) throws InterruptedException {
// 睡眠 5s
Thread.sleep(5000);
Object o = new Object();
log.info("未进入同步块,MarkWord 为:");
log.info(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
log.info(("进入同步块,MarkWord 为:"));
log.info(ClassLayout.parseInstance(o).toPrintable());
}
}
重新查看运行结果:
这样的结果是符合我们预期的,但是结果中的 biasable
状态,在 MarkWord 表格中并不存在,其实这是一种匿名偏向状态,是对象初始化中,JVM 帮我们做的
这样当有线程进入同步块:
可偏向状态:直接就 CAS 替换 ThreadID,如果成功,就可以获取偏向锁了
不可偏向状态:就会变成轻量级锁
那问题又来了,现在锁对象有具体偏向的线程,如果新的线程过来执行同步块会偏向新的线程吗?
场景3
public static void main(String[] args) throws InterruptedException {
// 睡眠 5s
Thread.sleep(5000);
Object o = new Object();
log.info("未进入同步块,MarkWord 为:");
log.info(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
log.info(("进入同步块,MarkWord 为:"));
log.info(ClassLayout.parseInstance(o).toPrintable());
}
Thread t2 = new Thread(() -> {
synchronized (o) {
log.info("新线程获取锁,MarkWord为:");
log.info(ClassLayout.parseInstance(o).toPrintable());
}
});
t2.start();
t2.join();
log.info("主线程再次查看锁对象,MarkWord为:");
log.info(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
log.info(("主线程再次进入同步块,MarkWord 为:"));
log.info(ClassLayout.parseInstance(o).toPrintable());
}
}
来看运行结果,奇怪的事情发生了:
标记1
: 初始可偏向状态标记2
:偏向主线程后,主线程退出同步代码块标记3
: 新线程进入同步代码块,升级成了轻量级锁标记4
: 新线程轻量级锁退出同步代码块,主线程查看,变为不可偏向状态标记5
: 由于对象不可偏向,同场景1主线程再次进入同步块,自然就会用轻量级锁
至此,场景一二三可以总结为一张图:
从这样的运行结果上来看,偏向锁像是“一锤子买卖”,只要偏向了某个线程,后续其他线程尝试获取锁,都会变为轻量级锁,这样的偏向非常有局限性。事实上并不是这样,如果你仔细看标记2(已偏向状态),还有个 epoch 我们没有提及,这个值就是打破这种局限性的关键,在了解 epoch 之前,我们还要了解一个概念——偏向撤销
偏向撤销
在真正讲解偏向撤销之前,需要和大家明确一个概念——偏向锁撤销和偏向锁释放是两码事
撤销:笼统的说就是多个线程竞争导致不能再使用偏向模式的时候,主要是告知这个锁对象不能再用偏向模式
释放:和你的常规理解一样,对应的就是 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/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/bytecodeInterpreter.cpp#l1816
偏向撤销入口:http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/interpreterRuntime.cpp#l608
偏向锁释放入口:http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/bytecodeInterpreter.cpp#l1923
文中有疑问的地方欢迎留言讨论,有错误的地方还请大家帮忙指正
灵魂追问
轻量级和重量级锁,hashcode 存在了什么位置?
参考资料
感谢各路前辈的精华总结,可以让我参考理解:
https://www.oracle.com/technetwork/java/javase/tech/biasedlocking-oopsla2006-preso-150106.pdf
https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf
https://wiki.openjdk.java.net/display/HotSpot/Synchronization#Synchronization-Russel06
https://github.com/farmerjohngit/myblog/issues/12
https://zhuanlan.zhihu.com/p/440994983
https://mp.weixin.qq.com/s/G4z08HfiqJ4qm3th0KtovA
https://www.jianshu.com/p/884eb51266e4
完
往期推荐
“全宇宙首个”用中文编写的操作系统!作者还自创了甲、乙、丙编程语言?
Chrome 100发布:全新图标,CPU、内存占用暴降!
本周 火火火火火 的开源项目
有道无术,术可成;有术无道,止于术
欢迎大家关注Java之道公众号
好文章,我在看❤️