背景
在 JDK1.5 之前,面对 Java 并发问题, synchronized 是一招鲜的解决方案:
- 普通同步方法,锁上当前实例对象
- 静态同步方法,锁上当前类 Class 对象
- 同步块,锁上括号里面配置的对象
拿同步块来举例:
public void test(){ 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
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.14</version> </dependency>
Gradle Package
implementation 'org.openjdk.jol:jol-core:0.14'
接下来我们就通过代码来深入了解一下偏向锁吧
注意:
上图(从左到右) 代表
高位 -> 低位
JOL 输出结果(从左到右)代表
低位 -> 高位
来看测试代码
场景1
public static void main(String[] args) { Object o = new Object(); log.info("未进入同步块,MarkWord 为:"); 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 之前,我们还要了解一个概念——偏向撤销