一、为什么要单独讲 synchronized 的底层实现
前几篇讲了 JMM、happens-before、volatile,它们解决的都是可见性和有序性问题,唯独没有系统讲过"互斥"——也就是"同一时刻只允许一个线程执行某段代码"这个能力具体是怎么实现的。synchronized 是 Java 里最古老、最基础的互斥手段,很多人只知道它"能加锁",但说不清楚锁到底加在哪里、锁住的是什么、为什么早期版本里它被认为很慢而现代 JDK 里性能已经大幅改善。这一篇把对象头、Monitor、锁升级这三层原理讲透,读完就能理解 synchronized 从"重量级"变成"按需自适应"背后的完整逻辑。
二、锁到底加在哪里:Java 对象头结构
synchronized 修饰的对象,锁状态信息并不是存在什么额外的地方,而是直接存在这个**对象自己的对象头(Object Header)**里。在 HotSpot 虚拟机中,每个普通对象的对象头主要包含两部分:
- Mark Word:一个机器字长(64 位 JVM 下通常是 8 字节)的区域,是整套锁机制的核心,它的具体含义会随着锁状态的变化而变化——有时存对象的哈希码,有时存指向锁记录的指针,有时存线程 ID。
- Class Pointer:指向该对象对应的类元数据(Class 对象)的指针,用于确定对象是哪个类的实例。如果对象是数组,对象头里还会额外存一份数组长度。
关键在于 Mark Word 是"复用"的——同一块内存区域,在不同锁状态下存储完全不同的信息。这种设计避免了给每个对象都额外分配一块固定的"锁专用空间",只在真正需要更复杂锁信息时才把 Mark Word 指向额外分配的数据结构。理解这一点,是理解后面锁升级过程的前提。

三、Monitor:synchronized 真正依赖的对象
当锁竞争进入"重量级"阶段时,Mark Word 会指向一个叫 Monitor(也叫 ObjectMonitor)的数据结构,这是用 C++ 在 HotSpot 虚拟机层面实现的,每个可能被当作锁的 Java 对象在需要时都可以关联一个 Monitor。Monitor 内部主要维护几个关键字段:
- Owner:记录当前持有锁的线程,未被占用时为空。
- EntryList:存放正在竞争这把锁、但还没有获取到锁的线程队列(处于阻塞状态,等待被唤醒重新竞争)。
- WaitSet:存放调用了这把锁对象的
wait()方法、主动让出锁并等待被notify()/notifyAll()唤醒的线程队列。 - 计数器(Recursions):记录当前持有者对这把锁的重入次数,这也是
synchronized支持"同一个线程可重复获取自己已经持有的锁"(可重入性)的实现基础。
在字节码层面,synchronized 代码块会被编译成 monitorenter 和 monitorexit 两条指令:进入同步块之前执行 monitorenter,尝试获取(或增加重入计数)关联的 Monitor;退出同步块时执行 monitorexit,释放(或减少重入计数)。如果用 synchronized 修饰的是方法而不是代码块,则通过方法的 ACC_SYNCHRONIZED 标志位实现等价的语义,原理相通。

四、锁升级:从无锁到重量级锁的四个阶段
早期 JDK 里 synchronized 一旦被使用就直接进入"重量级"路径——没获取到锁的线程会被真正挂起(阻塞),涉及操作系统层面的线程切换,代价很高。从 JDK 6 开始,HotSpot 引入了锁升级机制,让锁的实现根据实际竞争情况自适应地选择代价更低的方式,只有竞争激烈时才真正付出重量级锁的代价。整个过程分为四个阶段,按需单向升级:
- 无锁(No Lock):对象刚创建、还没有任何线程尝试对它加锁时的初始状态。
- 偏向锁(Biased Locking):当只有一个线程反复进入同步块、几乎不存在多线程竞争时,JVM 会把这个线程的 ID 直接记录在对象的 Mark Word 里。之后这个线程再次进入同步块,只需要简单比较 Mark Word 里记录的线程 ID 是不是自己,不需要执行开销较大的 CAS 操作,效率非常高。但一旦有第二个线程尝试竞争这把锁,偏向锁就会被撤销,升级为轻量级锁。
- 轻量级锁(Lightweight Locking):当存在多个线程交替竞争但没有真正同时争抢(比如线程运行时间不重叠)的情况下,JVM 使用 CAS 操作在对象的 Mark Word 和线程栈上的锁记录之间做原子性的指针替换来获取锁,竞争失败的线程会短暂自旋(不断重试)而不是立即被挂起,避免了线程真正阻塞、唤醒带来的系统调用开销。如果自旋一定次数后仍未获取到锁,就会继续升级为重量级锁。
- 重量级锁(Heavyweight Lock):当竞争足够激烈,多个线程真正同时抢一把锁、轻量级锁的自旋无法快速解决时,锁最终升级为重量级锁——Mark Word 指向一个真正的 Monitor 对象,未获取到锁的线程被放入 EntryList 并真正挂起(阻塞),等待持有者释放锁后被唤醒重新竞争。
需要特别说明的是:偏向锁在 JDK 15 之后已经默认关闭(此前从 JDK 6 引入,到 JDK 15 默认改为不再启用),原因是现代多核并发场景下,真正只有单线程访问、完全没有竞争的场景越来越少,偏向锁在有竞争时反而会带来额外的撤销开销,权衡之后 HotSpot 团队选择默认关闭它,把优化重点放在轻量级锁和后面提到的自适应自旋、锁消除等机制上。

五、现代 JVM 的额外优化手段
除了锁升级本身,HotSpot 还提供了几种进一步降低 synchronized 开销的优化:
- 适应性自旋(Adaptive Spinning):轻量级锁竞争失败时的自旋次数不是固定的,JVM 会根据同一把锁最近的自旋成功/失败历史动态调整自旋时长——如果最近自旋通常能等到锁释放,就允许自旋更久;如果最近自旋经常失败,就缩短自旋时间,更快转为挂起,避免浪费 CPU。
- 锁消除(Lock Elimination):基于逃逸分析(Escape Analysis),如果编译器在 JIT 编译期间判断出某个加了锁的对象根本不可能被其他线程访问到(比如只在方法内部创建和使用,从未被返回或赋值给外部可见的引用),就会直接把这段代码的加锁操作整体消除,因为锁根本没有存在的必要。
- 锁粗化(Lock Coarsening):如果一段代码在循环体内反复对同一个对象加锁又解锁,JIT 编译器会识别出这种模式,把加锁范围从"循环体内部反复加解锁"粗化为"循环体外部整体加一次锁",减少反复申请、释放锁带来的开销。
这几项优化都是编译器和运行时自动完成的,开发者不需要手动干预,但理解它们的存在,有助于理解为什么现代 JDK 下 synchronized 的性能已经和 ReentrantLock 相差不大,早期"能用 Lock 就别用 synchronized"的经验之谈在现代 JDK 上已经不再是绝对正确的建议。

六、synchronized 和 ReentrantLock:不是谁取代谁
现代 JDK 下,synchronized 和 ReentrantLock 的性能差距已经不大,两者的选择更多取决于功能需求而不是性能:synchronized 语法简单、由 JVM 自动管理加锁解锁(不需要手动 finally 释放锁,减少忘记释放的风险),适合大多数常规同步场景;ReentrantLock 提供了 synchronized 不具备的能力,比如可中断的锁获取、超时获取锁、公平锁选项、以及配合 Condition 实现更精细的等待/通知控制。两者不是替代关系,而是"默认选简单的 synchronized,遇到需要更精细控制的场景再上 ReentrantLock"。

七、面试追问链

延伸阅读
- 《深入理解 Java 虚拟机》对象头与锁机制相关章节
- HotSpot 虚拟机源码中 ObjectMonitor 的实现(objectMonitor.cpp)
- 下一篇预告:CAS 与原子类——compare-and-swap、ABA 问题、自旋代价和 AtomicStampedReference
问答(不计入阅读时长)
Q1:synchronized 加的锁到底存在什么地方?
A:不是存在什么额外的全局表里,而是直接存在被加锁对象自己的对象头(Object Header)里,具体是对象头中的 Mark Word 区域。Mark Word 是一个复用区域,同一块内存在无锁、偏向锁、轻量级锁、重量级锁四种状态下存储的内容完全不同——可能是对象哈希码,也可能是线程ID,也可能是指向锁记录或 Monitor 的指针。
Q2:Monitor 里的 EntryList 和 WaitSet 有什么区别?很多人会搞混。
A:EntryList 存放的是正在竞争这把锁、但还没抢到、处于阻塞状态的线程——它们没有调用过 wait(),只是单纯没抢到锁在排队。WaitSet 存放的是已经拿到过锁、但主动调用了 wait() 让出锁、等待被 notify()/notifyAll() 唤醒的线程。简单说:EntryList 是"还没进门",WaitSet 是"进过门又主动出去等通知"。被唤醒后,WaitSet 里的线程会重新进入 EntryList 竞争锁,不会直接拿到锁。
Q3:锁升级的四个阶段,触发升级的关键条件分别是什么?
A:无锁→偏向锁:第一个线程访问同步块时直接设置偏向标志和线程ID。偏向锁→轻量级锁:只要有第二个线程尝试竞争(哪怕没有真正同时竞争,只是出现了竞争者),偏向锁就会被撤销升级。轻量级锁→重量级锁:多个线程真正同时争抢,轻量级锁的 CAS+自旋在一定次数内解决不了竞争。整个过程是单向的,不会自动降级——一旦升级为重量级锁,就一直保持重量级,直到锁被完全释放、对象重新回到无锁状态。
Q4:为什么偏向锁在 JDK 15 之后默认关闭了?这不是"退步"吗?
A:不是退步,是权衡之后的合理选择。偏向锁的收益在"只有单线程反复访问、完全没有竞争"的场景下才明显;但现代应用普遍是多核并发,真正长期无竞争的场景越来越少。而偏向锁一旦遇到竞争就要执行撤销操作,这个撤销本身有一定开销,如果竞争频繁发生,撤销开销反而抵消甚至超过了偏向锁节省的开销。HotSpot 团队评估后认为默认关闭更符合大多数现代应用的实际情况,需要的场景仍可以通过 JVM 参数手动开启。
Q5:轻量级锁的自旋失败后为什么不能无限自旋下去?
A:自旋本质上是让 CPU 空转等待,虽然避免了线程真正挂起、唤醒的系统调用开销,但自旋本身也消耗 CPU 时间。如果持有锁的线程执行时间较长,无限自旋只会白白浪费 CPU 资源,还不如让当前线程真正挂起、把 CPU 让给别的线程用。这也是为什么会有"适应性自旋"机制——JVM 会根据同一把锁最近的自旋成功/失败历史,动态判断这次值不值得继续自旋,而不是用一个固定次数简单粗暴地判断。
Q6:锁消除是怎么知道一个对象"从未逃逸"的?会不会误判导致线上出并发问题?
A:锁消除依赖 JIT 编译器的逃逸分析(Escape Analysis),分析对象的作用域和引用链,判断这个对象是否可能被方法外部的其他线程访问到——比如从未被 return、从未被赋值给成员变量或静态变量、从未作为参数传递给可能保留引用的外部方法。这是编译器基于严格的静态分析规则得出的保守判断,只有能确定百分百不逃逸时才会消除锁,不存在"赌概率"的误判空间,所以不会引入并发问题。
Q7:synchronized 支持可重入,具体靠什么实现?和普通锁的区别在哪?
A:靠 Monitor 里的 Recursions(重入计数器)实现。当一个线程已经持有某把锁,再次进入同一把锁保护的同步块时,JVM 会识别出 Owner 就是当前线程,不会重新走一遍加锁竞争流程,而是直接把 Recursions 计数器加一;退出时对应减一,直到计数器归零才真正释放锁。这就是为什么一个线程可以在自己已经持有锁的情况下,安全地递归调用或调用另一个也被同一把锁保护的方法,不会把自己锁死。
Q8:现在还需要纠结 synchronized 和 ReentrantLock 哪个性能更好吗?
A:不需要。现代 JDK 下,synchronized 经过锁升级、适应性自旋、锁消除、锁粗化等一系列优化后,性能已经和 ReentrantLock 非常接近,早年"重量级锁很慢,一定要用 Lock"的经验之谈在现代 JVM 上已经过时。真正该考虑的是功能需求:如果只是常规互斥,synchronized 语法更简单、由 JVM 自动管理不容易忘记释放;如果需要可中断获取锁、超时获取锁、公平锁、或配合 Condition 做精细的等待/通知控制,才需要上 ReentrantLock。选型看功能,不看性能。