Java synchronized 锁的演变与实现

11 阅读16分钟

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 手动启用,但预计未来版本将完全移除。偏向锁取消的原因如下:

原因分析
  1. 性能收益递减

    • 偏向锁的设计初衷是优化单线程反复获取锁的场景,但在现代多核 CPU 和高并发应用中,锁竞争更加普遍,偏向锁的适用场景减少。
    • JIT 编译器的优化(如锁消除、循环展开)已大幅降低单线程场景的锁开销,偏向锁的额外性能提升不明显。
  2. 撤销偏向锁的高成本

    • 当其他线程竞争偏向锁时,JVM 需要暂停持有锁的线程(通过安全点),撤销偏向锁并升级为轻量级锁或重量级锁。这一过程涉及全局安全点操作,成本极高,尤其在多线程环境中。
    • 安全点操作会暂停所有线程,影响 JVM 的吞吐量,特别是在高负载场景下。
  3. 复杂性与维护成本

    • 偏向锁的实现增加了 JVM 的代码复杂性,包括偏向锁的初始化、撤销、状态管理等逻辑。
    • HotSpot JVM 的偏向锁代码(biased_locking.cpp)与其他锁机制耦合,维护成本高,而偏向锁的收益不足以抵消这些开销。
  4. 现代硬件与并发模型的变化

    • 现代硬件(如多核 CPU)更适合轻量级锁和重量级锁的自旋机制,偏向锁的单线程假设与高并发场景不匹配。
    • 许多现代应用程序使用线程池或异步框架,对象往往被多个线程访问,偏向锁的单线程优化效果有限。
  5. 竞争激烈的场景增加

    • 在微服务、云原生等场景下,锁竞争的频率和强度增加,偏向锁频繁撤销导致性能波动。
    • 轻量级锁通过 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 bitsHashCode(未计算时为 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 bitsEpoch(偏向锁的时间戳)
4 bits分代年龄
1 bit偏向锁标志(1 表示偏向锁,JDK 18 后忽略)
2 bits锁标志(01)

锁标志 01 + 偏向锁标志 1 表示偏向锁状态。

为什么锁标志仍是 01?
  • 偏向锁复用了无锁的 01 标志,通过额外的偏向锁标志位(第 3 位)区分。
  • 这种设计减少了状态切换的开销,因为从无锁到偏向锁只需设置线程 ID 和偏向锁标志。
  • 在偏向锁禁用后,第 3 位不再使用,01 直接表示无锁状态。
工作原理
  1. 首次加锁时,JVM 检查对象是否可偏向(偏向锁标志为 0,锁标志为 01)。
  2. 如果可偏向,JVM 使用 CAS 操作将当前线程 ID 写入 Mark Word,并设置偏向锁标志为 1。
  3. 后续同一线程加锁时,只需检查 Mark Word 中的线程 ID 是否匹配,无需 CAS。
锁撤销

当其他线程竞争锁时,偏向锁会被撤销:

  • JVM 暂停持有偏向锁的线程(安全点机制)。
  • 将 Mark Word 切换为无锁或轻量级锁状态。
  • 撤销偏向锁的开销较高(涉及全局安全点),这是偏向锁被取消的主要原因之一。
底层实现(C++ 层面)

偏向锁的实现位于 HotSpot 的 biased_locking.cppsynchronizer.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 位对齐要求。
工作原理
  1. 线程尝试加锁时,JVM 在线程栈中分配一个锁记录。
  2. 使用 CAS 将 Mark Word 替换为指向锁记录的指针,原 Mark Word 内容保存到锁记录中。
  3. 加锁成功后,线程继续执行。
  4. 解锁时,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 位锁标志保持一致性。
工作原理
  1. JVM 为对象分配一个 ObjectMonitor(Monitor 对象),存储锁状态、等待队列等。
  2. 加锁时,线程通过 CAS 将 Mark Word 替换为 Monitor 指针。
  3. 竞争失败的线程进入 Monitor 的等待队列,调用操作系统的 park 函数挂起。
  4. 解锁时,JVM 唤醒等待队列中的线程(通过 unpark)。
底层实现(C++ 层面)

重量级锁的实现位于 objectMonitor.cppsynchronizer.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)或等效函数。

  • 线程挂起/唤醒:通过 parkunpark 实现,映射到操作系统的信号量或条件变量。

为什么需要重量级锁?
  • 场景:高竞争场景下,重量级锁通过线程挂起避免 CPU 空转。
  • 正确性:重量级锁保证严格的互斥语义,适合复杂同步场景。

三、锁升级的逻辑与必要性

1. 为什么需要锁升级?

锁升级的核心目标是性能优化。JVM 观察到:

  • 单线程场景(历史) :偏向锁通过记录线程 ID 消除 CAS 开销(现已被轻量级锁取代)。
  • 低竞争场景:轻量级锁通过 CAS 实现快速加锁,避免内核态切换。
  • 高竞争场景:重量级锁通过线程挂起减少 CPU 浪费。

如果始终使用重量级锁,低竞争场景的性能会大幅下降;而如果只用轻量级锁,高竞争场景会导致 CAS 失败率激增。因此,JVM 设计了锁升级机制,根据竞争程度动态选择合适的锁类型。

2. 锁升级流程(JDK 18 后)

  1. 无锁 → 轻量级锁

    • 对象创建后默认无锁。
    • 首次加锁时,JVM 直接进入轻量级锁,使用 CAS 设置锁记录指针。
  2. 轻量级锁 → 重量级锁

    • 轻量级锁的 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 位)描述
无锁010存储 HashCode 或 GC 信息
偏向锁(历史)011存储线程 ID 和 Epoch(JDK 18 后禁用)Компания
轻量级锁00-存储锁记录指针
重量级锁10-存储 Monitor 指针
GC 标记11-用于垃圾回收
为什么这样分布?
  • 最低 2 位作为锁标志

    • 2 位可表示 4 种状态(00, 01, 10, 11),足以覆盖无锁、轻量级锁、重量级锁和 GC 标记。
    • 最低位选择便于位运算(如 mark & 0x3 检查锁状态)。
  • 偏向锁复用 01(历史)

    • 偏向锁与无锁共享 01,通过第 3 位的偏向锁标志区分,节省状态空间。
    • 偏向锁禁用后,第 3 位被忽略,01 仅表示无锁状态。
  • 轻量级锁用 00,重量级锁用 10

    • 0010 分别表示指针类型状态(锁记录或 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 团队在性能优化与代码维护之间的精妙抉择。