资讯动态

Java锁的实现全解析:从synchronized锁升级到AQS底层原理

发布时间:2026/10/21 7:07:26 来源:尧图企业网站定制
前几天帮朋友做模拟面试复盘正好抽到一道Java并发高频八股文“锁是怎么实现的”一个三年经验的候选人把 synchronized 和 ReentrantLock 的区别背得滚瓜烂熟但当我继续追问“偏向锁为什么要设计成单向升级”“AQS 的 state 到底由谁维护”“锁竞争失败后线程去了哪里”时就直接卡住了。这个问题表面是考一个知识点实际上是在考察并发编程的底层全貌。今天我把这道题完完整整拆一次从 CPU 指令说到 JVM 对象头再从 JUC 源码往回推最后总结一套既适合背诵又经得住追问的回答路径。准备面试的朋友可以直接照着组织答案想真正理解锁而不是只会“写个 synchronized”的同学这篇也值得你读完。1. 先把问题翻译清楚面试官问的“锁”到底在问什么1.1 锁要解决的三个核心问题面试官提“锁是怎么实现的”首先得想明白锁存在的意义。多线程并发环境下共享资源会同时被多个线程读写我们需要解决三类问题原子性比如count并不是一步完成的。它需要读取、加一、写回三个动作任意一个线程在中间插入结果就会错乱。可见性线程工作内存和主内存之间存在缓存一个线程写了某个变量另一个线程可能很长一段时间都看不到最新值。互斥/有序性多个线程同时进入临界区操作就会穿插执行。锁的作用就是给“临界区”加一道门。一个朴素的比喻是公共厕所的“占用中”指示灯谁把灯翻过来谁就有资格进隔间其他人只能排队等里面的人出来才能再翻。这个“占用中”的标志就是锁状态本身。然后面试官真正想看的是一个候选人能不能说清楚这个“占用标志”放在哪里用什么操作去翻翻不动的时候排队的人是怎么被安排和叫醒的这三个问题恰好对应锁实现的三层维度。1.2 锁的实现是分层配合不是单点技术从底层往上锁的实现分三层硬件层CPU 提供了LOCK前缀指令、CASCompare-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 是同一块空间放不下两个东西。做实验时经常会发现某些对象死活“偏不了向”多半就是这个原因。想看真实布局可以用 JOLJava Object Layout工具public class LockLayoutDemo { static final Object obj new Object(); public static void main(String[] args) throws InterruptedException { // 等过偏向延迟时间默认4秒也可以加 -XX:BiasedLockingStartupDelay0 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:BiasedLockingStartupDelay4000。原因是 JVM 启动阶段大量对象会被并发访问立刻开启反而导致频繁撤销。调优压测时可以把这个延迟调成 0 再分析。偏向锁撤销的代价很高。一旦另一个线程 B 来竞争同一个对象JVM 就得判断是否撤销偏向。撤销过程要求进入安全点SafePoint暂停所有业务线程再检查原来持有偏向锁的线程是否存活如果线程 A 已经退出临界区就把 Mark Word 改成无锁或轻量级锁状态如果 A 还在临界区就直接升级成轻量级锁。注意偏向锁的撤销不是发生在临界区内部而是发生在下一个线程尝试获取锁的时候。到了 JDK 15官方觉得偏向锁的复杂度和维护成本收益不成正比默认禁用了偏向锁JDK 18 里它已经被彻底移除。现在面试官问偏向锁更多是在考“你知不知道历史上 JVM 是怎么一步步优化的”。2.3 轻量级锁少量竞争时用 CAS 替代阻塞当存在竞争但竞争不激烈时JVM 升级到轻量级锁。它的思路是用自旋等待代替线程挂起。加锁流程是这样的线程 A 的栈帧里创建一个 Lock Record用于存放对象头原本的 Mark Word 拷贝。通过 CAS把对象头的 Mark Word 改成指向 Lock Record 的指针。成功后A 拿到锁。如果 CAS 失败说明有人在竞争。JVM 进入自旋也就是空转几条指令再重新尝试。自旋也不能无限自旋。早期 HotSpot 用固定自旋次数后来改成了适应性自旋如果上一次自旋成功了JVM 认为这次大概率也能成功就多自旋一会儿如果上一次失败很多次就直接放弃自旋升级为重量级锁。所以轻量级锁自旋失败之后锁会膨胀成重量级锁。这也解释了为什么锁升级是单向的从轻量级升级到重量级很容易但从重量级降回来几乎不会发生因为线程的阻塞和唤醒一旦引入内核态整个锁的语义已经变成一个真正的监视器锁HotSpot 没有必要再做反向优化。2.4 重量级锁Monitor 与真正的阻塞唤醒重量级锁对应的是ObjectMonitor对象在 JVM 内部表现为一份完整的数据结构核心字段包括_owner当前持有锁的线程、_recursions重入计数器、_EntryList等待获取锁的线程队列、_WaitSet调用 wait 的线程集合。字节码层面的实现是monitorenter和monitorexit。一个线程执行到monitorenter时如果_owner为 nullCAS 抢占成功否则把自己放进_EntryList并挂起。monitorexit时恢复_owner唤醒队列里的线程继续竞争。这里有个特别容易答错的知识点synchronized方法块里调用wait()后锁是被释放的。实现上就是当前线程从_owner挪到了_WaitSet别人可以重新抢锁直到有人notify/notifyAll把它从_WaitSet移回_EntryList。重量级锁的“重”在于阻塞和唤醒线程通常需要操作系统原语意味着用户态到内核态的切换一次切换成本大约在微秒量级。高并发臭名昭著的上下文切换开销主要就是这部分。3. ReentrantLock 与 AQS从队列角度看显式锁实现3.1 AQS 的骨架volatile state 加 CLH 变体队列如果说 synchronized 的锁升级是 JVM 内部优化那 JUC 里的显式锁核心骨架就是AbstractQueuedSynchronizerAQS。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)实际会依次调用tryAcquire(1)再试着获取一次。如果当前线程就是持有锁的线程state 加一返回 true这就是可重入。addWaiter(Node.EXCLUSIVE)把当前线程包装成 NodeCAS 追加到队列尾部。如果尾节点为空或 CAS 失败会走enq自旋初始化队列。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 变成 3unlock 三次后归零。但注意 state 是全局的JVM 怎么知道“是不是同一个线程”答案是通过getExclusiveOwnerThread()保存当前持锁线程tryAcquire里先判断持锁线程是不是当前线程是才允许重入。相比 synchronizedReentrantLock 最大的增强是可中断加锁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()配合 synchronizedJVM 能分析出没有其他线程能看到这个对象直接消除锁开销。锁粗化则是把相邻的多个同步块合并成一个更大的同步块。比如 for 循环里每次迭代都synchronized(obj)做一些小操作JIT 发现这些操作之间本来就没有竞争点合并后反而减少了反复加解锁的次数。很多人写代码时为了“减小锁粒度”而在循环里反复加锁其实 JIT 有可能把这个优化搞回去所以过度优化锁粒度往往没有意义。4.3 锁选型速查表锁/机制优势代价适用场景synchronized语法简洁JVM 自动升降级无需手动解锁后续版本偏向锁被移除后竞争激烈时阻塞成本高绝大多数需要互斥的共享资源ReentrantLock可中断、可超时、多个条件队列公平性可配需要手动 unlock存在忘记释放的隐患需要公平锁或等待可中断场景ReentrantReadWriteLock读写分离读读并发写锁饥饿可能计数器拆位后过于紧凑并发读明显多于写的场景StampedLock乐观读开销极低不可重入、不支持 Condition、使用复杂读多写少且需要极致性能的统计场景原子类CAS无锁、无上下文切换高竞争时自旋浪费 CPU有 ABA 风险简单计数、累加配合版本号防 ABA5. 面试实战怎么把“锁的实现”答出深度5.1 推荐回答框架如果面试只有三分钟我会这样组织答案先讲锁要解决什么问题原子性、可见性、互斥。锁的本质是控制临界区访问状态。讲硬件层CAS 和内存屏障定了基础原子操作。没有 CAS后面什么都谈不了。讲 synchronized 的锁升级无锁 - 偏向锁 - 轻量级锁 - 重量级锁每个状态解决什么场景为什么单向不可逆。讲 AQSstate FIFO 队列锁竞争失败后入队挂起释放锁后唤醒后继节点。最后对比一句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再开两个线程模拟竞争亲眼看看锁状态怎么从无锁一路变成重量级锁。这个直观经验比翻十遍源码分析文章都管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑