☰
Java锁的实现全解析:从synchronized锁升级到AQS底层原理
2026/10/21 7:07:22 网站建设 项目流程

前几天帮朋友做模拟面试复盘,正好抽到一道Java并发高频八股文:“锁是怎么实现的?”一个三年经验的候选人把 synchronized 和 ReentrantLock 的区别背得滚瓜烂熟,但当我继续追问“偏向锁为什么要设计成单向升级”“AQS 的 state 到底由谁维护”“锁竞争失败后线程去了哪里”时,就直接卡住了。这个问题表面是考一个知识点,实际上是在考察并发编程的底层全貌。

今天我把这道题完完整整拆一次:从 CPU 指令说到 JVM 对象头,再从 JUC 源码往回推,最后总结一套既适合背诵又经得住追问的回答路径。准备面试的朋友可以直接照着组织答案,想真正理解锁而不是只会“写个 synchronized”的同学,这篇也值得你读完。

1. 先把问题翻译清楚:面试官问的“锁”到底在问什么

1.1 锁要解决的三个核心问题

面试官提“锁是怎么实现的”,首先得想明白锁存在的意义。多线程并发环境下,共享资源会同时被多个线程读写,我们需要解决三类问题:

  • 原子性:比如count++并不是一步完成的。它需要读取、加一、写回三个动作,任意一个线程在中间插入,结果就会错乱。
  • 可见性:线程工作内存和主内存之间存在缓存,一个线程写了某个变量,另一个线程可能很长一段时间都看不到最新值。
  • 互斥/有序性:多个线程同时进入临界区,操作就会穿插执行。

锁的作用就是给“临界区”加一道门。一个朴素的比喻是公共厕所的“占用中”指示灯:谁把灯翻过来,谁就有资格进隔间;其他人只能排队,等里面的人出来才能再翻。这个“占用中”的标志,就是锁状态本身。

然后面试官真正想看的,是一个候选人能不能说清楚:这个“占用标志”放在哪里?用什么操作去翻?翻不动的时候,排队的人是怎么被安排和叫醒的?这三个问题,恰好对应锁实现的三层维度。

1.2 锁的实现是分层配合,不是单点技术

从底层往上,锁的实现分三层:

  • 硬件层:CPU 提供了LOCK前缀指令、CAS(Compare-And-Swap)原子操作,以及 MESI 等缓存一致性协议来保证多个核之间状态同步。
  • JVM 层:HotSpot 在对象头里维护锁标记,用 Monitor(监视器)实现阻塞和唤醒,这就是 synchronized 的底层骨架。
  • JDK 层:JUC 包用 AQS 这套模板框架,把 CAS、队列、阻塞唤醒组合成 ReentrantLock、读写锁等显式锁。

为什么不能只靠一个方案走天下?硬件 CAS 是纯用户态操作,性能极高,但如果竞争激烈,线程一直在自旋重试,CPU 时间被白白烧掉。重量级锁(依赖操作系统互斥量)虽然能让线程真正挂起,不占 CPU,但每次抢锁都可能要进入内核态,上下文切换开销非常大。所以 JDK 锁的设计思路永远是“能乐观就别悲观,能自旋就别阻塞,能用户态就别进内核态”,这也就是后面 synchronized 锁升级的底层逻辑。

2. synchronized 的完整实现链路:对象头、Mark Word 与锁升级

2.1 锁状态存在对象的哪里?答案是对象头里的 Mark Word

很多人只知道 synchronized 可以锁对象,却不知道锁的标记到底存在哪个位置。Java 对象在 HotSpot 里分为三部分:对象头、实例数据、对齐填充。对象头里的Mark Word就是承载锁状态的核心区域。

以 64 位 JVM 为例,Mark Word 是一个 64 bit 的字段,它的 bit 位会随着锁状态不同而重新解释。无锁状态下,这 64 bit 里有 31 位存放对象的 identity hash code、4 位存放分代年龄、1 位标记是否偏向。偏向锁状态下,前 54 位变成持有锁线程的 ID。轻量级锁状态下,变成指向线程栈中 Lock Record 的指针。重量级锁状态下,变成指向 ObjectMonitor 的指针。最后两位的标记位01、00、10用来区分状态。

这也是一个很值得说的细节:身份哈希码和偏向锁是冲突的。如果某个对象在无锁状态下计算过System.identityHashCode(),它的 hashCode 已经写进了 Mark Word,那就进不了偏向锁状态,因为 hashCode 和线程 ID 是同一块空间,放不下两个东西。做实验时经常会发现某些对象死活“偏不了向”,多半就是这个原因。

想看真实布局,可以用 JOL(Java Object Layout)工具:

public class LockLayoutDemo { static final Object obj = new Object(); public static void main(String[] args) throws InterruptedException { // 等过偏向延迟时间,默认4秒,也可以加 -XX:BiasedLockingStartupDelay=0 Thread.sleep(5000); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); synchronized (obj) { System.out.println(ClassLayout.parseInstance(obj).toPrintable()); } Thread t = new Thread(() -> { synchronized (obj) { System.out.println(ClassLayout.parseInstance(obj).toPrintable()); } }); t.start(); t.join(); } }

肉眼看到 Mark Word 的 bit 位变化,比记住任何理论都直观。

2.2 偏向锁:默认“没人抢”,直接把线程 ID 写进去

大多数情况下,同一个锁对象会被同一个线程反复获取。比如一个单线程循环里不断synchronized读写同一个集合,此时根本不存在竞争。如果每次加锁都走 CAS 甚至内核阻塞,显然浪费。

偏向锁做的优化是:锁对象第一次被线程 A 拿到后,Mark Word 直接记录 A 的线程 ID。下次 A 再进来,只需要检查 Mark Word 里的线程 ID 是不是自己,是就什么都不用做直接进临界区。这个检查是极低成本的,几乎无原子操作。

当然这里有一个坑:HotSpot 启动后,偏向锁不会立刻生效,默认有 4 秒延迟(-XX:BiasedLockingStartupDelay=4000)。原因是 JVM 启动阶段大量对象会被并发访问,立刻开启反而导致频繁撤销。调优压测时可以把这个延迟调成 0 再分析。

偏向锁撤销的代价很高。一旦另一个线程 B 来竞争同一个对象,JVM 就得判断是否撤销偏向。撤销过程要求进入安全点(SafePoint),暂停所有业务线程,再检查原来持有偏向锁的线程是否存活:

  • 如果线程 A 已经退出临界区,就把 Mark Word 改成无锁或轻量级锁状态;
  • 如果 A 还在临界区,就直接升级成轻量级锁。

注意,偏向锁的撤销不是发生在临界区内部,而是发生在下一个线程尝试获取锁的时候。到了 JDK 15,官方觉得偏向锁的复杂度和维护成本收益不成正比,默认禁用了偏向锁,JDK 18 里它已经被彻底移除。现在面试官问偏向锁,更多是在考“你知不知道历史上 JVM 是怎么一步步优化的”。

2.3 轻量级锁:少量竞争时用 CAS 替代阻塞

当存在竞争,但竞争不激烈时,JVM 升级到轻量级锁。它的思路是:用自旋等待代替线程挂起。

加锁流程是这样的:

  1. 线程 A 的栈帧里创建一个 Lock Record,用于存放对象头原本的 Mark Word 拷贝。
  2. 通过 CAS,把对象头的 Mark Word 改成指向 Lock Record 的指针。成功后,A 拿到锁。
  3. 如果 CAS 失败,说明有人在竞争。JVM 进入自旋,也就是空转几条指令再重新尝试。

自旋也不能无限自旋。早期 HotSpot 用固定自旋次数,后来改成了适应性自旋:如果上一次自旋成功了,JVM 认为这次大概率也能成功,就多自旋一会儿;如果上一次失败很多次,就直接放弃自旋,升级为重量级锁。

所以轻量级锁自旋失败之后,锁会膨胀成重量级锁。这也解释了为什么锁升级是单向的:从轻量级升级到重量级很容易,但从重量级降回来几乎不会发生,因为线程的阻塞和唤醒一旦引入内核态,整个锁的语义已经变成一个真正的监视器锁,HotSpot 没有必要再做反向优化。

2.4 重量级锁:Monitor 与真正的阻塞唤醒

重量级锁对应的是ObjectMonitor对象,在 JVM 内部表现为一份完整的数据结构,核心字段包括_owner(当前持有锁的线程)、_recursions(重入计数器)、_EntryList(等待获取锁的线程队列)、_WaitSet(调用 wait 的线程集合)。

字节码层面的实现是monitorenter和monitorexit。一个线程执行到monitorenter时,如果_owner为 null,CAS 抢占成功;否则把自己放进_EntryList并挂起。monitorexit时恢复_owner,唤醒队列里的线程继续竞争。

这里有个特别容易答错的知识点:synchronized方法块里调用wait()后,锁是被释放的。实现上就是当前线程从_owner挪到了_WaitSet,别人可以重新抢锁,直到有人notify/notifyAll把它从_WaitSet移回_EntryList。

重量级锁的“重”在于阻塞和唤醒线程通常需要操作系统原语,意味着用户态到内核态的切换,一次切换成本大约在微秒量级。高并发臭名昭著的上下文切换开销,主要就是这部分。

3. ReentrantLock 与 AQS:从队列角度看显式锁实现

3.1 AQS 的骨架:volatile state 加 CLH 变体队列

如果说 synchronized 的锁升级是 JVM 内部优化,那 JUC 里的显式锁,核心骨架就是AbstractQueuedSynchronizer(AQS)。

AQS 做的事可以用一句话概括:用一个 volatile int state 表示锁状态,加上一个双向 FIFO 队列存放等待线程。队列节点里保存线程引用、前驱后继引用、等待状态waitStatus。waitStatus的几个关键值必须记住:

  • CANCELLED(1):线程已取消,节点将被清理;
  • SIGNAL(-1):后继线程需要被唤醒;
  • CONDITION(-2):节点在条件队列里;
  • PROPAGATE(-3):共享锁传播唤醒。

子类(比如 ReentrantLock)只需要实现tryAcquire和tryRelease。AQS 已经把入队、出队、阻塞、唤醒的流程用模板方法定好了。这是设计模式里典型的模板方法模式。

3.2 加锁失败后的完整路径

以 ReentrantLock 的非公平锁为例,lock()方法会先做一次 CAS:尝试把 state 从 0 改成 1。这一步如果成功,当前线程直接获得锁,不需要进入队列。这也正是“非公平”的由来:新来的线程和队列里已经等待很久的线程站在同一起跑线,甚至新线程还优先。

如果 CAS 失败,进入acquire(1),实际会依次调用:

  1. tryAcquire(1):再试着获取一次。如果当前线程就是持有锁的线程,state 加一,返回 true,这就是可重入。
  2. addWaiter(Node.EXCLUSIVE):把当前线程包装成 Node,CAS 追加到队列尾部。如果尾节点为空或 CAS 失败,会走enq自旋初始化队列。
  3. acquireQueued:进入循环。如果当前节点的前驱是头节点,说明轮到自己抢锁了,再试一次tryAcquire。成功就把自己设为头节点。失败则判断是否应该挂起,挂起前把前驱节点的 waitStatus 改成 SIGNAL。

阻塞操作本身调用的不是Thread.sleep也不是Object.wait,而是LockSupport.park()。它底层走Unsafe.park/unpark,直接由系统调度。这就是 ReentrantLock 和 synchronized 在阻塞方式上最本质的区别之一。

3.3 公平锁和非公平锁:一个方法之差

公平锁和非公平锁的代码差异,核心就在tryAcquire里的一个判断:

protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } // ...可重入逻辑 }

hasQueuedPredecessors()会检查队列中是否有比自己更早到达的节点。如果有,公平锁直接放弃本次抢锁机会,乖乖排队。非公平锁则省掉这个检查,先 CAS 一把试试运气。

从性能角度看,非公平锁往往吞吐量更高,因为当一个线程被唤醒后重新抢锁,它正好可能持有 CPU 热点,插队成功可以减少一次的线程切换。公平锁的代价是让这些线程醒来了也不一定能马上拿到锁,白白增加调度开销。所以默认选择非公平锁,是有工程依据的,不是简单的“效率优先于公正”。

3.4 可重入、中断响应与 Condition 条件队列

可重入的实现特别简单:AQS 的 state 不只为 0/1,而是可以累加。比如同一个线程连续 lock 三次,state 变成 3,unlock 三次后归零。但注意 state 是全局的,JVM 怎么知道“是不是同一个线程”?答案是通过getExclusiveOwnerThread()保存当前持锁线程,tryAcquire里先判断持锁线程是不是当前线程,是才允许重入。

相比 synchronized,ReentrantLock 最大的增强是可中断加锁:lockInterruptibly()在等待锁的过程中如果收到中断信号,会直接抛出InterruptedException并出队。而 synchronized 在锁等待期间无法响应 Thread.interrupt() 的中断。

Condition 则是把 Object 的 wait/notify 变成了更精细的多条件队列。每个 Condition 维护自己的等待队列。await()会让当前线程释放锁并进入条件队列,signal()则把条件队列里第一个节点转移到 AQS 主队列。使用场景比如生产者-消费者中,用“不满”和“不空”两个条件分别唤醒生产者、消费者,避免无意义的全量唤醒。

4. JUC 里的组合锁和 JIT 的锁优化

4.1 读写锁:把 state 劈成两半

ReentrantReadWriteLock 是对 AQS 的巧妙复用。它把同一个 state 按 bit 拆开:高 16 位表示读锁数量,低 16 位表示写锁状态。写锁是排他锁,读锁是共享锁。多个线程可以同时持有读锁,但写锁和写锁、写锁和读锁之间互斥。

这里有个非常实用的概念叫锁降级:线程先拿到写锁,再拿到读锁,此时释放写锁,锁就从写锁降级为读锁。常见用途是保证某个数据更新后,其他线程读到的永远是最新值,同时又不至于长时间霸占写锁。写锁升级为读锁则是 Java 锁里明确不支持的,因为可能形成循环等待。

如果你看过 StampedLock 的源码,会发现它还搞了一个乐观读:不加锁,直接读取,返回一个版本号,读取后再校验版本号是否变化。变化了再升级为悲观读锁或写锁。这在读多写少但要求低的场景下性能优于读写锁。代价是它不可重入,并且不支持条件队列。

4.2 JIT 做的两个看不见的优化

除了 JVM 锁状态升级,JIT 编译器还会帮你做锁优化。

锁消除依赖逃逸分析。如果 JVM 发现一个锁对象只会在某个线程内部创建和使用,根本不会逃逸到其他线程,那这个 synchronized 就是多余的,JIT 会直接把锁去掉。典型的例子是局部变量里的new Object()配合 synchronized,JVM 能分析出没有其他线程能看到这个对象,直接消除锁开销。

锁粗化则是把相邻的多个同步块合并成一个更大的同步块。比如 for 循环里每次迭代都synchronized(obj)做一些小操作,JIT 发现这些操作之间本来就没有竞争点,合并后反而减少了反复加解锁的次数。很多人写代码时为了“减小锁粒度”而在循环里反复加锁,其实 JIT 有可能把这个优化搞回去,所以过度优化锁粒度往往没有意义。

4.3 锁选型速查表

锁/机制优势代价适用场景
synchronized语法简洁,JVM 自动升降级,无需手动解锁后续版本偏向锁被移除后,竞争激烈时阻塞成本高绝大多数需要互斥的共享资源
ReentrantLock可中断、可超时、多个条件队列,公平性可配需要手动 unlock,存在忘记释放的隐患需要公平锁或等待可中断场景
ReentrantReadWriteLock读写分离,读读并发写锁饥饿可能,计数器拆位后过于紧凑并发读明显多于写的场景
StampedLock乐观读开销极低不可重入、不支持 Condition、使用复杂读多写少且需要极致性能的统计场景
原子类(CAS)无锁、无上下文切换高竞争时自旋浪费 CPU,有 ABA 风险简单计数、累加,配合版本号防 ABA

5. 面试实战:怎么把“锁的实现”答出深度

5.1 推荐回答框架

如果面试只有三分钟,我会这样组织答案:

  1. 先讲锁要解决什么问题:原子性、可见性、互斥。锁的本质是控制临界区访问状态。
  2. 讲硬件层:CAS 和内存屏障定了基础原子操作。没有 CAS,后面什么都谈不了。
  3. 讲 synchronized 的锁升级:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,每个状态解决什么场景,为什么单向不可逆。
  4. 讲 AQS:state + FIFO 队列,锁竞争失败后入队挂起,释放锁后唤醒后继节点。
  5. 最后对比一句:synchronized 是 JVM 层面实现的监视器锁,ReentrantLock 是 JDK 基于 AQS 实现的显式锁,后者多了中断、超时、多条件、公平锁这些能力。

这套回答从宏观到微观,既体现了广度,也给了面试官追问的方向。他随便揪一环继续问,你都不至于冷场。

5.2 高频追问与正确应对

  • 为什么 JDK 15 之后偏向锁被弃用?偏向锁在访问模式足够简单时确实有用,但现在的业务系统锁竞争模式复杂,偏向锁的撤销反而触发 STW;许多基于并发框架的代码自身已无锁化,偏向锁收益不明显。
  • 轻量级锁自旋失败后一定会升级重量级锁吗?不是立即,而是不断自旋到阈值或 JIT 判断“该放弃了”才升级。不过面试通常希望你说“会升级”,可以补充说明这背后是适应性自旋在控制。
  • synchronized 和 ReentrantLock 分别适合什么场景?优先答:能用 synchronized 就优先 synchronized,代码简洁且 JVM 会自己调优;需要公平锁、可中断等待、多条条件队列时再换 ReentrantLock。
  • park/unpark 与 wait/notify 的区别?二者都阻塞线程,但 wait 必须在持有锁且有 synchronized 块内执行,且 notify 是随机唤醒一个;park/unpark 不需要持有锁,并且 unpark 可以先于 park 被调用,不会丢信号。
  • 公平锁会不会完全避免饥饿?公平锁降低饥饿概率,但没有解决“线程还没开始排队时,已经被大量插队”?其实公平锁堵住了插队口,基本能避免饥饿,但线程被唤醒后再竞争锁,仍可能输给刚好在同一时刻去抢锁的新线程。不过已经不是“排队论”意义上的饥饿。

5.3 用工具把锁状态“看”出来

除了 JOL,线上排查时最常用的是jstack。生成线程 dump 后,如果看到Found one Java-level deadlock,第一行就是死锁涉及的线程和锁编号。顺着往下读,能看到两个线程互相持有一把锁又等待另一把锁,形成环。

也可以用jcmd <pid> Thread.print远程打印线程信息。更底层的工具是HSDB,它通过图形界面可以直接查看某个对象的 ObjectMonitor 详情。不过在面试里,能把jstack和JOL讲得清楚,已经足够有说服力。

6. 我踩过的一些“锁的坑”

6.1 死锁不是只能靠看文档避免

死锁四条件——互斥、持有并等待、非抢占、循环等待。实际工作里最常见的是两个线程以相反顺序拿两把锁。定位这种问题,jstack 是神器。

例如线程 A 持有“库存锁”等待“订单锁”,线程 B 持有“订单锁”等待“库存锁”,线程 dump 里会非常清晰地写出来:

"Thread-A" waiting for 0x00000000c1234567 (an ObjectMapper) owned by "Thread-B" "Thread-B" waiting for 0x00000000c1234568 (an OrderService) owned by "Thread-A"

解决思路一是保证多把锁的获取顺序全局一致,二是用 ReentrantLock 的tryLock(timeout)获取不到就回滚退出。后面这种思路对老系统改造更友好。

6.2 锁粒度不是越小越好

我见过一个性能事故:一个批量任务里,开发者为了“减少锁持有时间”,把原本需要原子更新的三行代码拆成三次 synchronized,每次锁完立即解锁。结果三个锁之间的窗口期被其他线程插入,数据反复不一致,代码变得越来越复杂。

正确做法是把“一组必须整体生效的操作”看成一个事务性临界区,锁粒度反而放到一个方法级别;同时把真正耗时的 IO、网络调用挪到锁外。先保证正确性,再考虑性能。锁粒度优化的话题,一定是基于 metrics 说话的。

6.3 优先考虑“无锁”再考虑“锁”

锁的底层实现无论怎么优化,只要有锁,就存在竞争、排队、唤醒。真正高并发系统里,能选并发容器就别用 synchronized 包 HashMap,能选 LongAdder 就别用 AtomicLong 硬扛高并发计数,能选读写分离就别让所有线程抢同一把写锁。理解锁的实现,很重要的一点就是知道它的边界在哪里,然后尽量避开这个边界。

对我来说,这道“锁是怎么实现的”与其说是八股文,不如说是一张并发编程的基础地图。面试者如果能从“锁的本质是控制一条访问路径”出发,再一路扩展到对象头、Monitor、AQS,就已经超过了大部分背题型候选人。我自己做技术评审时,也更看重对方能不能讲清楚“为什么会有偏向锁”“为什么会用 CLH 变体队列”,而不是只记得锁升级的先后顺序。

最后分享一个我常用的实践技巧:写完一段并发代码后,不要急着上线,先用 JOL 打印目标锁对象的 Mark Word,再开两个线程模拟竞争,亲眼看看锁状态怎么从无锁一路变成重量级锁。这个直观经验,比翻十遍源码分析文章都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询