大概花费了两周的实现看代码(花费了这么久时间有些忏愧, 主要是对 C++,JVM 底层机制, JVM 调试以及汇编代码不太熟), 将 synchronized 涉及到的代码基本都看了一遍, 其中还包括在 JVM 中添加日志验证自己的猜想, 总的来说目前对 synchronized 这块有了一个比较全面清晰的认识, 但水平有限, 有些细节难免有些疏漏, 还望请大家指正.
本篇文章将对 synchronized 机制做个大致的介绍, 包括用以承载锁状态的对象头, 锁的几种形式, 各种形式锁的加锁和解锁流程, 什么时候会发生锁升级. 需要注意的是本文旨在介绍背景和概念, 在讲述一些流程的时候, 只提到了主要 case, 对于实现细节, 运行时的不同分支都在后面的文章中详细分析.
本人看的 JVM 版本是 jdk8u, 具体版本号以及代码可以在这里看到.
synchronized 简介
Java 中提供了两种实现同步的基础语义: synchronized 方法和 synchronized 块, 我们来看个 demo:
- public class SyncTest {
- public void syncBlock(){
- synchronized (this){
- System.out.println("hello block");
- }
- }
- public synchronized void syncMethod(){
- System.out.println("hello method");
- }
- }
当 SyncTest.java 被编译成 class 文件的时候, synchronized 关键字和 synchronized 方法的字节码略有不同, 我们可以用 javap -v 命令查看 class 文件对应的 JVM 字节码信息, 部分信息如下:
- {
- public void syncBlock();
- descriptor: ()V
- flags: ACC_PUBLIC
- Code:
- stack=2, locals=3, args_size=1
- 0: aload_0
- 1: dup
- 2: astore_1
- 3: monitorenter // monitorenter 指令进入同步块
- 4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
- 7: ldc #3 // String hello block
- 9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
- 12: aload_1
- 13: monitorexit // monitorexit 指令退出同步块
- 14: goto 22
- 17: astore_2
- 18: aload_1
- 19: monitorexit // monitorexit 指令退出同步块
- 20: aload_2
- 21: athrow
- 22: return
- Exception table:
- from to target type
- 4 14 17 any
- 17 20 17 any
- public synchronized void syncMethod();
- descriptor: ()V
- flags: ACC_PUBLIC, ACC_SYNCHRONIZED // 添加了 ACC_SYNCHRONIZED 标记
- Code:
- stack=2, locals=1, args_size=1
- 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
- 3: ldc #5 // String hello method
- 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
- 8: return
- }
从上面的中文注释处可以看到, 对于 synchronized 关键字而言, javac 在编译时, 会生成对应的 monitorenter 和 monitorexit 指令分别对应 synchronized 同步块的进入和退出, 有两个 monitorexit 指令的原因是: 为了保证抛异常的情况下也能释放锁, 所以 javac 为同步代码块添加了一个隐式的 try-finally, 在 finally 中会调用 monitorexit 命令释放锁. 而对于 synchronized 方法而言, javac 为其生成了一个 ACC_SYNCHRONIZED 关键字, 在 JVM 进行方法调用时, 发现调用的方法被 ACC_SYNCHRONIZED 修饰, 则会先尝试获得锁.
在 JVM 底层, 对于这两种 synchronized 语义的实现大致相同, 在后文中会选择一种进行详细分析.
因为本文旨在分析 synchronized 的实现原理, 因此对于其使用的一些问题就不赘述了, 不了解的朋友可以看看这篇文章.
锁的几种形式
传统的锁 (也就是下文要说的重量级锁) 依赖于系统的同步函数, 在 Linux 上使用 mutex 互斥锁, 最底层实现依赖于 futex, 关于 futex 可以看我之前的文章 https://github.com/farmerjohngit/myblog/issues/8 , 这些同步函数都涉及到用户态和内核态的切换, 进程的上下文切换, 成本较高. 对于加了 synchronized 关键字但运行时并没有多线程竞争, 或两个线程接近于交替执行的情况, 使用传统锁机制无疑效率是会比较低的.
在 JDK 1.6 之前, synchronized 只有传统的锁机制, 因此给开发者留下了 synchronized 关键字相比于其他同步机制性能不好的印象.
在 JDK 1.6 引入了两种新型锁机制: 偏向锁和轻量级锁, 它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题.
在看这几种锁机制的实现前, 我们先来了解下对象头, 它是实现多种锁机制的基础.
对象头
因为在 Java 中任意对象都可以用作锁, 因此必定要有一个映射关系, 存储该对象以及其对应的锁信息(比如当前哪个线程持有锁, 哪些线程在等待). 一种很直观的方法是, 用一个全局 map, 来存储这个映射关系, 但这样会有一些问题: 需要对 map 做线程安全保障, 不同的 synchronized 之间会相互影响, 性能差; 另外当同步对象较多时, 该 map 可能会占用比较多的内存.
所以最好的办法是将这个映射关系存储在对象头中, 因为对象头本身也有一些 hashcode,GC 相关的数据, 所以如果能将锁信息与这些信息共存在对象头中就好了.
在 JVM 中, 对象在内存中除了本身的数据外还会有个对象头, 对于普通对象而言, 其对象头中有两类信息: mark Word 和类型指针. 另外对于数组而言还会有一份记录数组长度的数据.
类型指针是指向该对象所属类对象的指针, mark Word 用于存储对象的 HashCode,GC 分代年龄, 锁状态等信息. 在 32 位系统上 mark Word 长度为 32 字节, 64 位系统上长度为 64 字节. 为了能在有限的空间里存储下更多的数据, 其存储格式是不固定的, 在 32 位系统上各状态的格式如下:
可以看到锁信息也是存在于对象的 mark Word 中的. 当对象状态为偏向锁 (biasable) 时, mark Word 存储的是偏向的线程 ID; 当状态为轻量级锁 (lightweight locked) 时, mark Word 存储的是指向线程栈中 Lock Record 的指针; 当状态为重量级锁 (inflated) 时, 为指向堆中的 monitor 对象的指针.
重量级锁
重量级锁是我们常说的传统意义上的锁, 其利用操作系统底层的同步机制去实现 Java 中的线程同步.
重量级锁的状态下, 对象的 mark Word 为指向一个堆中 monitor 对象的指针.
一个 monitor 对象包括这么几个关键字段: cxq(下图中的 ContentionList),EntryList ,WaitSet,owner.
其中 cxq ,EntryList ,WaitSet 都是由 ObjectWaiter 的链表结构, owner 指向持有锁的线程.
当一个线程尝试获得锁时, 如果该锁已经被占用, 则会将该线程封装成一个 ObjectWaiter 对象插入到 cxq 的队列尾部, 然后暂停当前线程. 当持有锁的线程释放锁前, 会将 cxq 中的所有元素移动到 EntryList 中去, 并唤醒 EntryList 的队首线程.
如果一个线程在同步块中调用了 Object#wait 方法, 会将该线程对应的 ObjectWaiter 从 EntryList 移除并加入到 WaitSet 中, 然后释放锁. 当 wait 的线程被 notify 之后, 会将对应的 ObjectWaiter 从 WaitSet 移动到 EntryList 中.
以上只是对重量级锁流程的一个简述, 其中涉及到的很多细节, 比如 ObjectMonitor 对象从哪来? 释放锁时是将 cxq 中的元素移动到 EntryList 的尾部还是头部? notfiy 时, 是将 ObjectWaiter 移动到 EntryList 的尾部还是头部?
关于具体的细节, 会在重量级锁的文章中分析.
轻量级锁
JVM 的开发者发现在很多情况下, 在 Java 程序运行时, 同步块中的代码都是不存在竞争的, 不同的线程交替的执行同步块中的代码. 这种情况下, 用重量级锁是没必要的. 因此 JVM 引入了轻量级锁的概念.
线程在执行同步块之前, JVM 会先在当前的线程的栈帧中创建一个 Lock Record, 其包括一个用于存储对象头中的 mark Word(官方称之为 Displaced Mark Word)以及一个指向对象的指针. 下图右边的部分就是一个 Lock Record.
加锁过程
1. 在线程栈中创建一个 Lock Record, 将其 obj(即上图的 Object reference)字段指向锁对象.
2. 直接通过 CAS 指令将 Lock Record 的地址存储在对象头的 mark Word 中, 如果对象处于无锁状态则修改成功, 代表该线程获得了轻量级锁. 如果失败, 进入到步骤 3.
3. 如果是当前线程已经持有该锁了, 代表这是一次锁重入. 设置 Lock Record 第一部分 (Displaced Mark Word) 为 null, 起到了一个重入计数器的作用. 然后结束.
4. 走到这一步说明发生了竞争, 需要膨胀为重量级锁.
解锁过程
1. 遍历线程栈, 找到所有 obj 字段等于当前锁对象的 Lock Record.
2. 如果 Lock Record 的 Displaced Mark Word 为 null, 代表这是一次重入, 将 obj 设置为 null 后 continue.
3. 如果 Lock Record 的 Displaced Mark Word 不为 null, 则利用 CAS 指令将对象头的 mark Word 恢复成为 Displaced Mark Word. 如果成功, 则 continue, 否则膨胀为重量级锁.
偏向锁
Java 是支持多线程的语言, 因此在很多二方包, 基础库中为了保证代码在多线程的情况下也能正常运行, 也就是我们常说的线程安全, 都会加入如 synchronized 这样的同步语义. 但是在应用在实际运行时, 很可能只有一个线程会调用相关同步方法. 比如下面这个 demo:
- import java.util.ArrayList;
- import java.util.List;
- public class SyncDemo1 {
- public static void main(String[] args) {
- SyncDemo1 syncDemo1 = new SyncDemo1();
- for (int i = 0; i <100; i++) {
- syncDemo1.addString("test:" + i);
- }
- }
- private List<String> list = new ArrayList<>();
- public synchronized void addString(String s) {
- list.add(s);
- }
- }
在这个 demo 中为了保证对 list 操纵时线程安全, 对 addString 方法加了 synchronized 的修饰, 但实际使用时却只有一个线程调用到该方法, 对于轻量级锁而言, 每次调用 addString 时, 加锁解锁都有一个 CAS 操作; 对于重量级锁而言, 加锁也会有一个或多个 CAS 操作(这里的'一个','多个'数量词只是针对该 demo, 并不适用于所有场景).
在 JDK1.6 中为了提高一个对象在一段很长的时间内都只被一个线程用做锁对象场景下的性能, 引入了偏向锁, 在第一次获得锁时, 会有一个 CAS 操作, 之后该线程再获取锁, 只会执行几个简单的命令, 而不是开销相对较大的 CAS 命令. 我们来看看偏向锁是如何做的.
对象创建
当 JVM 启用了偏向锁模式 (1.6 以上默认开启), 当新创建一个对象的时候, 如果该对象所属的 class 没有关闭偏向锁模式(什么时候会关闭一个 class 的偏向模式下文会说, 默认所有 class 的偏向模式都是是开启的), 那新创建对象的 mark Word 将是可偏向状态, 此时 mark Word 中的 thread id(参见上文偏向状态下的 mark Word 格式) 为 0, 表示未偏向任何线程, 也叫做匿名偏向(anonymously biased).
加锁过程
case 1: 当该对象第一次被线程获得锁的时候, 发现是匿名偏向状态, 则会用 CAS 指令, 将 mark Word 中的 thread id 由 0 改成当前线程 Id. 如果成功, 则代表获得了偏向锁, 继续执行同步块中的代码. 否则, 将偏向锁撤销, 升级为轻量级锁.
case 2: 当被偏向的线程再次进入同步块时, 发现锁对象偏向的就是当前线程, 在通过一些额外的检查后(细节见后面的文章), 会往当前线程的栈中添加一条 Displaced Mark Word 为空的 Lock Record 中, 然后继续执行同步块的代码, 因为操纵的是线程私有的栈, 因此不需要用到 CAS 指令; 由此可见偏向锁模式下, 当被偏向的线程再次尝试获得锁时, 仅仅进行几个简单的操作就可以了, 在这种情况下, synchronized 关键字带来的性能开销基本可以忽略.
case 3. 当其他线程进入同步块时, 发现已经有偏向的线程了, 则会进入到撤销偏向锁的逻辑里, 一般来说, 会在 safepoint 中去查看偏向的线程是否还存活, 如果存活且还在同步块中则将锁升级为轻量级锁, 原偏向的线程继续拥有锁, 当前线程则走入到锁升级的逻辑里; 如果偏向的线程已经不存活或者不在同步块中, 则将对象头的 mark Word 改为无锁状态(unlocked), 之后再升级为轻量级锁.
由此可见, 偏向锁升级的时机为: 当锁已经发生偏向后, 只要有另一个线程尝试获得偏向锁, 则该偏向锁就会升级成轻量级锁. 当然这个说法不绝对, 因为还有批量重偏向这一机制.
解锁过程
当有其他线程尝试获得锁时, 是根据遍历偏向线程的 lock record 来确定该线程是否还在执行同步块中的代码. 因此偏向锁的解锁很简单, 仅仅将栈中的最近一条 lock record 的 obj 字段设置为 null. 需要注意的是, 偏向锁的解锁步骤中并不会修改对象头中的 thread id.
下图展示了锁状态的转换流程:
另外, 偏向锁默认不是立即就启动的, 在程序启动后, 通常有几秒的延迟, 可以通过命令 -XX:BiasedLockingStartupDelay=0 来关闭延迟.
批量重偏向与撤销
从上文偏向锁的加锁解锁过程中可以看出, 当只有一个线程反复进入同步块时, 偏向锁带来的性能开销基本可以忽略, 但是当有其他线程尝试获得锁时, 就需要等到 safe point 时将偏向锁撤销为无锁状态或升级为轻量级 / 重量级锁. safe point 这个词我们在 GC 中经常会提到, 其代表了一个状态, 在该状态下所有线程都是暂停的(大概这么个意思), 详细可以看这篇文章. 总之, 偏向锁的撤销是有一定成本的, 如果说运行时的场景本身存在多线程竞争的, 那偏向锁的存在不仅不能提高性能, 而且会导致性能下降. 因此, JVM 中增加了一种批量重偏向 / 撤销的机制.
存在如下两种情况:(见官方论文第 4 小节):
1. 一个线程创建了大量对象并执行了初始的同步操作, 之后在另一个线程中将这些对象作为锁进行之后的操作. 这种 case 下, 会导致大量的偏向锁撤销操作.
2. 存在明显多线程竞争的场景下使用偏向锁是不合适的, 例如生产者 / 消费者队列.
批量重偏向 (bulk rebias) 机制是为了解决第一种场景. 批量撤销 (bulk revoke) 则是为了解决第二种场景.
其做法是: 以 class 为单位, 为每个 class 维护一个偏向锁撤销计数器, 每一次该 class 的对象发生偏向撤销操作时, 该计数器 + 1, 当这个值达到重偏向阈值 (默认 20) 时, JVM 就认为该 class 的偏向锁有问题, 因此会进行批量重偏向. 每个 class 对象会有一个对应的 epoch 字段, 每个处于偏向锁状态对象的 mark Word 中也有该字段, 其初始值为创建该对象时, class 中的 epoch 的值. 每次发生批量重偏向时, 就将该值 + 1, 同时遍历 JVM 中所有线程的栈, 找到该 class 所有正处于加锁状态的偏向锁, 将其 epoch 字段改为新值. 下次获得锁时, 发现当前对象的 epoch 值和 class 的 epoch 不相等, 那就算当前已经偏向了其他线程, 也不会执行撤销操作, 而是直接通过 CAS 操作将其 mark Word 的 Thread Id 改成当前线程 Id.
当达到重偏向阈值后, 假设该 class 计数器继续增长, 当其达到批量撤销的阈值后(默认 40),JVM 就认为该 class 的使用场景存在多线程竞争, 会标记该 class 为不可偏向, 之后, 对于该 class 的锁, 直接走轻量级锁的逻辑.
End
Java 中的 synchronized 有偏向锁, 轻量级锁, 重量级锁三种形式, 分别对应了锁只被一个线程持有, 不同线程交替持有锁, 多线程竞争锁三种情况. 当条件不满足时, 锁会按偏向锁 ->轻量级锁 ->重量级锁 的顺序升级. JVM 种的锁也是能降级的, 只不过条件很苛刻, 不在我们讨论范围之内. 该篇文章主要是对 Java 的 synchronized 做个基本介绍, 后文会有更详细的分析.
来源: https://juejin.im/post/5bfe6ddee51d45491b0163eb