一、synchronized 的字节码表示
Java 中 synchronized 有两种使用形式:同步方法和同步代码块。它们在字节码层面的表示截然不同。
1.1 同步代码块:monitorenter / monitorexit
同步代码块编译后会生成 monitorenter 和 monitorexit 指令对。以如下代码为例:
public void syncBlock() { |
使用 javap -c -v 反编译得到:
public void syncBlock(); |
这里有三个关键细节:
首先,synchronized 对象引用必须被保存到局部变量中(偏移 0-2:aload_0 + dup + astore_1),因为 monitorenter 会消费栈顶引用后压入,monitorexit 需要再从局部变量加载。
其次,编译器必须生成一个隐式的 catch-all 异常处理器(Exception table 中 from 4 to 8 target 13 type any)。如果同步块内部抛出异常,程序跳转到偏移 13,先执行 monitorexit 释放锁(偏移 15),再通过 athrow(偏移 17)重新抛出异常。这确保了异常场景下锁必然被释放。
第三,异常处理路径中还有一个嵌套的异常处理器(from 13 to 16 target 13 type any),用于处理 monitorexit 自身可能抛出的异常(虽然理论上不会发生,但 JVM 规范要求此处理器存在)。
1.2 同步方法:ACC_SYNCHRONIZED 标志
同步方法不需要 monitorenter/monitorexit 指令,而是在方法表的 access_flags 中设置 ACC_SYNCHRONIZED 标志(0x0020)。对于实例方法,锁对象是 this;对于静态方法,锁对象是该方法所属的 Class 对象。
public synchronized void syncMethod() { |
字节码:
public synchronized void syncMethod(); |
JVM 在执行带有 ACC_SYNCHRONIZED 标志的方法时,自动在方法入口获取锁、在方法出口(正常返回或异常抛出)释放锁。对于实例方法,JVM 在执行第一条指令前隐式执行 monitorenter(this),在 return 指令后或异常路径中隐式执行 monitorexit(this)。
二、Java 对象头与 Mark Word
理解 synchronized 的底层实现,必须先理解 Java 对象的内部结构。在 HotSpot JVM 和 ART 中,每个 Java 对象都有一个对象头(object header)。
2.1 ART 中的 Object 类定义
在 AOSP 的 art/runtime/mirror/object.h 中,Object 类的核心数据结构如下:
// art/runtime/mirror/object.h |
在 32 位 ART 中,对象头共 8 字节(4 字节 klass_ + 4 字节 monitor_)。在 64 位 ART 中,如果开启了压缩指针(compressed oops),对象头可以是 12 字节(8 字节 Mark Word + 4 字节压缩 klass 指针)。
2.2 Mark Word 的位布局
monitor_ 字段(也称为 Lock Word 或 Mark Word)是一个多用途字段,根据低位的锁标志(lock state bits)解释为不同含义。以 32 位 JVM 为例:
| 锁状态 | 位[31:3] | 位[2:0] | 含义 |
|---|---|---|---|
| 无锁(unlocked) | hash_code (25位) + age (4位) | 001 | 存储 identity hash code 和 GC 分代年龄 |
| 偏向锁(biased) | thread_id (23位) + epoch (2位) + age (4位) | 101 | 记录持有偏向锁的线程 ID |
| 轻量级锁(thin) | lock_record_ptr (30位) | 000 | 指向当前线程栈中 Lock Record 的指针 |
| 重量级锁(fat) | monitor_ptr (30位) | 010 | 指向 ObjectMonitor 对象的指针 |
| GC 标记(marked) | forwarding_ptr (30位) | 011 | GC 期间的转发指针 |
在 64 位 ART 中,Mark Word 有 32 位或 64 位两种形式,遵循类似的位布局逻辑但更复杂。这一状态机决定了锁升级的路径。
三、锁升级(Lock Escalation)的完整路径
synchronized 在 JVM/ART 中的实现经历了从「全部重量级」到「偏向锁→轻量级锁→重量级锁」的演化。Java 6 引入的锁升级机制是 synchronized 性能优化的里程碑。
3.1 偏向锁(Biased Lock)
目标:解决大多数锁实际上只有一个线程反复获取的场景(如 Vector 和 Hashtable 的内部方法)。
偏向锁的设计哲学是:如果一个锁至今只被一个线程获取过,那么该线程再次获取时不需要任何同步操作,直接更新 Mark Word 中的线程 ID 即可。
获取过程:
- 检查 Mark Word 的低 3 位是否为
101(偏向模式),且 Thread ID 为 0(未偏向)或等于当前线程 ID。 - 如果 Thread ID 匹配,直接成功——这是最快路径,没有任何 CAS 操作。
- 如果 Thread ID 为 0,执行一次 CAS 将当前线程 ID 写入 Mark Word。CAS 成功则获取偏向锁。
- 如果 CAS 失败(说明发生了竞争),则在安全点(safe point)撤销偏向锁,升级为轻量级锁。
撤销(Revocation):偏向锁的撤销需要等到全局安全点(所有线程都进入可以被检查的状态,即 GC safepoint),暂停持有偏向锁的线程,检查该线程是否还在同步块中。
Android ART 中偏向锁的实现值得注意。由于移动设备通常 CPU 核数较少且线程模型较简单,部分 Android 版本默认关闭了偏向锁。在 ART 中,偏向锁相关逻辑位于 art/runtime/monitor.cc,Monitor::InflateThinLocked 函数处理从 thin lock 到 fat lock 的升级(ART 将偏向锁称为 biased locking,轻量级锁称为 thin lock)。
3.2 轻量级锁(Thin Lock / Lightweight Lock)
当一个线程尝试获取已被其他线程偏向、且撤销偏向后仍有竞争时,锁升级为轻量级锁。轻量级锁通过在线程栈上分配锁记录(Lock Record)实现,依赖 CAS 自旋。
获取过程:
- 在线程栈上分配一个 Lock Record 空间。
- 将对象的 Mark Word 拷贝到 Lock Record 中(保留原始的 hash code 等信息)。
- 通过 CAS 操作将对象的 Mark Word 更新为指向该 Lock Record 的指针。
- 如果 CAS 成功,表示获取轻量级锁成功。如果 CAS 失败,进入自旋等待或锁膨胀。
自旋(Spinning):CAS 失败的线程不会立即阻塞,而是执行一定次数的空循环(busy loop),期望持有锁的线程在自旋期间释放锁。自旋次数通常根据前一次在同一个锁上的自旋成功率和当前 CPU 负载动态调整(自适应自旋)。JVM 参数 -XX:PreBlockSpin 控制初始自旋次数(默认 10),-XX:+UseSpinning 启用自旋(Java 7 之后默认开启)。
在 ART 的源码 art/runtime/monitor.cc 中,Monitor::MonitorLock 函数在获取重量级锁时也包含了自旋逻辑的尝试(先尝试 fast path,失败后再走 futex 阻塞)。
3.3 重量级锁(Heavyweight Lock / Fat Lock)
当自旋等待超时,或有第三个以上线程参与竞争,或锁的持有者调用了 wait(),锁膨胀为重量级锁。重量级锁通过操作系统的互斥机制实现,在 Linux 上是 futex(Fast Userspace muTEX)。
膨胀过程(在 ART 的 Monitor::InflateThinLocked 中):
// art/runtime/monitor.cc(简化逻辑) |
Monitor 对象(即 ObjectMonitor,在 art/runtime/monitor.h 中定义)包含以下核心字段:
class Monitor { |
重量级锁的获取/释放通过 Monitor 对象的 Lock / Unlock 方法实现,其底层依赖 Linux 的 futex 系统调用。
3.4 关于锁降级
重要事实:synchronized 锁不会降级。一旦膨胀为重量级锁,即使竞争消失,锁也不会回退为轻量级锁或偏向锁。只有在 GC 的安全点,JVM 才能安全地检查锁状态并执行降级(HotSpot 在某些 GC 周期中实现了一种有限的批量降级,被称为「bulk revoke bias」和「deflation」)。ART 中的 Monitor 对象有一个 Deflate 机制,在 MonitorPool 空间紧张时回收不再被使用的 Monitor 对象,但这不等同于运行时锁降级。
四、Object.wait / notify 与 futex
4.1 wait/notify 的 Monitor 实现
Object.wait()、Object.notify()、Object.notifyAll() 必须在 synchronized 块内部调用,它们在字节码层面没有特殊指令,而是作为普通的 JNI 本地方法实现。
在 ART 中,这三个方法注册在 Object 类中,实际实现在 art/runtime/monitor.cc:
**
Monitor::Wait**:- 确保当前线程持有该对象的 Monitor。
- 释放 Monitor(通过
Monitor::Unlock),将lock_count_保存后清零。 - 将当前线程加入 Monitor 的
wait_set_链表。 - 在条件变量
monitor_cont_上等待(Wait→futex(FUTEX_WAIT))。 - 被唤醒后,重新获取 Monitor(
Monitor::Lock),恢复lock_count_。
**
Monitor::Notify**:- 从
wait_set_中取出一个线程(通常是从头部)。 - 通过
monitor_cont_.Signal唤醒该线程(底层futex(FUTEX_WAKE)唤醒一个线程)。
- 从
**
Monitor::NotifyAll**:- 遍历整个
wait_set_,通过monitor_cont_.Broadcast唤醒所有等待线程(futex(FUTEX_WAKE, INT_MAX))。
- 遍历整个
4.2 futex 原理
futex 是 Linux 内核提供的快速用户态锁机制,不需要为无竞争情况支付系统调用的开销。futex 将一个 32 位整数作为「锁变量」放在用户空间,线程通过原子操作修改锁变量的值,只有需要阻塞/唤醒等待时才进入内核:
futex(uaddr, FUTEX_WAIT, val, ...):如果*uaddr == val,则将当前线程挂起等待。原子性由内核保证(避免了 TOCTOU 竞态)。futex(uaddr, FUTEX_WAKE, n, ...):唤醒最多n个在uaddr上等待的线程。
ART 中的 ConditionVariable 类(art/runtime/base/mutex.h)封装了 futex 调用。Monitor::monitor_cont_ 是一个 ConditionVariable 实例,它的 Wait、Signal、Broadcast 最终对应 futex(FUTEX_WAIT) 和 futex(FUTEX_WAKE)。
五、重入性(Reentrancy)与锁计数
synchronized 是可重入锁,即同一个线程可以多次获取同一把锁而不会死锁。实现方式是通过 Monitor.lock_count_ 字段计数:
- 首次获取:
lock_count_ = 1,owner_ = current_thread。 - 重入:检查
owner_ == current_thread,则lock_count_ += 1,无需任何 CAS 或 futex 操作。 - 退出:
lock_count_ -= 1,仅当lock_count_ == 0时将owner_ = nullptr并真正释放锁。
在字节码层面,嵌套的同步块会产生嵌套的 monitorenter/monitorexit 对,JVM 在运行时通过 lock_count_ 跟踪嵌套深度。例如:
synchronized (obj) { |
在 ART 的解释器中(art/runtime/interpreter/interpreter_common.cc),OP_MONITOR_ENTER 处理中调用了 Monitor::MonitorEnter,该函数在执行 Monitor::Lock 前首先检查 owner_ == self,如果是则仅递增 lock_count_,完全避免了重量级锁路径。
六、偏向锁在 Android ART 中的特殊处理
Android ART 对偏向锁的处理与 HotSpot 有所不同。在 ART 的源代码 art/runtime/monitor.cc 中,偏向锁的实现称为 “biased locking”,轻量级锁称为 “thin lock”。由于移动端通常 CPU 核数较少(2-8 核),偏向锁的收益不如在服务器端显著——撤销偏向锁需要全局安全点,这在低延迟要求的移动应用中代价较高。
ART 在 Android 8.0(Oreo)前后对锁实现做了重要改进:
锁膨胀的批量处理:
MonitorPool不再为每个对象单独分配 Monitor,而是维护一个全局的 Monitor 池(art/runtime/monitor_pool.cc),通过 CAS 竞争分配。这减少了内存碎片和分配开销。thin lock 的优化:ART 的 thin lock 支持直接存储 Lock Record 指针而不走 Monitor,避免了 Monitor 对象的堆分配。thin lock to fat lock 的转换仅在调用 wait/notify 或线程竞争超时时发生。
AOT 编译优化:ART 的 optimizing compiler 在
art/compiler/optimizing/中对单线程模式下的锁操作做消除优化(lock elision)。如果 AOT 编译器通过逃逸分析确认一个同步对象不会被多个线程访问到,可以直接消除 monitorenter/monitorexit 指令。
面试问答
Q1:synchronized 在字节码层面有哪两种表示方式?JVM 如何保证异常情况下锁一定被释放?
A:同步代码块使用 monitorenter / monitorexit 指令对;同步方法在 access_flags 中设置 ACC_SYNCHRONIZED 标志(0x0020),JVM 在方法入口隐式获取锁、方法出口隐式释放锁。对于代码块方式,编译器生成异常表,包含覆盖同步块全部范围的 catch-all 处理器(from...to...target type any)。当同步块内抛出异常时,执行流转到异常处理器,先执行 monitorexit 释放锁,再通过 athrow 重新抛出。异常处理器自身也有嵌套异常保护,确保即使 monitorexit 失败也能保证正确的异常传播。
Q2:简述 synchronized 锁升级的完整路径,每级锁的标志位是什么?
A:锁升级路径为:无锁(001)→ 偏向锁(101)→ 轻量级锁(000)→ 重量级锁(010)。无锁状态下,Mark Word 存储 hashCode 和 GC 分代年龄。偏向锁状态下,Mark Word 存储持有该锁偏向的线程 ID,同一线程再次进入时无需任何同步操作。当另一线程尝试获取已偏向的锁时,在安全点撤销偏向,升级为轻量级锁。轻量级锁通过线程栈上的 Lock Record 和 CAS 自旋争夺,Mark Word 存储 Lock Record 指针。当自旋超时、等待线程数超过阈值(通常为 2)或调用 wait() 时,锁膨胀为重量级锁,Mark Word 指向 ObjectMonitor 对象,底层通过 futex 实现阻塞和唤醒。锁不会自动降级(HotSpot 中在某些 GC 周期可能有批量降级操作,ART 中 MonitorPool 空间紧张时会回收闲置 Monitor)。
Q3:Object.wait() / notify() 在 ART 中是如何实现的?跟 futex 的关系是什么?
A:Object.wait() 的核心逻辑在 art/runtime/monitor.cc 的 Monitor::Wait 方法中。首先释放 Monitor,清零 lock_count_;然后将当前线程加入 Monitor 的 wait_set_ 链表;最后在条件变量 monitor_cont_ 上调用 Wait。notify / notifyAll 从 wait_set_ 中取出一个或全部线程,通过 monitor_cont_.Signal / Broadcast 唤醒。monitor_cont_ 的类型是 ConditionVariable(art/runtime/base/mutex.h),其内部使用 futex(FUTEX_WAIT) 和 futex(FUTEX_WAKE) 系统调用。futex 的优势在于无竞争时完全在用户态操作一个 32 位锁变量,不需要进入内核;只有需要阻塞等待或唤醒时才通过系统调用进入内核。wait/notify/notifyAll 都必须在持有对象锁的同步块中调用,如果未持有则抛出 IllegalMonitorStateException——这个检查在 Monitor 方法入口处通过 owner_ != self 判定实现。
Q4:为什么偏向锁在 Android 上可能被禁用?
A:偏向锁通过减少无竞争场景下的同步开销来提高性能,但它的撤销操作需要全局安全点(GC safepoint),即暂停所有线程。在服务器端大量单线程使用锁组件(如线程池、集合类)的场景下,偏向锁收益明显。但在 Android 移动端,CPU 核数少(2-8 核),safepoint 延迟对帧率和响应时间的影响更显著,且移动应用的线程模型相对简单,偏向锁的收益不如 HotSpot 服务端。因此 ART 在某些版本中默认关闭偏向锁或减薄偏向锁的逻辑。开发者可以通过 boot.oat 的编译选项评估偏向锁的开关。这对代码无影响——synchronized 语义由 JVM 保证,偏向锁只是运行时优化策略,不影响正确性。

