背景
在 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 官网提供了可以查看对象内存布局的工具