一、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 需要再次从局部变量表加载同一个引用。如果 synchronized 对象是一个临时表达式(而非事先存储的变量),编译器也会生成 dup+astore 来保留一份副本。
其次,编译器必须生成一个隐式的 catch-all 异常处理器(Exception table 中 from 4 to 8 target 13 type any)。如果同步块内部抛出异常,程序跳转到偏移 13,先执行 monitorexit 释放锁(偏移 15),再通过 athrow(偏移 17)重新抛出异常。这确保了异常场景下锁必然被释放——这是 synchronized 相对于手动 Lock.lock()/unlock() 的核心安全保障之一。
第三,异常处理路径中还有一个嵌套的异常处理器(from 13 to 16 target 13 type any),用于处理 monitorexit 自身可能抛出的异常(虽然理论上 monitorexit 不应抛出异常,但如果对象头损坏或 monitor 状态异常,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)。这种”声明式”的同步方式比代码块方式更简洁,因为异常释放由 JVM 保证,字节码中不需要显式的异常表。
代码块 vs 方法的关键差异总结:
| 特性 | 同步代码块 | 同步方法 |
|---|---|---|
| 字节码表示 | monitorenter + monitorexit + 异常表 | ACC_SYNCHRONIZED 标志 |
| 锁对象 | 任意对象(由代码指定) | 实例方法:this;静态方法:Class 对象 |
| 异常安全 | 通过异常表显式保证 | JVM 隐式保证 |
| 灵活性 | 高(可缩小同步范围,选择锁对象) | 低(整个方法被锁定) |
| 字节码体积 | 较大(额外 ~15+ 字节指令和异常表) | 几乎无额外体积 |
二、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 指针);如果未压缩,则对象头为 16 字节(8 字节 Mark Word + 8 字节 klass 指针)。
2.2 32 位 Mark Word 的位布局
monitor_ 字段(也称为 Lock Word 或 Mark Word)是一个多用途字段,根据低位的锁标志解释为不同含义。以 32 位 HotSpot JVM 为例(ART 的逻辑相同但具体位布局可能略有差异):
| 锁状态 | 位[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 期间的转发指针 |
2.3 64 位 Mark Word 的位布局
在 64 位 JVM 中(包括 ART 的 64 位模式),Mark Word 有 64 位,布局如下:
| 锁状态 | 位布局 | 锁位(biased_lock:lock) |
|---|---|---|
| 无锁(unlocked) | unused:25 | hash:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | 0:01 |
| 偏向锁(biased) | thread_id:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | 1:01 |
| 轻量级锁(thin) | lock_record_ptr:62 | lock:2 | 00 |
| 重量级锁(fat) | monitor_ptr:62 | lock:2 | 10 |
| GC 标记(marked) | forwarding_ptr:62 | lock:2 | 11 |
解读要点:
- biased_lock = 1, lock = 01:偏向锁模式。高 54 位存储持有偏向的线程 ID。
- biased_lock = 0, lock = 01:无锁状态。31 位存储 identity hash code。
- lock = 00:轻量级锁。62 位存储指向当前线程栈帧中 Lock Record 的指针。
- lock = 10:重量级锁。62 位存储指向 ObjectMonitor 的指针。
- lock = 11:GC 标记,62 位存储 GC 转发指针。
identity hash code 的惰性计算:当一个对象的 hashCode() 方法首次被调用(且类未重写 hashCode()),JVM 生成一个随机 hash 并存入 Mark Word 的 hash 区域。一旦 hash 被计算并写入,偏向锁就无法再被安装到该对象上(因为 hash 区域被占用了),这个对象直接走轻量级锁或重量级锁路径。这是系统中有些对象永远拿不到偏向锁的一个常见原因。
三、锁升级(Lock Escalation)的完整路径
synchronized 在 JVM/ART 中的实现经历了从”全部重量级”到”偏向锁→轻量级锁→重量级锁”的演化。Java 6 引入的锁升级机制是 synchronized 性能优化的里程碑。
3.1 状态机概览
┌──────────────────────┐ |
核心特征:锁只能升级,不能降级。一旦膨胀为重量级锁,即使竞争消失,锁也不会回退为轻量级锁或偏向锁。降级只在极特殊的情况下发生(如 HotSpot 的 safepoint 批量降级或 ART MonitorPool 压缩时)。
3.2 偏向锁(Biased Lock)
设计目标:解决大多数锁实际上只有一个线程反复获取的场景(如 Vector 和 Hashtable 的内部方法,线程池中的重复操作)。
偏向锁的设计哲学是:如果一个锁至今只被一个线程获取过,那么该线程再次获取时不需要任何同步操作,直接验证 Mark Word 中的线程 ID 是否匹配即可。
获取过程(快速路径):
- 检查 Mark Word 的低 3 位是否为
101(偏向模式),且 Thread ID 为 0(未偏向)或等于当前线程 ID。 - 如果 Thread ID 匹配当前线程:直接成功——这是最快路径,没有任何 CAS 或内存屏障操作。仅需一次读 Mark Word。
- 如果 Thread ID 为 0(未偏向任何线程):执行一次 CAS 将当前线程 ID 写入 Mark Word。CAS 成功则获取偏向锁。
- 如果 CAS 失败(说明发生了竞争):在安全点(safe point)撤销偏向锁,升级为轻量级锁。
撤销(Revocation)的代价:偏向锁的撤销需要等到全局安全点(GC safepoint,所有线程都进入可以被检查的状态)。在安全点中,JVM 暂停持有偏向锁的线程,检查该线程是否还在同步块中:
- 如果持有线程已退出同步块:直接将 Mark Word 恢复为无锁状态。
- 如果持有线程仍在同步块内:将偏向锁升级为轻量级锁(为该线程在栈上分配 Lock Record)。
撤销操作的高昂代价(需要全局 STW 暂停)是偏向锁的最大缺点。当系统中同一把锁频繁在不同线程间切换时,偏向锁反而成为性能负担。
批量重偏向(Bulk Rebias):当一个类的多个实例频繁遇到偏向锁撤销时,JVM 不再逐个撤销,而是执行批量重偏向——在安全点中将属于该类的所有对象的偏向线程 ID 更新为新的主导线程。这大大减少了安全点的频率。
批量撤销(Bulk Revocation):当批量重偏向的执行次数超过阈值(默认 JVM 参数 BiasedLockingBulkRevokeThreshold=40,即某个类的撤销次数达到 40 次),JVM 将该类标记为”不可偏向”,该类后续创建的所有对象都不会再尝试偏向锁,直接走轻量级锁路径。
偏向启动延迟(BiasedLockingStartupDelay):HotSpot 在 JVM 启动后的前 4 秒(默认值)不会启用偏向锁。这是因为启动阶段有大量类加载和初始化的同步操作,如果启用偏向锁会导致大量不必要的撤销。这个延迟可以通过 -XX:BiasedLockingStartupDelay=0 关闭。
Android ART 中的偏向锁:ART 对偏向锁的支持与 HotSpot 有所不同。Android O (8.0) 之前,ART 默认不启用偏向锁。Android O 之后,部分引入了偏向锁机制,但由于移动端 CPU 核数较少(4-8 核),撤销偏向锁需要的安全点暂停对 UI 线程的影响更为显著,所以 ART 的偏向锁实现比 HotSpot 更为保守。相关代码位于 art/runtime/monitor.cc。
3.3 轻量级锁(Thin Lock / Lightweight Lock)
当一个线程尝试获取已被其他线程偏向的锁,且该锁的偏向被撤销后仍有竞争时,锁升级为轻量级锁。轻量级锁通过在线程栈上分配锁记录(Lock Record)实现,依赖 CAS 自旋。
获取过程:
- 在线程栈帧上分配一个 Lock Record 空间(即
BasicLock对象)。 - 将对象的 Mark Word 拷贝到 Lock Record 的
displaced_header字段中(保留原始的 hash code、分代年龄等信息)。 - 通过 CAS 操作,将对象的 Mark Word 替换为指向该 Lock Record 的指针(指针的低 2 位为 00,即 lock = 00)。
- 如果 CAS 成功:轻量级锁获取成功。
- 如果 CAS 失败:说明发生竞争。进入自适应自旋或直接膨胀为重量级锁。
释放过程:
- 将 Lock Record 中的
displaced_header通过 CAS 写回对象的 Mark Word。 - 如果 CAS 成功:锁释放成功,Mark Word 恢复到加锁前的状态。
- 如果 CAS 失败:说明在持有期间有其他线程等待过,锁已膨胀为重量级锁。此时需要走重量级释放路径(唤醒等待线程)。
可重入的轻量级锁:当同一线程重入获取轻量级锁时(例如嵌套 synchronized 块),ART 不是再次分配 Lock Record 并 CAS Mark Word,而是在新的 Lock Record 中将 displaced_header 设为 0(表示”重入”)。释放时检查 displaced_header 是否为 0:为 0 则仅弹出 Lock Record(减少嵌套计数);非 0 则执行 CAS 写回。
自适应自旋(Adaptive Spinning):CAS 失败的线程不会立即阻塞,而是在一定次数的忙循环中反复尝试(自旋)。自旋次数不是固定的:
- 如果在同一个锁上近期的自旋成功了,增加自旋次数(因为这次很可能也会成功)。
- 如果近期自旋总失败,减少自旋次数甚至不自旋直接阻塞。
- JVM 维护每个锁的自旋成功历史,基于指数加权移动平均做自适应决策。
自旋的边界条件:
- 锁的持有者在另一个 CPU 核心上运行时,自旋才有意义(持有者可能很快释放锁)。
- 如果持有者在同一个核心上(或者当前是单核系统),自旋是浪费——因为只有当前线程被挂起后,持有者才有机会执行。
在 ART 的源码 art/runtime/monitor.cc 中,Monitor::MonitorLock 函数在获取重量级锁时也包含了自旋逻辑的尝试。
3.4 重量级锁(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 的队列模型:
当线程尝试获取已被持有的重量级锁时,它被加入到竞争队列(cxq_)中。当锁被释放时,持有者线程从 cxq_ 或 entry_list_ 中取出一个线程并唤醒。这个”两阶段队列”设计(cxq 收集新请求,entry_list 是就绪等待队列)减少了锁释放时的队列操作竞争。
重量级锁的获取/释放通过 Monitor 对象的 Lock / Unlock 方法实现,其底层依赖 Linux 的 futex 系统调用。
四、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(
owner_ == self,否则抛出IllegalMonitorStateException)。 - 释放 Monitor(通过
Monitor::Unlock递减lock_count_直到为 0,然后唤醒队列中的下一个等待者)。 - 将当前线程加入 Monitor 的
wait_set_链表。 - 在条件变量
monitor_cont_上等待(Wait→ 底层futex(FUTEX_WAIT))。 - 被唤醒后,重新获取 Monitor(
Monitor::Lock),恢复lock_count_。
Monitor::Notify:
- 从
wait_set_中取出一个线程(通常是从头部)。 - 通过
monitor_cont_.Signal唤醒该线程(底层futex(FUTEX_WAKE, 1))。 - 被唤醒的线程不会立即执行——它需要先重新获取 Monitor(此时它进入
cxq_或entry_list_队列)。
Monitor::NotifyAll:
- 遍历整个
wait_set_,将每个线程移出并准备好被调度。 - 通过
monitor_cont_.Broadcast唤醒所有等待线程(futex(FUTEX_WAKE, INT_MAX))。
4.2 Wait Morphing 优化
HotSpot JVM 实现了一种称为 “wait morphing” 的优化:当 notify 被调用后,被唤醒的线程不会立即被放入竞争队列,而是保持在一个过渡状态。如果通知线程在退出同步块之前又调用了 notifyAll,这一批线程可以批量地转移到 entry_list,减少了多次 futex 系统调用的开销。
4.3 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)。
futex 的关键性能特征:
- 无竞争时,锁获取只需一次原子 CAS(用户态操作,~10-20 ns)。
- 有竞争且需要阻塞时,需要一次
futex(FUTEX_WAIT)系统调用(~500-1000 ns)。 - 唤醒线程时的
futex(FUTEX_WAKE)也是一个系统调用。
这才是 synchronized “轻量级”的本质——大多数场景下锁获取在用户态即可完成,仅在真正需要挂起/唤醒时才进入内核。
五、重入性(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并真正释放锁。
轻量级锁(Thin Lock): 使用 displaced_header == 0 标记重入。
- 首次获取:Lock Record 的
displaced_header存储原始的 Mark Word。 - 重入:新 Lock Record 的
displaced_header = 0。 - 退出:如果
displaced_header != 0,CAS 将其写回 Mark Word(释放);如果== 0,仅弹出 Lock Record(减少嵌套深度)。
在字节码层面,嵌套的同步块会产生嵌套的 monitorenter/monitorexit 对,JVM 在运行时通过上述计数机制跟踪嵌套深度。例如:
synchronized (obj) { // lock_count_ = 1 |
在 ART 的解释器中(art/runtime/interpreter/interpreter_common.cc),OP_MONITOR_ENTER 处理中调用了 Monitor::MonitorEnter,该函数在执行 Monitor::Lock 前首先检查 owner_ == self,如果是则仅递增 lock_count_,完全避免了重量级锁路径。这确保了重入场景下的最优性能。
六、锁优化技术
6.1 锁粗化(Lock Coarsening)
当编译器(JIT 或 AOT)检测到多个连续的 synchronized 块操作同一个锁对象时,会将这些小块合并为一个更大的同步块,减少 monitorenter/monitorexit 的次数。
示例(优化前):
public void append(String a, String b) { |
优化后(JIT 内部表示):
public void append(String a, String b) { |
锁粗化的权衡:减少锁操作次数(降低 CAS/futex 开销),但可能增大锁持有区间(增加竞争概率)。JIT 编译器基于 profiling 数据决定是否执行粗化——如果该锁的竞争很低,粗化通常是净收益。
6.2 锁消除(Lock Elision)
当逃逸分析(Escape Analysis)确认一个对象只会被当前线程访问(不逃逸到其他线程),编译器可以直接消除对该对象的 synchronized 操作——因为不存在竞争的可能性。
典型场景: StringBuffer 在单线程中的使用:
public String concat() { |
StringBuffer.append() 是 synchronized 方法,但 JIT 通过逃逸分析确定 sb 对象不会离开当前栈帧,因此完全消除了 append 的锁操作。锁消除后,StringBuffer 的性能与 StringBuilder 相当。
必要条件:
- 同步对象必须是局部变量(或方法参数且在调用链中不逃逸)。
- 逃逸分析必须能够证明该对象不暴露给任何其他线程(不通过静态字段、返回、参数传递等方式逃逸)。
锁消除在 ART 的 optimizing compiler 中实现,相关代码在 art/compiler/optimizing/ 的逃逸分析和优化 pass 中。
6.3 锁降级(Lock Deflation)
虽然正常运行时锁不会降级,但 HotSpot 和 ART 都在特定条件下实现了某种形式的”降级”:
HotSpot:在 safepoint 期间(如 GC),JVM 遍历所有 Monitor 对象。如果发现某个 Monitor 没有被任何线程持有且等待队列为空,就将其回收并将对应对象的 Mark Word 恢复为无锁状态。这个过程称为 “monitor deflation”。
ART:MonitorPool(art/runtime/monitor_pool.cc)维护一个全局 Monitor 池。当 Monitor 不再被任何对象引用时,它被回收到池中供后续复用。在 MonitorPool 空间紧张时,ART 会执行 “monitor deflation”,释放不再被引用的 Monitor。这与 HotSpot 的降级不同——ART 的回收是针对 Monitor 对象的池化复用,而非真正的运行时锁状态降级。
6.4 偏向锁在 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 指令。
七、死锁检测机制
ART 的 Monitor 机制包含基本的死锁检测能力。当线程在 Monitor::MonitorLock 中等待锁时,可以检查锁的持有者链:
Thread A 持有 Monitor1 ,等待 Monitor2 |
这种检测仅在重量级锁层面可行(因为只有重量级锁有 owner_ 字段记录持有者)。轻量级锁的 Lock Record 存储在各自线程的栈上,无法跨线程遍历。因此,死锁检测依赖于锁已膨胀到重量级状态。
八、AOSP 关键源码路径总结
| 文件 | 功能 |
|---|---|
art/runtime/monitor.cc |
Monitor 核心实现:MonitorEnter/Exit、Wait/Notify/NotifyAll、InflateThinLocked |
art/runtime/monitor.h |
Monitor 类定义:owner_、lock_count_、wait_set_、entry_list_ |
art/runtime/monitor_pool.cc |
MonitorPool:全局 Monitor 池,Monitor 的创建和回收 |
art/runtime/mirror/object.h |
Object 类定义:klass_、monitor_(LockWord) |
art/runtime/mirror/object.cc |
Object 方法:GetLockWord、SetLockWord、IdentityHashCode 惰性计算 |
art/runtime/base/mutex.h |
Mutex 和 ConditionVariable(封装 futex) |
art/runtime/interpreter/interpreter_common.cc |
解释器处理 OP_MONITOR_ENTER、OP_MONITOR_EXIT |
art/runtime/thread_list.cc |
ThreadList::SuspendAll(安全点/线程挂起机制) |
art/compiler/optimizing/ |
锁粗化、锁消除等编译优化 pass |
九、ART 解释器中的 monitorenter/monitorexit 实现
这部分展示 ART 解释器如何逐条处理 monitorenter 和 monitorexit 指令,从而将字节码语义映射到 Monitor 接口。
9.1 monitorenter 的解释器路径
在 art/runtime/interpreter/interpreter_common.cc 中,OP_MONITOR_ENTER 的处理逻辑(简化)如下:
OP_MONITOR_ENTER: |
Monitor::MonitorEnter 的内部流程(art/runtime/monitor.cc):
Monitor::MonitorEnter(Thread* self, Object* obj, bool try_spinning): |
9.2 monitorexit 的解释器路径
OP_MONITOR_EXIT: |
Monitor::MonitorExit 的内部流程:
Monitor::MonitorExit(Thread* self, Object* obj): |
9.3 嵌套 monitorenter/monitorexit 的 Lock Record 栈
每个线程的栈帧维护一个隐式的 Lock Record 栈:
Thread's stack frame: |
每个 synchronized 块的进入在栈顶压入一个 Lock Record;退出时弹出。如果要退出的 Lock Record 的 displaced_header 不是重入标记(!= 0),则需要与对象头的 Mark Word 做 CAS 交互来真正释放锁。
十、安全点与线程挂起机制
偏向锁撤销、批量重偏向/撤销、GC 根枚举等操作都依赖 ART 的线程挂起机制。核心实现在 art/runtime/thread_list.cc 的 ThreadList::SuspendAll 中。
10.1 安全点(Safepoint)的工作原理
安全点不是一条特定的指令,而是一个协作式协议:
- 请求方(如偏向锁撤销逻辑)调用
ThreadList::SuspendAll(),设置一个全局标志(safepoint flag)。 - 每个运行的线程在特定位置(如方法返回、循环回边、JNI 调用边界)检查这个标志。
- 当线程发现 safepoint flag 被设置时,它将自己挂起(通过 futex wait 或 pthread_cond_wait),并将控制权交还给请求方。
- 请求方等待所有线程都到达安全点后,执行需要全局暂停的操作(如批量重偏向、GC)。
- 操作完成后,请求方清除 safepoint flag 并调用
ThreadList::ResumeAll()唤醒所有线程。
10.2 安全点对 Android 应用的影响
安全点暂停(Stop-The-World, STW)是偏向锁撤销的最大成本:
- 延迟:一个缓慢到达安全点的线程(如在执行大型 memcpy 或在内核中等待 I/O)会拖慢所有其他线程。
- 频率:频繁的偏向锁撤销意味着频繁的 STW 暂停,这会直接表现为 UI 卡顿。
- ART 的策略:正是由于这种 STW 开销,ART 在移动端对偏向锁持谨慎态度——移动应用的帧渲染通常在 16ms 预算内,一次意外的 safepoint 暂停就可能导致掉帧。
十一、LockWord 状态转换实战
以下通过具体的 32 位 hex 值展示 LockWord 在锁升级过程中的变化:
11.1 状态转换序列
初始状态(无锁,刚创建的对象): |
11.2 实战辨识技巧
在调试 crash 或线程 dump 时,可以通过 Mark Word 的低 3 位快速判断对象当前的锁状态:
| Mark Word 低 3 位 | 锁状态 |
|---|---|
.... .... .... .001 (biased_lock=0) |
无锁 |
.... .... .... .101 (biased_lock=1) |
偏向锁 |
.... .... .... .000 |
轻量级锁 |
.... .... .... .010 |
重量级锁 |
.... .... .... .011 |
GC 转发指针 |
十二、synchronized vs JUC 锁 性能对比
12.1 不同场景下的性能特征
| 场景 | synchronized | ReentrantLock | 说明 |
|---|---|---|---|
| 单线程重复获取 | 极快(偏向锁消除所有开销) | 需要 CAS(约 10-15ns) | synchronized 胜出 |
| 双线程低竞争 | 轻量级锁 CAS 自旋,与 ReentrantLock 相当 | CAS 获取,成功则无需系统调用 | 势均力敌 |
| 高竞争(>2 线程) | 膨胀为重量级锁,futex 阻塞 | futex 阻塞,但有公平/非公平模式可选 | ReentrantLock 可选公平策略 |
| 需要 tryLock / 定时获取 | 不支持 | 支持(tryLock, tryLock(time, unit)) | ReentrantLock 唯一选择 |
| 需要 Condition 多条件等待 | 不支持(只有单条件 wait/notify) | 支持(多个 Condition 实例) | ReentrantLock 唯一选择 |
| 需要中断等待 | 不支持(不可中断) | 支持(lockInterruptibly) | ReentrantLock 唯一选择 |
| 锁状态监控 | 有限(通过线程 dump) | 丰富(getQueueLength, hasQueuedThreads, isLocked) | ReentrantLock 更灵活 |
12.2 选择建议
优先使用 synchronized,除非:
- 需要 tryLock(尝试获取,不阻塞)、定时获取或可中断的锁获取。
- 需要多个等待条件(如生产者-消费者中区分”非空”和”非满”两个条件)。
- 需要公平锁(FIFO 保证,避免线程饥饿)。
- 需要运行时监控锁的队列长度和等待状态。
选择 synchronized 的理由:
- 语法简洁,异常自动释放(不需要 finally unlock)。
- JVM 持续优化(锁粗化、锁消除、自适应自旋),这些优化对 synchronized 更成熟。
- ART 的 thin lock 快速路径在低竞争移动场景中通常优于 ReentrantLock 的 CAS 路径。
十三、常见锁问题的调试方法
13.1 死锁检测:通过线程 Dump
死锁的典型 ANR 堆栈特征:
"Thread-2" prio=5 tid=12 Blocked |
如果 Thread-1 等待锁 A(被 Thread-2 持有),同时 Thread-2 等待锁 B(被 Thread-1 持有),则形成经典死锁环。
13.2 通过 ANR trace 分析锁争用
Android ANR trace 文件(/data/anr/traces.txt)中的关键信息:
"main" prio=5 tid=1 Blocked |
关键信息提取:
Blocked状态表示线程正在等待获取 monitor(synchronized)。waiting to lock <address>指明了等待的具体锁对象和地址。held by thread N指明了当前持有者。held mutexes=列出了该线程当前持有的所有锁。
13.3 Android Studio Profiler 的锁监控
Android Studio 的 CPU Profiler 支持记录和可视化锁争用:
- 使用 Debug.startMethodTracingSampling() 或 CPU Profiler 中的 “Java/Kotlin Method Trace” 模式。
- 锁争用事件显示为 “Monitor Contention”。
- 可以查看每个线程在每个锁上的等待时间,快速识别热点锁。
十四、AOSP 关键源码路径总结(续)
| 文件 | 功能 |
|---|---|
art/runtime/thread.cc |
Thread 类:safepoint 检查、线程状态转换 |
art/runtime/thread_list.cc |
ThreadList::SuspendAll/ResumeAll(安全点/线程挂起) |
art/runtime/runtime.cc |
Runtime 初始化:MonitorPool 和锁系统的启动 |
art/runtime/native/java_lang_Object.cc |
Object.wait/notify/notifyAll 的 JNI 注册 |
art/runtime/entrypoints/quick/quick_trampoline_entrypoints.cc |
JIT 编译代码中的锁 entrypoint |
libcore/ojluni/src/main/java/java/lang/Object.java |
Java 层 Object.wait/notify/notifyAll 声明 |
十五、深入 futex:从 Java 到 Linux 内核的完整路径
为了彻底理解 synchronized 的性能特征,以下追踪一个 futex 等待/唤醒的完整系统调用链:
15.1 Wait 路径(Thread.wait → FUTEX_WAIT)
Java: obj.wait() |
15.2 Wake 路径(Thread.notify → FUTEX_WAKE)
Java: obj.notify() |
15.3 性能数据(近似值,x86-64 Linux)
| 操作 | 耗时 | 说明 |
|---|---|---|
| 单体 CAS(无竞争) | ~10-15 ns | 纯用户态,单条 CPU 指令 |
| futex WAIT(无阻塞) | ~100-200 ns | 需要系统调用但立即返回 |
| futex WAIT(阻塞) | ~1-5 us | 包括上下文切换 |
| futex WAKE | ~500-1000 ns | 系统调用 + 被唤醒线程需竞争调度 |
| 偏向锁获取 | ~1-2 ns | 仅一条内存读取(比对 thread_id) |
| 轻量级锁获取(CAS 成功) | ~10-20 ns | 一次 CAS 操作 |
| 轻量级锁获取(CAS 失败,需自旋) | ~50-500 ns | 取决于自旋次数 |
| 重量级锁获取(无竞争) | ~20-50 ns | 一次 CAS 将对象头指向 Monitor |
这些数据揭示了为什么偏向锁在单线程场景下有压倒性优势(1-2ns vs 10-20ns),以及为什么锁升级是一条不归路——一旦进入重量级锁,每次获取都需要 futex 系统调用。
面试问答
Q1:synchronized 在字节码层面有哪两种表示方式?JVM 如何保证异常情况下锁一定被释放?
A:同步代码块使用 monitorenter / monitorexit 指令对;同步方法在 access_flags 中设置 ACC_SYNCHRONIZED 标志(0x0020),JVM 在方法入口隐式获取锁、方法出口隐式释放锁。对于代码块方式,编译器生成异常表,包含覆盖同步块全部范围的 catch-all 处理器(from...to...target type any)。当同步块内抛出异常时,执行流转到异常处理器,先执行 monitorexit 释放锁,再通过 athrow 重新抛出。异常处理器自身也有嵌套异常保护(第二个异常表条目),确保即使 monitorexit 出现异常也能保证正确的异常传播。对于同步方法,释放锁由 JVM 在方法退出时自动保证,包括异常退出。
Q2:简述 synchronized 锁升级的完整路径,每级锁的标志位是什么?
A:锁升级路径为:无锁(001)→ 偏向锁(101)→ 轻量级锁(000)→ 重量级锁(010)。无锁状态下,Mark Word 存储 hashCode 和 GC 分代年龄。偏向锁状态下,Mark Word 存储持有该锁偏向的线程 ID,同一线程再次进入时无需任何同步操作——仅读一次 Mark Word 比对 thread ID 即可。当另一线程尝试获取已偏向的锁时,在安全点撤销偏向,升级为轻量级锁。轻量级锁通过线程栈上的 Lock Record 和 CAS 自旋争夺,Mark Word 存储 Lock Record 指针。当自旋超时、等待线程数超过阈值或调用 wait() 时,锁膨胀为重量级锁,Mark Word 指向 ObjectMonitor 对象,底层通过 futex 实现阻塞和唤醒。锁不会自动降级(HotSpot 中在 safepoint 可能有 deflation,ART 中 MonitorPool 空间紧张时会回收闲置 Monitor,但这属于资源回收而非语义降级)。
Q3:Object.wait() / notify() 在 ART 中是如何实现的?跟 futex 的关系是什么?
A:Object.wait() 的核心逻辑在 art/runtime/monitor.cc 的 Monitor::Wait 方法中。第一步验证当前线程持有 Monitor(owner_ == self),否则抛出 IllegalMonitorStateException。第二步释放 Monitor,将 lock_count_ 清零,唤醒 entry_list 中的下一个等待者。第三步将当前线程加入 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 位锁变量,不需要进入内核;只有需要阻塞等待或唤醒时才通过系统调用进入内核。被 notify 唤醒的线程不会立即执行,而是先进入 entry_list 竞争重新获取 Monitor——这意味着 notify 和实际执行之间可能存在时间窗口,其他线程在此期间可能先获取锁。
Q4:为什么偏向锁在 Android 上可能被禁用?
A:偏向锁通过减少无竞争场景下的同步开销来提高性能,但它的撤销操作需要全局安全点(GC safepoint),即暂停所有线程。在服务器端大量单线程使用锁组件(如线程池、集合类)的场景下,偏向锁收益明显。但在 Android 移动端,CPU 核数少(2-8 核),safepoint 延迟对 UI 帧率和响应时间的影响更显著,且移动应用的线程模型相对简单,偏向锁的收益不如 HotSpot 服务端。因此 ART 在早期版本(Android O 之前)默认关闭偏向锁,后期版本虽然逐步引入,但其实现比 HotSpot 更为保守。开发者不需要关心这一细节——synchronized 语义由 JVM 保证,偏向锁只是运行时性能优化策略,不影响正确性。
Q5:锁粗化和锁消除的工作原理分别是什么?什么条件下会触发?
A:锁粗化由 JIT 编译器执行。当编译器检测到连续的、操作同一锁对象的 synchronized 块时,将这些小块合并为一个大的同步块,减少了 monitorenter/monitorexit 的频次。触发条件是在某个方法内短时间间隔内对同一对象有多次 synchronized 访问——编译器基于控制流分析判断这些块之间没有可能跳转到其他持有同一锁的代码。锁消除也由 JIT 编译器执行,前提是逃逸分析证明同步对象不会被其他线程访问(对象不逃逸当前栈帧)。典型场景是 StringBuffer 在方法局部变量中的使用:StringBuffer 的每个方法都是 synchronized,但 JIT 分析发现 sb 对象不逃逸→消除所有 sb 方法中的锁操作。两者在 ART 的 optimizing compiler 中都有对应实现(art/compiler/optimizing/)。
Q6:synchronized 支持可重入是如何实现的?轻量级锁和重量级锁在重入时的处理有何不同?
A:synchronized 的可重入性保证了同一线程可以多次进入同一锁保护的区域而不会死锁。在重量级锁(Monitor)层面,通过 lock_count_ 字段计数:首次获取时 lock_count_ = 1, owner_ = self;重入时检查 owner_ == self 后仅递增 lock_count_,不需要任何 CAS 或 futex 操作;退出时递减 lock_count_,仅在 lock_count_ == 0 时才释放锁。在轻量级锁层面,通过 Lock Record 的 displaced_header 字段判别:首次获取时 displaced_header 存储原始 Mark Word;重入时新的 Lock Record 的 displaced_header = 0 作为重入标记;退出时若 displaced_header != 0 则 CAS 写回(释放锁),若为 0 则仅弹出 Lock Record(退出一个嵌套层)。两种实现都确保了重入的 O(1) 开销。

