synchronized 锁升级:多数人还在背 JDK8 的答案,偏向锁早在 JDK18 就没了

synchronized 锁升级几乎是 Java 后端面试的必考题,标准答案大家都能背:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。但如果你手头的资料还停留在这个版本,可能没意识到一件事:偏向锁在 JDK 15 已经默认关闭,JDK 18 直接把实现代码删掉了(OpenJDK JEP 374,以及后续的 JDK-8256425 issue)。现在主流生产环境用的 17/21 LTS 上,这道题的"标准答案"其实只剩三级。

这篇把整条锁升级链路重新过一遍,顺带讲清楚偏向锁为什么会被淘汰,以及 synchronizedAQSReentrantLock 的底层)到底是两套什么样的东西,这也是追问环节最容易露馅的地方。

一、锁状态藏在对象头里

Java 对象在堆里的内存布局分三块:对象头(Header)、实例数据、对齐填充。对象头里的 Mark Word 就是锁状态的载体,它不是一个固定含义的字段,而是随锁状态动态复用同一块空间去存不同的东西。

在 32 位 HotSpot 的经典模型里,Mark Word 是 32 bit,末尾 2 bit 是锁标志位,用来判断当前是哪种状态:

  • 无锁 (锁标志 01):25 bit 存对象的 HashCode,4 bit 存分代年龄,1 bit 标记"是否偏向锁"。
  • 偏向锁 (锁标志 01,偏向位 1):把无锁态里 HashCode 的 25 bit 空间挪用掉,改存 23 bit 线程 ID + 2 bit Epoch。
  • 轻量级锁 (锁标志 00):30 bit 存一个指针,指向当前线程栈帧里的 Lock Record。
  • 重量级锁 (锁标志 10):30 bit 存一个指针,指向堆外的 Monitor(ObjectMonitor)。
  • GC 标记 (锁标志 11):GC 过程中使用的特殊状态。

记住这张位图比死背"四种状态"更有用。面试官追问"偏向锁到底偏向存了什么",答得出具体是哪几个 bit、怎么复用的,才是真的懂而不是背书。

二、轻量级锁:用 CAS 赌一把"没人跟我抢"

线程进入同步块前,会在自己的栈帧里开一块空间当 Lock Record,把对象当前的 Mark Word 复制过去(叫 Displaced Mark Word),然后尝试用 CAS 把对象头的 Mark Word 替换成指向这个 Lock Record 的指针。

  • CAS 成功:轻量级锁获取成功,锁标志位置为 00
  • CAS 失败:先判断是不是当前线程自己重入(比较 Mark Word 里的指针是否指向当前线程的栈帧),是的话锁记录数 +1;不是的话说明有别的线程在竞争,进入自旋等待。

轻量级锁的假设是"锁的持有时间很短、竞争不激烈",用自旋代替线程挂起去省一次用户态到内核态的切换开销。但自旋不是免费的,如果自旋若干次后还是拿不到锁(HotSpot 用自适应自旋,次数不是写死的常量),就会膨胀为重量级锁

三、重量级锁:真正靠操作系统兜底

膨胀之后,Mark Word 里的指针指向一个 ObjectMonitor,这是 C++ 层面的对象,核心字段大致是:

  • _owner:当前持有锁的线程
  • _EntryList / _cxq:竞争锁但还没拿到的线程,在这里排队阻塞
  • _WaitSet:调用了 wait() 之后进入等待的线程
  • _recursions:重入计数

这里的"阻塞"不是自旋,是真的调用操作系统的互斥原语把线程挂起,之后唤醒要靠 OS 调度。这就是"重量级"这个名字的由来:线程从用户态陷入内核态、被挂起、被唤醒,每一步都有实打实的上下文切换开销,比轻量级锁的自旋贵得多。

wait() / notify() / notifyAll() 对应的就是这里的 _WaitSet:调用 wait() 的线程会从 _owner 移到 _WaitSet,被 notify 唤醒后重新去竞争锁,这也是为什么 wait/notify 只能配合 synchronized 使用,它们操作的就是同一个 Monitor。

四、偏向锁为什么会被淘汰

偏向锁曾经想解决的问题是:如果一把锁几乎总是被同一个线程重复获取,那么每次都做一次 CAS 其实是浪费:第一次用 CAS 把线程 ID 写进 Mark Word 之后,同一个线程后续再进入同步块,只需要比较线程 ID 是不是自己,不需要再 CAS。

问题出在"撤销"上。一旦有第二个线程来竞争这把锁,就需要在安全点 把偏向锁撤销掉,这个撤销过程开销不小,且往往需要暂停持有锁的线程去检查其栈上的锁记录。JEP 374 里官方给出的理由讲得很直白:现在的应用大量使用线程池和并发集合(ConcurrentHashMap、并发队列等),锁经常在不同线程之间被竞争性地获取,"锁总被同一线程持有"这个假设在很多现代服务里根本不成立,偏向锁引入的复杂度和撤销开销,早就超过了它能带来的收益。

所以时间线是这样的:

  • JDK 15 (JEP 374):默认关闭偏向锁,相关参数标记为 deprecated,需要手动加 -XX:+UseBiasedLocking 才能打开。
  • JDK 18 (JDK-8256425):连实现代码都删掉了,UseBiasedLocking 等一整套参数彻底作废。

也就是说,如果你现在用的是 17 或 21 这两个主流 LTS,synchronized 的锁升级路径已经是无锁 → 轻量级锁 → 重量级锁三级,偏向锁只存在于历史文档和老项目里。这一点很多网上的八股文答案没跟上------面试官如果追问一句"你说的是哪个版本",能答上这个时间线,基本就是加分项。

五、synchronized 和 AQS 不是一回事

另一个经常被问混的点,是 synchronizedReentrantLock 的关系。很多人会含糊地说"差不多,都是锁",但两者的实现层级完全不同:

synchronized 是 JVM 层面的语言原语。锁状态编码在对象头里,锁的膨胀、自旋、阻塞挂起全部由 JVM 和操作系统配合完成,Java 代码里看不到任何显式的锁对象或状态字段。

ReentrantLock 建立在 AQSAbstractQueuedSynchronizer)之上,是纯 Java 代码实现的锁框架。AQS 的核心就两样东西:

  • 一个 volatile int state,用 CAS 去修改它表示锁是否被占用(ReentrantLock 用 0/1,Semaphore 用剩余许可数,CountDownLatch 用倒数计数,语义都是子类自己定义的);
  • 一条基于 CLH 队列变体的双向链表,竞争失败的线程包装成 Node 挂到队列上自旋或阻塞(依赖 LockSupport.park),锁释放时按队列顺序唤醒。

tryAcquire / tryRelease 是 AQS 留给子类的模板方法,ReentrantLock 的公平锁和非公平锁的区别,就在这两个方法里对"要不要检查队列里有没有排在前面的线程"这一步的实现不同。

正因为 ReentrantLock 是纯 Java 层实现,它能做到 synchronized 做不到的几件事:tryLock() 尝试获取而不阻塞、lockInterruptibly() 响应中断、支持超时、配合多个 Condition 实现分组等待队列(synchronized 配合 wait/notify 只有一个等待集合,没法区分"因为不同条件在等"的线程)。这些差异不是背下来的知识点,而是"JVM 内置原语"和"Java 层框架实现"这个根本区别自然推出来的结果,理解了这一层,面试官换着花样问也不容易卡壳。

写在最后

这道题真正的深度不在"背出四个状态",而在于说清楚每一级升级到底在权衡什么:轻量级锁赌的是竞争不激烈、自旋比阻塞划算;重量级锁认栽用了更贵但更稳的操作系统原语;偏向锁曾经想省下 CAS 的成本,但现代应用的锁竞争模式让这个赌注输多赢少,所以被官方主动砍掉。前几天用面灵 AI 陪练模拟面试,被追问了一句"那 JDK 21 呢",当场卡壳,才发现自己对这道老题的认知还停留在好几年前,这也是把这条链路重新捋一遍写下来的起因。

面试遇到这类题,别止步于背出状态名,顺着"为什么"往下追一层,通常就是拉开差距的地方。

相关推荐
暗不需求1 小时前
手写深拷贝:从 JSON 到递归,彻底解决循环引用问题
前端·面试
荣码1 小时前
LangGraph多步工作流:Agent不够用的时候,我踩了4个坑
java·python
是小李呀1 小时前
Java集合扩容机制解析:ArrayList与HashMap底层原理及性能差异
java
SimonKing1 小时前
FreeLLMAPI:24 家厂商、128 个模型、1 个 Key智能切换所有可用的模型
java·后端·程序员
阿pin1 小时前
Java随笔-JVM内存模型
java·开发语言·jvm·内存模型
AI 大模型学习不踩坑1 小时前
Harness 架构原理与工程实践详解
java·人工智能·神经网络·机器学习·计算机视觉·架构
深盾科技_Virbox2 小时前
软件交付保护如何从加壳走向代码虚拟化
java·python·安全