背景
在 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,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,