Java并发工具:synchronized演进,从JDK 1.6 锁升级到 JDK 24 重构

核心速览

阶段 关键版本 核心特征 解决痛点
1.0 JDK 1.0 - 1.5 重量级锁 实现基本的线程互斥,但性能差,依赖 OS。
2.0 JDK 1.6 - 14 锁升级 引入偏向锁、轻量级锁,大幅降低无竞争下的开销。
3.0 JDK 15 - 23 移除偏向锁 移除偏向锁,简化逻辑;但在虚拟线程下存在 Pinning 缺陷。
4.0 JDK 24+ 虚拟线程适配 重构锁底层 (JEP 491),支持虚拟线程卸载,彻底解决 Pinning。

synchronized锁住了什么?

1、字节码指令

  • monitorenter :当线程执行到同步代码块开头时,尝试获取锁。

  • monitorexit:当线程执行完同步块(或发生异常)时,释放锁。JVM 会自动插入两个 monitorexit 指令,确保异常发生时锁也能被释放,防止死锁。

2、对象头(Mark Word)

Java 对象在内存中都有一个"对象头",其中包含一个 Mark Word,这是最核心的部分。在 64 位 JVM 开启指针压缩的情况下,它通常占用 8 个字节。它是一个多态的数据结构,根据对象状态的不同,存储不同的信息,如下表所示。

锁状态 存储内容 (64位) 标志位
无锁 对象哈希码 + GC 分代年龄 001
偏向锁 线程 ID + 偏向时间戳 + 年龄 101
轻量级锁 指向栈中 Lock Record 的指针 000
重量级锁 指向 Monitor (堆内存) 的指针 10 (重量级)

3、监视器(Monitor)

这是 JVM 内部的一个 C++ 对象(ObjectMonitor)。当锁升级为"重量级锁"时,线程会进入这个 Monitor 的等待队列(EntryList)中阻塞。

在 Java 层面,我们锁的是 Object。但在 JVM 内存(堆)中,一个普通对象由三部分(对象头(Object Header)、实例数据(Instance Data) 和 对齐填充(Padding))组成,锁的秘密全藏在对象头(Object Header) 里。

锁升级(JDK 1.6 - JDK 20)

在 JDK 1.5 及之前,synchronized 是性能杀手。它直接依赖操作系统的互斥锁(Mutex),每次获取锁都要从用户态切换到内核态,开销极大。

JDK 1.6 引入了"锁升级"机制,这是 synchronized 的里程碑。JVM 发现,大多数锁其实不存在竞争,或者只被同一个线程反复获取。于是在JDK 1.6 锁的状态变成了,无锁 → 偏向锁 → 轻量级锁 → 重量级锁 四个状态动态变化的过程。

1、偏向锁

当第一个线程 A 访问同步块时,JVM 通过 CAS 指令 尝试将 Mark Word 中的线程 ID 替换为 A 的 ID。

  • 成功:Mark Word 变为偏向锁状态。线程 A 以后再来,只需对比 Mark Word 里的 ID 是不是自己,完全不需要任何 CAS 或内存屏障操作。
  • 撤销:当线程 B 来竞争时,JVM 必须暂停线程 A(Stop-The-World),检查 A 是否还活着。如果 A 死了,锁升级;如果 A 活着,A 必须释放锁,锁升级为轻量级锁。

因为在高并发场景下,线程频繁切换,导致偏向锁频繁撤销(需要 STW),开销反而比直接上轻量级锁更大。所以在 JDK 15 中默认禁用,JDK 17 中彻底移除

2、轻量级锁

在有少量竞争,但还没到需要阻塞线程地步的场景下,线程通过 CAS (Compare-And-Swap) 操作尝试将对象头替换为指向自己栈帧中"锁记录"的指针。如果成功,获得锁;如果失败,说明有竞争。

整个过程在用户态完成,不用调用操作系统,速度极快。

  • Lock Record (锁记录):线程进入同步块时,JVM 会在当前线程的 Java 栈帧 中分配一块空间,叫 Lock Record。它复制了当前对象头的 Mark Word(作为备份)。
  • CAS 抢占 :JVM 尝试用 CAS 将对象头的 Mark Word 替换为指向栈中 Lock Record 的指针。
    • 成功:获得锁
    • 失败:说明有其他线程竞争。JVM 会检查对象头是否指向当前线程的栈(即重入),如果是,计数器+1;如果不是,说明是真竞争,锁升级为重量级锁
  • 自旋优化:在升级为重量级锁之前,JVM 会让线程进行几次自旋(Spin),即空转 CPU 循环检查锁是否释放。(PS:如果锁持有者马上就释放了,自旋比挂起线程(内核切换)要快得多。)

3、重量级锁

当 CAS 失败且自旋无效,锁"膨胀"为重量级锁。对象头指向堆中的 Monitor 对象,竞争失败的线程被挂起(阻塞),进入操作系统的等待队列。

此时,对象头指向堆中的 ObjectMonitor 对象。其核心 C++ 结构如下:

java 复制代码
class ObjectMonitor {
   int _count;        // 重入次数
   void* _owner;      // 持有锁的线程指针
   ObjectWaiter* _EntryList;  // 等待获取锁的队列 (Blocked)
   ObjectWaiter* _WaitSet;    // 调用 wait() 后的等待队列
};

竞争失败的线程被封装成 ObjectWaiter 放入 _EntryList,并调用操作系统的 park() 函数。线程状态从 Runnable 变为 Blocked,CPU 时间片被剥夺,发生 用户态 -> 内核态 的切换,性能损耗巨大。

虚拟线程带来的挑战(JDK 21 - 23)

JDK 21 引入的虚拟线程给 synchronized 带来了前所未有的危机。

  • 设计冲突:虚拟线程的精髓是"遇阻即挂"------当它做 I/O 阻塞时,应该把底层的载体线程(平台线程)释放出来去干别的事。
  • Pinning(钉住)问题
    • 在 JDK 21-23 中,synchronized 的底层 Monitor 是绑定在载体线程上的。
    • 如果一个虚拟线程持有了 synchronized 锁,然后去执行 I/O 阻塞,JVM 不敢把它卸载(挂起),因为一旦卸载,载体线程就自由了,可能会去抢别的锁,导致锁状态混乱。
    • 结果,虚拟线程被死死"钉"在载体线程上,载体线程无法释放。

JDK 24 底层重构

为了解决 Pinning 问题,JDK 24 推出了 JEP 491,对 synchronized 底层数据结构进行了重构。

核心思想是:让锁不再认识载体线程,只认识虚拟线程。

JDK 24 修改了锁的底层数据结构,使其能够感知虚拟线程:

1、新轻量级锁(New Lightweight Locking)

  • 旧方式:锁记录直接存在线程栈里,对象头指向栈指针。虚拟线程一挂起,栈就没了,指针就失效了。
  • 新方式 :JVM 引入了一个线程局部的 Lock Stack(锁栈),具体流程如下:
     1. 对象头 Mark Word 不再存指针,而是标记为"已锁定",并指向一个全局的元数据或仅仅标记状态。
     2. 实际的锁记录(哪个对象被锁了)被压入线程私有的 Lock Stack。
     3. 卸载时,JVM 把 Lock Stack 的锁记录复制一份到堆内存(虚拟线程对象内部)。
     4. 恢复时,从堆内存把锁记录恢复到新载体线程的 Lock Stack。

2、重量级锁:ID 化

Monitor 不再关心是哪个载体线程在干活,它只认虚拟线程的 ID。在 C++ 的 ObjectMonitor 中:

  • 旧版:_owner 存的是 JavaThread*(C++ 指针)。
  • 新版:_owner 存的是 java.lang.Thread#tid(Java 层的 64 位 ID)。

因为虚拟线程的 tid 是唯一的且持久存在的(即使它被卸载到堆里)。Monitor 只需要对比 ID,就能判断锁是不是被同一个虚拟线程持有(重入),而不需要关心这个虚拟线程此刻挂载在哪个载体线程上。

相关推荐
无籽西瓜a1 小时前
【西瓜带你学设计模式 | 第十三期 - 组合模式】组合模式 —— 树形结构统一处理实现、优缺点与适用场景
java·后端·设计模式·组合模式·软件工程
翊谦10 小时前
Java Agent开发 Milvus 向量数据库安装
java·数据库·milvus
晓晓hh10 小时前
JavaSE学习——迭代器
java·开发语言·学习
Laurence10 小时前
C++ 引入第三方库(一):直接引入源文件
开发语言·c++·第三方库·添加·添加库·添加包·源文件
查古穆10 小时前
栈-有效的括号
java·数据结构·算法
你撅嘴真丑10 小时前
[蓝桥杯 2025 省 B] 生产车间 与 装修报价
职场和发展·蓝桥杯
kyriewen1110 小时前
你点的“刷新”是假刷新?前端路由的瞒天过海术
开发语言·前端·javascript·ecmascript·html5
Java面试题总结10 小时前
Spring - Bean 生命周期
java·spring·rpc
硅基诗人10 小时前
每日一道面试题 10:synchronized 与 ReentrantLock 的核心区别及生产环境如何选型?
java