深入AQS:理解Java并发同步器的设计与源码实现

发布时间:2026/10/7 17:50:17
深入AQS:理解Java并发同步器的设计与源码实现 1. AQS到底是什么为什么并发框架都绕不开它如果你在学Java并发或者在看像ReentrantLock、CountDownLatch、Semaphore这些类的源码一定会撞见同一个名字AQSAbstractQueuedSynchronizer。它太常被提起以至于面试都开始往死里问原理。但在搞懂一堆源码细节之前我觉得得先想明白一个更根本的问题AQS到底是来干嘛的没有它行不行。AQS的全称是 AbstractQueuedSynchronizer翻译过来是抽象队列同步器。它本质上是一个用来构建锁和其他同步组件的框架。JDK里很多并发工具就是基于它实现的ReentrantLock就是最典型的一个它内部有一个Sync类直接继承AQS然后利用AQS提供的状态管理、线程阻塞、唤醒机制实现了可重入的独占锁。为什么说它是框架而不是一个具体的锁因为它把什么叫拿到锁什么叫释放锁的判断逻辑留给子类去实现而把拿不到锁的线程怎么排队线程什么时机阻塞释放锁之后怎么唤醒下一个线程这些通用机制在父类里写好了。这有点像你要在食堂开个窗口AQS把排队、叫号、取餐的动线都修好了你只需要决定什么条件才算轮到下一个人。对比一下synchronizedJVM层面管好了锁开发者没法看到里面怎么排队、怎么唤醒。而AQS把这一套逻辑用Java代码明明白白摊开给你看分析它、改它、基于它做二次封装都可以。这就是为什么框架作者和面试官都盯着AQS的原因——它是Java并发世界里最核心的抽象层之一。2. 打破黑盒AQS的顶层设计与核心模型2.1 三件套state、CLH队列、独占线程理解AQS先记三个东西一个int类型的state字段一个双向链表结构的CLH变体队列一个记录独占锁持有者的exclusiveOwnerThread字段。state是AQS的灵魂它用volatile修饰保证多线程之间的可见性并用CAS操作来安全修改。不同的同步器对state的解释完全不同ReentrantLock里state表示锁被重入了几次Semaphore里state表示剩余可用许可证数量CountDownLatch里state表示还剩几个门闩没打开。简单说state就是同步器自己定义的状态数AQS不关心这数字具体代表什么只提供修改它的CAS工具以及基于这个状态判断要不要阻塞线程的机制。CLH队列是AQS的等待室。它其实是一种基于链表结构的FIFO队列原始CLH队列在学术论文里是自旋锁的变体而AQS在此基础上做了改造用阻塞和唤醒替代自旋所以叫CLH变体队列。每个拿不到锁的线程会被包装成一个Node节点挂到这个队列尾部然后通过LockSupport.park阻塞自己。等持有锁的线程释放锁时AQS会从队列头部开始找合适的节点唤醒它的线程。exclusiveOwnerThread这个字段在AQS的父类AbstractOwnableSynchronizer里专门记录当前持有独占锁的线程。ReentrantLock的重入就靠它同一个线程第二次加锁发现当前持有者是自己就只把state加一不用排队。2.2 模板方法模式框架把流程定死子类只填判断逻辑AQS的设计用的是模板方法模式这是理解它整个代码结构的关键。简单说父类把算法骨架定死了把某些步骤延迟到子类实现。AQS把获取锁和释放锁的整体流程写死在父类的final方法里子类只需要实现几个钩子方法。比如获取独占锁时父类定义的是acquire方法public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) { selfInterrupt(); } }这段逻辑读起来就是先尝试获取一次锁如果成功就万事大吉如果失败把当前线程包装成Node节点加入等待队列然后在队列里自旋或阻塞直到有机会获取锁。而tryAcquire这个尝试获取的逻辑就是留给子类实现的钩子方法。ReentrantLock的公平锁和非公平锁其实就是在tryAcquire这一个方法上做文章。ReentrantLock的非公平锁直接在tryAcquire里CAS抢锁抢到就赢公平锁则先看队列里有没有人在排队有排队就老实去队尾。同一个框架里换一个钩子方法的实现就得到两种完全不同的锁行为这就是模板方法模式的威力。3. 源码主线从lock()开始的完整获取与释放流程3.1 非公平锁的lock()为什么它能插队用ReentrantLock加锁默认走的是非公平锁。看NonfairSync的lock方法final void lock() { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); } else { acquire(1); } }这个方法很有意思它跟公平锁的lock不一样——它先不管队列里有没有人等锁直接CAS把state从0改成1如果改成功了锁就被当前线程拿走了。这个CAS成功意味着锁之前是空闲状态所以新来的线程直接插队拿到了锁。这放在现实生活中就是你排了很久的队结果一个刚来的人直接冲到窗口把饭买走了朋友你来评评理非公平锁干的就是这回事。如果CAS失败了说明锁正被别的线程持有于是走acquire模板方法。注意有意思的点acquire里第一步还是调用tryAcquire也就是非公平锁再试一次抢锁。这一次锁可能刚好被上一个线程释放了CAS又有可能成功。所以非公平锁在首次抢锁失败之后、真正入队之前其实还有一次侥幸的机会。然后看nonfairTryAcquire方法final boolean nonfairTryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current getExclusiveOwnerThread()) { int nextc c acquires; if (nextc 0) { throw new Error(Maximum lock count exceeded); } setState(nextc); return true; } return false; }这里能看到两个分支如果锁空闲CAS尝试抢占如果锁已经被当前线程持有直接state加一也就是重入。这个重入计数溢出的错误处理也不难理解state是int重入次数超过Integer.MAX_VALUE的话直接抛异常虽然现实中几乎不会发生但API设计上考虑到了这种极端情况。3.2 拿不到锁的线程去哪addWaiter与enq入队前面说tryAcquire失败之后线程就要准备入队了这个动作在addWaiter方法里private Node addWaiter(Node mode) { Node node new Node(Thread.currentThread(), mode); Node pred tail; if (pred ! null) { node.prev pred; if (compareAndSetTail(pred, node)) { pred.next node; return node; } } enq(node); return node; }这里有个细节值得注意CAS设置尾节点。因为AQS要支持高并发场景多个线程可能同时入队所以必须用CAS来竞争谁先当新尾巴。如果tail不为null新节点先用CAS把自己挂到尾节点后面成功就直接返回一旦CAS失败——说明别的线程也正在入队——就调用enq方法用自旋CAS的方式重新入队。enq方法里还有一个经典技巧如果tail为null说明队列还没初始化要先CAS设置一个空的head节点再处理真正的节点入队。这叫做惰性初始化也就是队列在第一次有线程竞争锁的时候才创建避免了每次加锁都空建队列的开销。我读源码的时候经常感慨这种CAS配合自旋处理并发入队的写法真的是并发编程的教科书范例。多线程环境下悲观锁用synchronized之类的互斥而CAS加循环是乐观的方式它在任何一个瞬间都只有一个线程能成功失败的线程不阻塞继续自旋重试直到成功。3.3 acquireQueued入队之后的自旋、阻塞与中断节点加入队列后真正的煎熬开始了看acquireQueued方法final boolean acquireQueued(final Node node, int arg) { boolean failed true; try { boolean interrupted false; for (;;) { final Node p node.predecessor(); if (p head tryAcquire(arg)) { setHead(node); p.next null; failed false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) { interrupted true; } } } finally { if (failed) { cancelAcquire(node); } } }简单翻译线程入队之后进入无限循环。每次循环先看自己的前一个节点是不是head如果是head说明自己是队列里第一个正规军就再尝试一次获取锁成功的话把自己设为新的head从队列里出队整个获取流程结束。如果前一个节点不是head或者尝试获取失败就检查能不能阻塞自己能阻塞就调用LockSupport.park让线程休眠直到被唤醒。这里面有无数细节值得说。第一个细节是只有前驱节点是head的节点才有资格尝试获取锁。这是FIFO队列公平性的基石新来的线程可以插队但已经入队的线程必须按顺序一个一个来。队列是双向链表真正唤醒的时候AQS从head向后找下一个有效节点所以队列里的顺序就是获取锁的顺序。第二个细节是shouldParkAfterFailedAcquire里对前驱节点waitStatus的处理。每个Node节点上有一个waitStatus字段表示当前节点的状态比如SIGNAL表示我准备被唤醒CANCELLED表示我已经放弃等待。private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws pred.waitStatus; if (ws Node.SIGNAL) { return true; } if (ws 0) { do { node.prev pred pred.prev; } while (pred.waitStatus 0); pred.next node; } else { compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }这个方法是在干嘛它在确认一件事前驱节点是否承诺了等我释放锁时唤醒你。如果前驱节点的waitStatus还不是SIGNAL那就用CAS把它改成SIGNAL然后返回false让上层再跑一圈循环。这样做的好处是线程不能一股脑直接park自己万一前驱节点马上就释放锁了park之后永远没机会被唤醒那就完蛋了。所以一定要先给前驱节点设置好SIGNAL标记确保后续释放锁的线程会执行unpark操作。第三个细节是线程被唤醒之后会回到for循环再次检查自己的前驱是不是head再次tryAcquire。这就是为什么叫自旋加阻塞——它先用park把自己挂起省CPU被唤醒后又开始新一轮的旋转检查。整个机制其实很优雅把忙等和休眠结合了起来在锁竞争不激烈的时候唤醒后基本一两次循环就能拿到锁。3.4 release释放锁并唤醒后继线程有获取就得有释放看父类的release模板方法public final boolean release(int arg) { if (tryRelease(arg)) { Node h head; if (h ! null h.waitStatus ! 0) { unparkSuccessor(h); } return true; } return false; }tryRelease也是子类实现的钩子方法ReentrantLock里是这么写的protected final boolean tryRelease(int releases) { int c getState() - releases; if (Thread.currentThread() ! getExclusiveOwnerThread()) { throw new IllegalMonitorStateException(); } boolean free false; if (c 0) { free true; setExclusiveOwnerThread(null); } setState(c); return free; }tryRelease的逻辑不复杂先算出state减去releases之后的值如果执行释放的线程不是锁的持有者直接抛IllegalMonitorStateException——这就是为什么不是持锁线程不能在代码里解锁会直接报错。然后判断减完之后state是不是0是0说明锁完全释放了把exclusiveOwnerThread置空返回true不是0说明还有重入次数锁还在当前线程手里返回false。如果tryRelease返回true说明锁已经彻底空了该去唤醒等待队列里的人了。unparkSuccessor负责这个工作private void unparkSuccessor(Node node) { int ws node.waitStatus; if (ws 0) { compareAndSetWaitStatus(node, ws, 0); } Node s node.next; if (s null || s.waitStatus 0) { s null; for (Node t tail; t ! null t ! node; t t.prev) { if (t.waitStatus 0) { s t; } } } if (s ! null) { LockSupport.unpark(s.thread); } }这里有个特别值得说的细节为什么唤醒后继节点时要从tail往前找而不是直接找node.next因为CLH队列是双向链表但入队的时候是先设置prev指针再CAS设置tail最后才设置前驱节点的next指针。也就是说在并发入队的瞬间可能存在next指针还没设置好的情况。从tail往前遍历可以确保一定能找到最靠近head的有效节点虽然多花了点时间但不会漏掉任何一个该被唤醒的线程。这个细节我一开始读源码的时候完全没注意到后来复现并发场景时才发现这种宁可多走几步也不丢线程的设计正是生产级并发代码的严谨之处。4. ReentrantLock里的设计精华重入、公平与中断4.1 可重入是怎么实现的可重入这个概念放到业务里就是同一个线程可以多次获取同一把锁而不会把自己锁死。举个最直观的例子public synchronized void outer() { inner(); } public synchronized void inner() { // 业务逻辑 }如果锁不可重入outer调用inner的时候线程发现自己已经持有锁了再去获取就死锁了。但Java里synchronized和ReentrantLock都支持可重入因为JVM和AQS都记录了锁的持有者线程。AQS里的实现路线是这样的在tryAcquire方法里如果发现当前线程等于exclusiveOwnerThread就把state加上acquires通常是1而不需要任何CAS操作因为持有锁的线程只有一个单线程修改state不会产生竞争直接setState就够了。释放的时候对应地减直到减到0才真正释放锁。我在写业务代码时遇到过一种情况一个递归方法里反复调用自己每次都需要加锁。如果锁不可重入第一层递归就死锁了。ReentrantLock配合可重入这种场景完全没压力。从设计角度看线程持有锁这个语义由exclusiveOwnerThread承载state只负责计数两者配合就构成了可重入的底层基础这个思路真的很干净。4.2 公平锁与非公平锁的差别到底在哪公平锁和非公平锁的代码差别其实就集中在tryAcquire方法里。公平锁的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; } } else if (current getExclusiveOwnerThread()) { int nextc c acquires; if (nextc 0) { throw new Error(Maximum lock count exceeded); } setState(nextc); return true; } return false; }跟非公平锁唯一的区别就一句话CAS之前先调用hasQueuedPredecessors看看队列里有没有人排在自己前面。如果有直接放弃这次尝试乖乖去队尾排队。这里需要注意一个细节hasQueuedPredecessors返回的语义是是否有线程比当前线程更早地请求锁如果当前线程本身就是队列里的head.next那它返回false因为排队排到自己了可以去抢锁。hasQueuedPredecessors的源码也有值得玩味的地方public final boolean hasQueuedPredecessors() { Node t tail; Node h head; Node s; return h ! t ((s h.next) null || s.thread ! Thread.currentThread()); }这个方法里有个经典并发问题当h ! t且h.next null时返回true。这意味着队列正在初始化head节点已经创建但第一个实际等待线程还没完全入队这时后面来的线程认为前面有人排队了不能插队。这种边界的处理就是为了防止锁的公平性被并发入队的瞬间打破。关于公平锁和非公平锁怎么选我的经验是默认用非公平锁性能更高。非公平锁的代价是可能造成线程饥饿但ReentrantLock的非公平锁其实没有想象中那么不公平——它只是允许新线程在锁释放的瞬间插队但已经入队的线程一旦开始排队队列内部依然是严格FIFO的。高并发下如果要求绝对的排队顺序比如某些对任务顺序敏感的业务才考虑公平锁。实践中我很少开公平锁因为它的线程切换成本明显高吞吐量会下降。4.3 中断响应lockInterruptibly和tryLock(timeout)ReentrantLock比synchronized强的一点就是支持中断响应。线程调用lockInterruptibly方法获取锁时如果被其他线程interrupt那么线程会从阻塞状态中被唤醒并抛出InterruptedException而不是傻傻地继续等锁。这个特性在实战中特别有用。比如一个线程在等待锁的时候服务要下线了如果锁迟迟拿不到线程一直block在那边进程就被卡住了。用lockInterruptibly外部可以把等待锁的线程interrupt掉线程获得响应并快速退出整个关闭流程就能顺利走完。AQS的中断处理逻辑藏在parkAndCheckInterrupt里private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); }线程从park状态被唤醒后第一件事就是检查自己是否被中断如果是返回true。这个返回值在acquireQueued里被收集到interrupted变量里等线程真正获取到锁之后调用selfInterrupt重新设置中断标志。这种外部中断不打断获取锁的流程但保留中断状态等拿到锁后补偿中断的设计体现了并发编程中对线程中断语义的精细把握。interrupt不是一个即时指令更像一个协作机制。5. 条件队列Condition与AQS的配合ReentrantLock提供了一个synchronized不具备的能力多个条件队列。synchronized配合wait和notify只能在一个隐式条件队列上等待而ReentrantLock可以new出多个Condition对象让不同类型的等待线程在不同的队列上休息。AQS内部有一个内部类ConditionObject它实现了Condition接口。每个ConditionObject对象维护一个单向链表是条件队列。当线程调用condition.await时线程会做三件事把自己从AQS的同步队列中移除加入ConditionObject的等待队列然后释放锁。等别的线程调用condition.signal时会有线程从条件队列移动到同步队列重新参与锁的竞争。现在你再看从整体设计上理解就轻松了。AQS对外提供了一个统一的同步队列管理线程的阻塞与唤醒ConditionObject在它之上再架了一层条件队列专门管理满足特定条件的线程等待。这样想获取锁但获取不到和获取到锁了但业务条件不满足两种情形就能分开管理了。我实际用多个Condition时踩过的坑是signal和signalAll的选择不当。signal只唤醒条件队列里的第一个线程如果那个线程等待的条件比较特殊被唤醒后发现条件还是不满足又继续await就可能导致其他线程一直没机会被唤醒。这就是信号丢失问题。如果拿不准业务允许的情况下优先用signalAll虽然可能会多唤醒几个线程但至少不会卡死。6. 高频问题与实战排查经验6.1 锁饥饿和公平性怎么判断要不要用公平锁很多人写并发代码时锁竞争策略默认就上ReentrantLock然后用了几年也说不清自己用的是公平还是非公平更不知道怎么排查锁饥饿。锁饥饿的典型特征是某个线程长时间获取不到锁但其他线程能正常执行。在高并发下这个现象可能表现为某条业务处理特别慢或者某个请求一直pending。用jstack抓线程栈能看到大量线程阻塞在同一个锁的lock方法上却始终看不到某个线程获得执行机会。排查思路上我会先看锁的竞争程度。如果锁的持有时间很短但竞争线程极多非公平锁的插队会让队列尾部的线程长时间得不到锁。这种场景下换公平锁能解决问题但会牺牲吞吐量。如果业务对操作顺序没有那么敏感更推荐用分段锁或读写锁之类的并发工具让整体竞争下降才是治本的路子。6.2 死锁排查jstack的几行日志怎么读ReentrantLock死锁和synchronized死锁一样都会导致相关线程永久阻塞。排查死锁最方便的还是jstack工具。抓下来的线程栈里两个线程互相持有对方需要的锁会出现Found one Java-level deadlock的明确提示然后列出两个线程的锁等待关系。在ReentrantLock的场景下线程栈会显示线程阻塞在AbstractQueuedSynchronizer的parkAndCheckInterrupt方法上沿着栈往下看能看到是哪个ReentrantLock的哪个lock方法。这里有个实践细节jstack默认抓的是当前瞬间的线程状态如果死锁线程刚好在运行可能一次没抓到。建议多抓几次间隔几秒对比线程栈确定哪些线程是持续block的。6.3 ReentrantLock和synchronized怎么选这俩选了这么多年很多人的选择标准还是ReentrantLock功能多所以更好。我的判断标准其实很简单如果只需要基本的互斥、可重入用synchronized就好简洁安全不易错。如果需要锁超时、可中断、多个条件队列或者希望实现非公平/公平切换用ReentrantLock。如果遇到缓存热点、读多写少优先考虑ReentrantReadWriteLock或StampedLock而不是在互斥锁上死磕。高并发性能上两者在Java 6之后的差距越来越小synchronized经过锁升级优化后并不慢。从维护角度讲synchronized最不容易写错。ReentrantLock必须手动unlock一定要在finally里做否则出异常锁就永远不会释放——这个坑我见过不止一次了。6.4 真拿不到锁的时候tryLock超时到底有多重要如果锁竞争特别激烈用lock()一直等待是有风险的。一个线程拿不到锁会一直block如果持锁线程又出了问题没释放所有等锁的线程全卡死。这时tryLock(timeout)几乎是救命的。tryLock带超时的实现底层依然走AQS的acquire模板但多了对超时时间的计算。每次自旋循环里都会检查距离超时剩余多少时间超时就返回false。它底层的定时唤醒依赖LockSupport.parkNanos来实现这也是AQS提供的一个核心能力。我用tryLock的典型场景是多个模块同时访问一个共享资源但每个任务都有最大等待预算。等不到锁就返回失败让上层走降级逻辑。这样整个系统的可用性不会被一个锁拖垮。7. 写在最后从源码阅读到工程落地的心得回头再看AQS这套设计我最佩服的其实是它的分层思想。state负责状态队列负责排队LockSupport负责线程阻塞和唤醒模板方法负责把流程串起来子类只需要关注什么时候算拿到锁。这几个核心关注点被拆得一清二楚所以各种同步工具才能像搭积木一样在它上面快速实现。如果你是自己学我建议不要一次性硬嚼全部源码。先跑通默认的非公平锁加锁、解锁流程把acquire、addWaiter、acquireQueued、release这条主线打通再去研究公平锁、重入、中断、条件队列最后才是ReentrantReadWriteLock、Semaphore这些进阶实现。你会发现主线通了其他都是在主线上做变种。我自己带团队时对并发框架的选型有一条原则能用JDK现成工具解决的就不要再自己造锁。AQS和ReentrantLock已经兼顾了正确性和性能自己写同步逻辑掉进细节坑的概率远比省下的那点性能收益大。理解底层原理的目的是为了更好地使用上层工具而不是为了遇事重造轮子。最后再分享一个小技巧遇到锁相关疑难问题别急着猜先jstack抓现场再结合代码看锁的获取顺序和释放路径。AQS的日志其实已经很友善了阻塞在哪一行、等待哪个锁栈里都写得清清楚楚。把AQS的源码主线读通一遍之后这些排查工具就不再是黑盒你能一眼看出问题出在入队阶段还是唤醒阶段这比背一百个面试题都管用。