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,就能判断锁是不是被同一个虚拟线程持有(重入),而不需要关心这个虚拟线程此刻挂载在哪个载体线程上。

相关推荐
猪猪拆迁队2 小时前
虚拟工厂仿真引擎的架构设计:让一条产线可编程、可观测、可干预
后端·ai编程
字节跳动数据库2 小时前
文章分享——相似函数处理方法
人工智能·后端·程序员
云技纵横2 小时前
@Transactional 失效的 7 种场景:第 5 种最难排查
后端
用户6757049885022 小时前
你知道 Go 结构体和结构体指针调用的区别吗?一文带你彻底搞懂!
后端·go
程序员cxuan3 小时前
读懂 Claude Code 架构分析系列,第一篇,开始!
人工智能·后端·架构
用户6757049885023 小时前
面试官问“装饰器模式”,这样回答薪资多要 3000!
后端
tntxia3 小时前
Geo Scene域名修改引起的一些问题
后端
用户298698530143 小时前
Java 实现 Word 文档加密与权限解除
java·后端
vanuan3 小时前
给你的A2A-Agent加把锁-认证鉴权实战指南
后端
Yeats_Liao3 小时前
14:Servlet中的页面跳转-Java Web
java·后端·架构