Java synchronized 锁的演变与实现
前言
在 Java 并发编程中,synchronized
是最基础、最常用的同步机制之一。随着 Java 版本的演进,synchronized
的实现从最初的重量级锁逐步优化,引入了偏向锁、轻量级锁等机制,并在后续版本中经历了偏向锁的取消。这些变化不仅大幅提升了性能,还反映了 JVM 开发团队对并发场景的深刻洞察。本文将从 synchronized
的发展历程入手,循序渐进地讲解无锁、偏向锁、轻量级锁、重量级锁的实现原理,分析锁升级的逻辑,深入探讨偏向锁取消的原因,并解析 Mark Word 中锁状态的分布及其底层 C++ 实现。
一、synchronized 的发展历程
1. 早期 Java(JDK 1.0 - JDK 1.4)
在 Java 的早期版本中,synchronized
完全依赖操作系统提供的互斥锁(Mutex),即重量级锁。这种锁的实现直接调用操作系统的内核态函数(如 POSIX 线程的 pthread_mutex_lock
),导致以下问题:
- 性能开销大:每次加锁和解锁都需要用户态到内核态的切换,上下文切换成本高。
- 线程阻塞:竞争失败的线程会被挂起,等待锁释放后再被唤醒,调度开销大。
- 适用场景单一:无论竞争激烈与否,
synchronized
总是使用重量级锁,无法适应低竞争场景。
2. JDK 5:引入并发工具
JDK 5 引入了 java.util.concurrent
包,提供了 Lock
接口和 ReentrantLock
等工具,允许开发者更灵活地控制锁行为。虽然 synchronized
仍是核心机制,但其性能问题促使 JVM 团队开始优化。
3. JDK 6:锁优化时代
JDK 6 引入了锁优化的核心改进,包括偏向锁、轻量级锁、锁粗化和锁消除等技术。这些优化基于以下观察:
- 大多数锁竞争并不激烈,许多锁只被单个线程反复获取。
- 重量级锁在低竞争场景下过于昂贵,轻量级机制可以显著降低开销。
偏向锁和轻量级锁通过在对象头(Mark Word)中存储锁状态,减少了对操作系统内核的依赖,大幅提升了性能。
4. JDK 8 及以后:进一步优化
JDK 8 和后续版本继续优化 synchronized
,包括:
- 锁消除:通过 JIT 编译器分析,移除不必要的锁操作。
- 锁粗化:将多个小范围的锁合并为一个大范围的锁,减少加锁次数。
- 自适应自旋:JVM 根据历史竞争情况动态调整自旋时间,减少线程挂起。
此外,JDK 9 引入了 VarHandle
和内存屏障机制,为开发者提供了更底层的并发控制工具。
5. JDK 15:偏向锁的取消
在 JDK 15(JEP 374)中,偏向锁被标记为废弃,并在 JDK 18(JEP 410)中默认禁用,开发者可以通过 -XX:+UseBiasedLocking
手动启用,但预计未来版本将完全移除。偏向锁取消的原因如下:
原因分析
-
性能收益递减:
- 偏向锁的设计初衷是优化单线程反复获取锁的场景,但在现代多核 CPU 和高并发应用中,锁竞争更加普遍,偏向锁的适用场景减少。
- JIT 编译器的优化(如锁消除、循环展开)已大幅降低单线程场景的锁开销,偏向锁的额外性能提升不明显。
-
撤销偏向锁的高成本:
- 当其他线程竞争偏向锁时,JVM 需要暂停持有锁的线程(通过安全点),撤销偏向锁并升级为轻量级锁或重量级锁。这一过程涉及全局安全点操作,成本极高,尤其在多线程环境中。
- 安全点操作会暂停所有线程,影响 JVM 的吞吐量,特别是在高负载场景下。
-
复杂性与维护成本:
- 偏向锁的实现增加了 JVM 的代码复杂性,包括偏向锁的初始化、撤销、状态管理等逻辑。
- HotSpot JVM 的偏向锁代码(
biased_locking.cpp
)与其他锁机制耦合,维护成本高,而偏向锁的收益不足以抵消这些开销。
-
现代硬件与并发模型的变化:
- 现代硬件(如多核 CPU)更适合轻量级锁和重量级锁的自旋机制,偏向锁的单线程假设与高并发场景不匹配。
- 许多现代应用程序使用线程池或异步框架,对象往往被多个线程访问,偏向锁的单线程优化效果有限。
-
竞争激烈的场景增加:
- 在微服务、云原生等场景下,锁竞争的频率和强度增加,偏向锁频繁撤销导致性能波动。
- 轻量级锁通过 CAS 操作已能高效处理低竞争场景,偏向锁的额外优化显得多余。
影响与替代方案
-
影响:禁用偏向锁后,
synchronized
默认从无锁直接进入轻量级锁或重量级锁,简化了锁状态管理,降低了安全点操作的开销。 -
替代方案:
- 轻量级锁通过 CAS 提供低竞争场景的优化,性能接近偏向锁。
- JIT 编译器的锁消除和锁粗化进一步减少锁开销。
- 开发者可使用
java.util.concurrent
包的工具(如ReentrantLock
)或无锁算法(如 CAS、原子类)实现更灵活的并发控制。
底层变化(C++ 层面)
在 HotSpot JVM 中,偏向锁的禁用简化了锁状态机的实现:
- Mark Word 调整:偏向锁标志位(第 3 位)不再使用,锁状态直接从无锁(
01
)过渡到轻量级锁(00
)或重量级锁(10
)。 - 代码移除:
biased_locking.cpp
中的偏向锁逻辑被逐步移除或标记为可选,减少了安全点相关的开销。 - 性能优化:禁用偏向锁后,JVM 避免了偏向锁撤销时的全局安全点操作,提升了多线程场景的吞吐量。
二、无锁、偏向锁、轻量级锁、重量级锁的逐级讲解
1. 无锁状态
概念
无锁状态是指对象未被任何线程锁定时的状态。对象头的 Mark Word 存储对象的哈希码或 GC 分代年龄等信息。
Mark Word 结构
在 64 位 JVM 中,Mark Word 占 64 位,其结构如下(以 HotSpot JVM 为例):
位数 | 内容 |
---|---|
25 bits | HashCode(未计算时为 0) |
4 bits | 分代年龄(GC 用) |
1 bit | 偏向锁标志(0 表示无偏向,JDK 18 后忽略) |
2 bits | 锁标志(01 表示无锁) |
锁标志 01 表示无锁状态。此时,Mark Word 不存储任何线程信息,对象可被任意线程访问。
为什么锁标志是 01?
- HotSpot JVM 使用最低 2 位作为锁状态标志,
01
是无锁的默认状态。 - 最低 2 位的选择是为了与历史实现兼容,同时方便位运算检查锁状态。
01
与其他状态(如轻量级锁00
、重量级锁10
)区分,确保状态切换时只需修改低几位。- 偏向锁标志(第 3 位)在 JDK 18 后被忽略,简化了状态检查。
底层实现(C++ 层面)
无锁状态下,JVM 不涉及任何锁操作。Mark Word 的初始化在对象创建时完成,代码位于 HotSpot 的 oopDesc
类中:
// hotspot/src/share/vm/oops/oop.hpp
class oopDesc {
volatile markOop _mark; // Mark Word
};
无锁状态的 Mark Word 仅存储元数据(如 HashCode),无需额外同步逻辑。
2. 偏向锁(历史机制)
概念
偏向锁(Biased Locking)针对单线程反复获取同一锁的场景,JVM 假设锁只会被一个线程持有,将线程 ID 记录在 Mark Word 中,后续加锁无需 CAS 操作。偏向锁在 JDK 6 引入,但在 JDK 15 废弃、JDK 18 默认禁用。
Mark Word 结构
位数 | 内容 |
---|---|
54 bits | 线程 ID |
4 bits | Epoch(偏向锁的时间戳) |
4 bits | 分代年龄 |
1 bit | 偏向锁标志(1 表示偏向锁,JDK 18 后忽略) |
2 bits | 锁标志(01) |
锁标志 01 + 偏向锁标志 1 表示偏向锁状态。
为什么锁标志仍是 01?
- 偏向锁复用了无锁的
01
标志,通过额外的偏向锁标志位(第 3 位)区分。 - 这种设计减少了状态切换的开销,因为从无锁到偏向锁只需设置线程 ID 和偏向锁标志。
- 在偏向锁禁用后,第 3 位不再使用,
01
直接表示无锁状态。
工作原理
- 首次加锁时,JVM 检查对象是否可偏向(偏向锁标志为 0,锁标志为 01)。
- 如果可偏向,JVM 使用 CAS 操作将当前线程 ID 写入 Mark Word,并设置偏向锁标志为 1。
- 后续同一线程加锁时,只需检查 Mark Word 中的线程 ID 是否匹配,无需 CAS。
锁撤销
当其他线程竞争锁时,偏向锁会被撤销:
- JVM 暂停持有偏向锁的线程(安全点机制)。
- 将 Mark Word 切换为无锁或轻量级锁状态。
- 撤销偏向锁的开销较高(涉及全局安全点),这是偏向锁被取消的主要原因之一。
底层实现(C++ 层面)
偏向锁的实现位于 HotSpot 的 biased_locking.cpp
和 synchronizer.cpp
中。主要逻辑包括:
-
偏向锁初始化:
// hotspot/src/share/vm/runtime/biased_locking.cpp void BiasedLocking::revoke_at_safepoint(oop obj) { markOop mark = obj->mark(); if (mark->is_biased()) { // 撤销偏向锁,更新 Mark Word obj->set_mark(mark->unbiased_prototype()); } }
-
CAS 操作:通过
Atomic::cmpxchg
实现线程 ID 的写入。 -
安全点检查:偏向锁撤销依赖 JVM 的安全点机制,确保线程暂停时 Mark Word 状态一致。
-
禁用后变化:在 JDK 18 后,
biased_locking.cpp
的逻辑被绕过,JVM 直接使用轻量级锁。
为什么需要偏向锁(历史视角)?
- 场景:许多对象(如集合类)只被单线程访问,偏向锁避免了不必要的 CAS 开销。
- 性能:偏向锁的加锁/解锁仅需几次内存操作,接近无锁性能。
- 取消原因:如前所述,偏向锁的撤销成本高、适用场景减少,性能收益不足以抵消复杂性。
3. 轻量级锁
概念
轻量级锁(Lightweight Locking)用于低竞争场景,线程通过 CAS 操作在栈帧中创建锁记录(Lock Record),将 Mark Word 替换为指向锁记录的指针。
Mark Word 结构
位数 | 内容 |
---|---|
62 bits | 指向栈中锁记录的指针 |
2 bits | 锁标志(00) |
锁标志 00 表示轻量级锁状态。
为什么锁标志是 00?
00
标志与无锁(01
)、重量级锁(10
)区分,确保状态切换的唯一性。- 指针占用 62 位,剩余 2 位用于锁标志,符合 64 位对齐要求。
工作原理
- 线程尝试加锁时,JVM 在线程栈中分配一个锁记录。
- 使用 CAS 将 Mark Word 替换为指向锁记录的指针,原 Mark Word 内容保存到锁记录中。
- 加锁成功后,线程继续执行。
- 解锁时,JVM 使用 CAS 恢复 Mark Word,若失败则说明有竞争,升级为重量级锁。
底层实现(C++ 层面)
轻量级锁的实现位于 synchronizer.cpp
中:
// hotspot/src/share/vm/runtime/synchronizer.cpp
intptr_t ObjectSynchronizer::fast_enter(oop obj, BasicLock* lock, TRAPS) {
markOop mark = obj->mark();
if (mark->is_neutral()) { // 无锁状态
lock->set_displaced_header(mark); // 保存 Mark Word
if (mark == obj->cas_set_mark((markOop)lock, mark)) { // CAS 替换
return; // 轻量级锁成功
}
}
// 失败则膨胀为重量级锁
inflate(obj)->enter(THREAD);
}
- 锁记录:
BasicLock
结构体存储锁记录,包含原 Mark Word 和锁状态。 - CAS 操作:通过
Atomic::cmpxchg_ptr
实现 Mark Word 的替换。 - 锁膨胀:若 CAS 失败,调用
inflate
方法升级为重量级锁。
为什么需要轻量级锁?
- 场景:轻量级锁适用于少量线程竞争的场景,避免直接进入内核态。
- 性能:CAS 操作的开销远低于内核态切换,适合短时间锁持有。
- 偏向锁禁用后的角色:轻量级锁接替了偏向锁的低竞争优化职责。
4. 重量级锁
概念
重量级锁(Heavyweight Locking)是最传统的锁实现,依赖操作系统互斥锁(Monitor)。竞争失败的线程会被挂起,进入等待队列。
Mark Word 结构
位数 | 内容 |
---|---|
62 bits | 指向 Monitor 对象的指针 |
2 bits | 锁标志(10) |
锁标志 10 表示重量级锁状态。
为什么锁标志是 10?
10
标志与轻量级锁(00
)、无锁(01
)区分,确保状态清晰。- Monitor 指针占用 62 位,2 位锁标志保持一致性。
工作原理
- JVM 为对象分配一个
ObjectMonitor
(Monitor 对象),存储锁状态、等待队列等。 - 加锁时,线程通过 CAS 将 Mark Word 替换为 Monitor 指针。
- 竞争失败的线程进入 Monitor 的等待队列,调用操作系统的
park
函数挂起。 - 解锁时,JVM 唤醒等待队列中的线程(通过
unpark
)。
底层实现(C++ 层面)
重量级锁的实现位于 objectMonitor.cpp
和 synchronizer.cpp
中:
// hotspot/src/share/vm/runtime/objectMonitor.cpp
void ObjectMonitor::enter(TRAPS) {
Thread* self = THREAD;
if (try_enter(self)) return; // 快速加锁
// 进入等待队列
ObjectWaiter node(self);
_WaitSetLock.lock();
_WaitSet.append(&node);
_WaitSetLock.unlock();
park(self); // 挂起线程
}
-
Monitor 结构:
class ObjectMonitor { volatile Thread* _owner; // 持有锁的线程 ObjectWaiter* _WaitSet; // 等待队列 volatile intptr_t _count; // 重入计数 };
-
操作系统调用:通过
os::PlatformMutex
实现互斥锁,底层调用pthread_mutex_lock
(Linux)或等效函数。 -
线程挂起/唤醒:通过
park
和unpark
实现,映射到操作系统的信号量或条件变量。
为什么需要重量级锁?
- 场景:高竞争场景下,重量级锁通过线程挂起避免 CPU 空转。
- 正确性:重量级锁保证严格的互斥语义,适合复杂同步场景。
三、锁升级的逻辑与必要性
1. 为什么需要锁升级?
锁升级的核心目标是性能优化。JVM 观察到:
- 单线程场景(历史) :偏向锁通过记录线程 ID 消除 CAS 开销(现已被轻量级锁取代)。
- 低竞争场景:轻量级锁通过 CAS 实现快速加锁,避免内核态切换。
- 高竞争场景:重量级锁通过线程挂起减少 CPU 浪费。
如果始终使用重量级锁,低竞争场景的性能会大幅下降;而如果只用轻量级锁,高竞争场景会导致 CAS 失败率激增。因此,JVM 设计了锁升级机制,根据竞争程度动态选择合适的锁类型。
2. 锁升级流程(JDK 18 后)
-
无锁 → 轻量级锁:
- 对象创建后默认无锁。
- 首次加锁时,JVM 直接进入轻量级锁,使用 CAS 设置锁记录指针。
-
轻量级锁 → 重量级锁:
- 轻量级锁的 CAS 失败次数过多时,JVM 分配 Monitor,切换为重量级锁。
- 这一过程称为锁膨胀(Inflation)。
注:偏向锁禁用后,锁升级流程简化为无锁 → 轻量级锁 → 重量级锁,减少了偏向锁撤销的开销。
3. 为什么设计这么多级别?
- 分层优化:不同锁类型针对不同竞争强度,覆盖从低并发到高并发的场景。
- 渐进式开销:从轻量级锁(CAS 开销)到重量级锁(内核态开销),开销逐级增加,避免“一刀切”。
- 自适应性:JVM 通过运行时统计(如 CAS 失败次数)动态调整锁策略。
- 偏向锁的教训:偏向锁的取消表明,锁级别的设计需要权衡性能与复杂性,过多的优化层级可能得不偿失。
四、Mark Word 中锁类型分布的深入分析
1. Mark Word 的设计原则
Mark Word 是对象头的核心部分,用于存储锁状态、哈希码、GC 信息等。其设计遵循以下原则:
- 空间效率:64 位 Mark Word 需要容纳多种信息,锁状态仅占用低几位。
- 状态区分:通过锁标志(2 位)和偏向锁标志(1 位)实现状态的唯一标识。
- 兼容性:与历史 JVM 实现兼容,避免大规模修改。
2. 锁状态的位分布
状态 | 锁标志(2 位) | 偏向锁标志(1 位) | 描述 |
---|---|---|---|
无锁 | 01 | 0 | 存储 HashCode 或 GC 信息 |
偏向锁(历史) | 01 | 1 | 存储线程 ID 和 Epoch(JDK 18 后禁用)Компания |
轻量级锁 | 00 | - | 存储锁记录指针 |
重量级锁 | 10 | - | 存储 Monitor 指针 |
GC 标记 | 11 | - | 用于垃圾回收 |
为什么这样分布?
-
最低 2 位作为锁标志:
- 2 位可表示 4 种状态(
00
,01
,10
,11
),足以覆盖无锁、轻量级锁、重量级锁和 GC 标记。 - 最低位选择便于位运算(如
mark & 0x3
检查锁状态)。
- 2 位可表示 4 种状态(
-
偏向锁复用 01(历史) :
- 偏向锁与无锁共享
01
,通过第 3 位的偏向锁标志区分,节省状态空间。 - 偏向锁禁用后,第 3 位被忽略,
01
仅表示无锁状态。
- 偏向锁与无锁共享
-
轻量级锁用 00,重量级锁用 10:
00
和10
分别表示指针类型状态(锁记录或 Monitor),与01
(元数据状态)区分。11
保留给 GC 标记,避免与锁状态冲突。
3. 位操作的效率
HotSpot JVM 通过位运算快速检查和切换锁状态。例如:
// 检查锁状态
bool is_locked = (mark & lock_mask_in_place) != unlocked_value;
// 设置轻量级锁
markOop new_mark = (markOop)(lock | lightweight_lock_bits);
这种设计确保锁状态检查和切换的开销极低。偏向锁禁用后,位操作逻辑进一步简化,省去了偏向锁标志的检查。
五、总结
synchronized
的演变是 Java 并发性能优化的缩影。从早期的重量级锁到偏向锁、轻量级锁的引入,再到偏向锁的取消,JVM 通过分层锁机制实现了从单线程到高并发的优化。偏向锁的取消反映了性能与复杂性的权衡,现代 JVM 通过轻量级锁和重量级锁的组合,结合 JIT 编译器的优化,足以应对大多数并发场景。
Mark Word 的设计巧妙地平衡了空间效率和状态区分的需求,而底层 C++ 实现(CAS、安全点、Monitor)保证了锁机制的正确性和高效性。锁升级的逻辑基于竞争强度的动态调整,确保性能与正确性的平衡。通过深入分析偏向锁的取消原因和 Mark Word 的位分布,我们可以看到 JVM 团队在性能优化与代码维护之间的精妙抉择。