Java synchronized 深入解析:从字节码指令到锁升级机制
在 Java 并发编程中,synchronized 是最基础也是最常用的同步工具。很多人知道怎么用,但对底层的实现原理却知之甚少。今天我们从字节码、JVM 对象头以及锁升级机制三个层面,把 synchronized 的底层逻辑彻底讲清楚。
1. 字节码层面:monitorenter 和 monitorexit
当我们使用 synchronized 关键字时,无论是修饰代码块还是方法,编译器都会在生成的字节码中插入对应的指令。
同步代码块
对于 synchronized(object) { ... } 这种形式,编译器会在同步块的前后分别生成 monitorenter 和 monitorexit 指令。
public void method() {
synchronized (obj) {
System.out.println("hello");
}
}
反编译后的字节码大致如下(简化展示):
0: aload_0 // 加载 this
1: getfield #2 // 获取 obj 引用
4: dup // 复制引用
5: astore_1 // 存入局部变量表
6: monitorenter // 尝试获取锁
7: getstatic #3 // 获取 System.out
...
15: aload_1 // 准备释放锁
16: monitorexit // 正常退出同步块,释放锁
17: goto 25
20: astore_2 // 捕获异常
21: aload_1 // 再次准备释放锁
22: monitorexit // 异常退出同步块,释放锁
23: aload_2
24: athrow
25: return
这里有个细节值得注意:你会看到有两个 monitorexit 指令。第一个用于正常流程退出,第二个隐藏在异常处理逻辑中。这是为了确保即使同步块内部抛出异常,锁也能被正确释放,避免死锁。
同步方法
对于 synchronized 修饰的方法,方法常量池中会设置 ACC_SYNCHRONIZED 标志。当调用该方法的指令(如 invokevirtual)执行时,虚拟机会检查这个标志。如果设置了,线程会先尝试获取锁(实例方法是 this,静态方法是类对象),再执行方法体。方法执行完毕,无论正常返回还是异常抛出,都会自动释放锁。
2. JVM 底层实现:对象头与 Monitor
monitorenter 和 monitorexit 指令背后的具体实现,是 JVM 的核心。其关键在于 Java 对象头 和 Monitor。
2.1 Java 对象头(Mark Word)
在 HotSpot 虚拟机中,每个 Java 对象在内存中的布局分为三部分:对象头、实例数据、对齐填充。其中,对象头 是理解锁的关键。
对象头包含两部分信息:
- Mark Word:存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 等。它是实现锁的'主战场'。
- Klass Pointer:指向类元数据的指针。
为了在极小的空间内存储尽可能多的信息,Mark Word 被设计成一个非固定的动态数据结构。它会根据对象的状态复用自己的存储空间。在 32 位 JVM 下,Mark Word 的最后 2 位(lock)标识了对象的锁状态,锁的升级过程就体现在这 2 位的变化上。
2.2 Monitor(管程/监视器锁)
JVM 为每个对象都关联了一个内置的 Monitor(管程)。monitorenter 指令的本质就是尝试去获取这个对象对应的 Monitor。
一个 Monitor 主要由以下部分组成:
- Owner:当前持有该 Monitor 的线程。初始为
null。 - EntryList:处于
BLOCKED状态的、等待锁的线程队列。 - WaitSet:处于
WAITING状态的、调用了Object.wait()方法的线程队列。
工作流程简述:
当线程执行到 monitorenter 指令时,会尝试进入该对象的 Monitor。如果 Owner 为 null,则成为 Owner;如果已经是 Owner(可重入锁),计数器 +1;如果是其他线程持有,则进入 EntryList 阻塞。执行 monitorexit 时,计数器 -1,减到 0 时释放 Monitor,EntryList 中的线程开始竞争。
3. 锁的升级与优化
在 Java 6 之前,synchronized 是一个重量级锁,性能较差,因为它依赖于操作系统的 Mutex Lock,涉及用户态到内核态的切换。为了减少开销,Java 6 引入了锁升级机制。锁状态从低到高分为四种,升级路径是单向的:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
3.1 偏向锁
- 目的:在没有竞争的情况下,消除整个同步操作。假设大多数情况下锁总是由同一线程多次获得。
- 原理:当一个线程访问同步块时,会在对象头和栈帧中的锁记录里存储偏向的线程 ID。以后该线程再次进入,只需测试 Mark Word 是否指向当前线程,无需 CAS 操作。
- 撤销:一旦出现另一个线程尝试竞争锁,偏向模式结束。持有偏向锁的线程会被挂起,JVM 撤销偏向锁,升级为轻量级锁。
注意:在 Java 15 之后,偏向锁被标记为废弃并默认禁用,因为维护收益已不如从前,但理解其原理依然重要。
3.2 轻量级锁
- 目的:在竞争不激烈('近交替执行')的情况下,避免直接使用重量级锁带来的性能消耗。
- 加锁过程:
- 在当前线程的栈帧中创建一个名为 锁记录 的空间。
- 将对象头的 Mark Word 复制到锁记录中(Displaced Mark Word)。
- 线程尝试使用 CAS 操作将对象头的 Mark Word 替换为指向锁记录的指针。
- 成功:获得锁,Mark Word 最后 2 位设为
00。 - 失败:存在竞争,需自旋或升级。
- 成功:获得锁,Mark Word 最后 2 位设为
- 解锁过程:使用 CAS 操作将 Displaced Mark Word 替换回对象头。如果失败,说明锁已升级,需唤醒被挂起的线程。
3.3 重量级锁
- 触发条件:当轻量级锁竞争失败后,会自旋尝试获取锁一定次数。如果自旋后依然失败,锁就会膨胀为重量级锁。
- 特点:此时 Mark Word 中存储的是指向重量级锁(Monitor)的指针。等待锁的线程都会进入 EntryList,进入
BLOCKED状态,依赖操作系统底层的 Mutex Lock,成本最高。
4. 硬件层面:内存屏障与 CAS
synchronized 的语义保证了原子性、可见性和有序性。
- 可见性与有序性:通过插入 内存屏障 实现。在同步块开始时加
Load Barrier,结束时加Store Barrier,强制刷新工作内存到主内存,禁止指令重排序。 - 原子性:对于简单的
monitorenter/monitorexit,由 Monitor 保证。对于锁升级过程中的状态变更,则是通过 CAS 操作实现的。CAS 是一条 CPU 原子指令(cmpxchg),保证了'比较 - 交换'操作的原子性。
理解这些底层机制,能帮助我们在实际开发中更合理地选择同步策略,写出更高效、安全的并发代码。


