synchronized锁升级过程详解
synchronized 锁的升级是 Java 为了在没有竞争或竞争程度不同的情况下,平衡性能开销和线程安全而设计的一套优化机制。它的核心思想是:当没有竞争时,使用代价最小的锁;当竞争加剧时,再逐步升级为更重量级的锁。
锁的状态从低到高依次为:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。锁只能升级,不能降级(目的是为了提高获得锁和释放锁的效率)。
前置知识:Java 对象头 (Object Header)
要理解锁升级,必须先了解 Java 对象在内存中的布局,尤其是对象头。对象头主要包含两部分:
Mark Word (标记字段):存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等。这是锁升级过程发生的主要场所。
Klass Pointer (类型指针):指向对象元数据的指针,JVM通过它来确定这个对象是哪个类的实例。
对象内存布局:
text
|--------------------------------------------------------------| | Object Header (64 bits) | |------------------------------------|-------------------------| | Mark Word (32 bits) | Klass Word (32 bits) | |------------------------------------|-------------------------|
32位JVM中Mark Word的结构:
text
|-------------------------------------------------------|------| | Mark Word (32 bits) | State| |-------------------------------------------------------|------| | identity_hashcode:25 | age:4 | biased_lock:0 | lock:01| 无锁 | |-------------------------------------------------------|------| | thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:01| 偏向锁| |-------------------------------------------------------|------| | ptr_to_lock_record:30 | lock:00| 轻量锁| |-------------------------------------------------------|------| | ptr_to_heavyweight_monitor:30 | lock:10| 重量锁| |-------------------------------------------------------|------| | | lock:11| GC | |-------------------------------------------------------|------|
注意:在偏向锁状态下,如果计算了对象的 hashCode()
,则无法进入偏向锁状态,因为 Mark Word 没有空间同时存储线程ID和哈希码。
锁升级的详细过程
我们可以用一个流程图来概括整个过程,然后逐一详解:
现在,我们按照流程图的路径,详细讲解每一步。
第一阶段:无锁 -> 偏向锁 (Biased Locking)
目的:在没有实际竞争的情况下,消除整个同步操作(如
monitorenter
和monitorexit
)的开销,进一步提高程序性能。它的假设是“在锁被持有期间,不会有其他线程来竞争”。触发条件:当 JVM 启用了偏向锁(
-XX:+UseBiasedLocking
,JDK 15 后默认关闭),并且一个对象被线程第一次访问时。JDK 15 默认关闭偏向锁是基于大量实际数据和性能分析做出的理性决策。详细过程:
检查状态:线程 A 第一次进入同步块,检查对象头的 Mark Word。
CAS 设置:发现锁标志位是 ‘01’,且偏向锁标志是 ‘0’(无锁状态)。于是,线程 A 使用 CAS 操作,尝试将自己的线程 ID 写入到对象的 Mark Word 中。
如果 CAS 成功,线程 A 就获得了偏向锁。对象头的偏向锁标志位变为 ‘1’,锁标志位仍为 ‘01’。之后,只要没有其他线程来竞争,线程 A 每次进入这个同步块时,只需要检查 Mark Word 中的线程ID是否是自己,如果是,则直接进入,无需任何同步操作。
如果 CAS 失败,说明在之前已经存在竞争,另一个线程 B 已经持有了这个偏向锁。此时,偏向锁需要撤销(Revoke Bias)。
第二阶段:偏向锁 -> 轻量级锁 (Lightweight Locking)
目的:当存在轻微的竞争(即线程交替执行同步块)时,避免直接使用重量级锁带来的操作系统内核态切换开销。
触发条件:当有第二个线程 B 来尝试获取已经被线程 A 偏向的锁时。
详细过程(偏向锁撤销与升级):
暂停线程 (STW):JVM 首先会安全点(Safepoint) 暂停拥有偏向锁的线程 A。
检查状态:检查线程 A 是否仍然活着或仍需要这个锁。
如果线程 A 已经不活动或者已经退出同步代码块,则对象头可以重置为无锁状态,然后重新偏向给线程 B。
如果线程 A 仍然需要这个锁(最常见的情况),则执行偏向锁撤销。
撤销与升级:JVM 会将对象头的 Mark Word 更新为指向线程 A 栈中锁记录(Lock Record)的指针。这个锁记录是 JVM 在各自线程的栈帧中创建的,用于存储锁对象的 Mark Word 拷贝(Displaced Mark Word)。
状态变更:此时,对象头的锁标志位变为 ‘00’,表示对象升级为轻量级锁状态。
唤醒线程:恢复被暂停的线程 A。现在,线程 A 持有轻量级锁,线程 B 会通过自旋(循环CAS) 的方式尝试获取这个轻量级锁。
第三阶段:轻量级锁的运行与升级
运行机制:在轻量级锁状态下,线程通过 CAS 操作(
cmpxchg
指令)来尝试获取锁。它不会立即阻塞自己,而是在一个循环中自旋(忙等待),不断尝试获取锁。优点:如果锁被持有的时间很短,自旋的线程很快就能获得锁,避免了用户态到内核态的切换,开销很小。
触发升级条件:如果线程 B 自旋了一定次数后(JDK 6 引入了自适应自旋,次数由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定),还没有获取到锁。或者此时有第三个线程 C 也来参与竞争。
详细过程:
升级:轻量级锁会膨胀(Inflate) 为重量级锁。
状态变更:JVM 会创建一个监视器(Monitor) 对象(在 HotSpot 中称为
ObjectMonitor
),并将对象头的 Mark Word 更新为指向这个 Monitor 的指针,锁标志位变为 ‘10’。线程阻塞:此时,所有试图获取锁的线程(如线程 B 和 C)都会进入阻塞状态,并被放入 Monitor 的 EntryList 队列中等待。这些线程的状态会从
RUNNABLE
变为BLOCKED
。
第四阶段:重量级锁 (Heavyweight Locking)
运行机制:这是传统的 synchronized 锁的实现,依赖于操作系统底层的互斥量(Mutex Lock) 来实现。
过程:
持有锁的线程 A 在执行完同步代码后,会调用
monitorexit
指令来释放锁。释放锁时,它会唤醒 EntryList 中阻塞的线程,这些线程需要重新竞争锁。
开销:线程的阻塞和唤醒需要操作系统的介入,导致从用户态切换到内核态,开销非常大。
总结与要点
锁状态 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要CAS操作,性能开销极低 | 如果存在锁竞争,会带来额外的撤销开销 | 只有一个线程访问同步块 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁,自旋会消耗CPU | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 竞争的线程不使用CPU自旋,不会消耗CPU | 线程阻塞,响应时间缓慢,用户态内核态切换慢 | 追求吞吐量,同步块执行时间较长,竞争激烈 |
核心思想:synchronized 不再是过去那个“笨重”的代名词。它通过这套精细的锁升级机制,实现了“按需分配”,在绝大多数没有竞争或低竞争的场景下,提供了接近无锁的性能;只有在高竞争的场景下,才会退化为开销较大的重量级锁。这正是 JVM 运行时优化的精髓所在。