
做Java开发到一定阶段很难绕过AQS这个名字。AQS全称AbstractQueuedSynchronizer翻译过来是抽象队列同步器几乎所有你在并发编程里用到的锁和同步工具ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock甚至ThreadPoolExecutor内部的Worker底层都直接或间接建立在它上面。很多人在面Java并发时被问到一个问题你讲讲AQS的原理然后就没有然后了。这篇博客我打算把AQS的骨架彻底摊开讲清楚包括state状态位、CLH变体等待队列、获取锁和释放锁的完整链路、公平与非公平的实现差异再配上我实际排查线程问题时的经验教训。适合已经写过synchronized、用过ReentrantLock但没仔细看过源码的人也适合准备面试想系统梳理并发基础的读者。1. AQS到底解决了一个什么问题1.1 为什么Java要单独造一个AQS很多人一开始学并发时会觉得奇怪synchronized不是已经能解决线程互斥了吗为什么还要搞出一个AQS出来这就要从synchronized的局限性说起。早期的synchronized性能确实不怎么样依赖监视器锁竞争激烈时会有重量级锁的开销。后来JVM做了很多优化偏向锁、轻量级锁、锁膨胀性能其实已经很好了。但关键是synchronized提供的功能太“死板”它没法响应中断线程拿着锁死等它没有超时机制不能设置最多等多久它没有公平性选择谁抢到算谁的它一个监视器对象只能绑一个等待队列实现多个条件变量很麻烦。业务场景里经常需要“等一个条件满足了再继续”比如连接池满了要等待连接释放但不能无休止地等超过3秒就该失败。这种需求用synchronized很难干净地实现而用ReentrantLock配合Condition就能优雅解决。为了让各种锁和同步器都能复用同一套线程阻塞和唤醒机制Doug Lea在JDK 1.5里设计了AQS这套通用框架。说白了一句话AQS把“抢锁、排队、阻塞、唤醒”这套通用流程管起来具体“什么条件下可以抢到锁”由子类说了算。1.2 一个状态位加一个等待队列AQS的设计核心可以浓缩为两个关键概念一个是int类型的state状态位另一个是双向的CLH变体等待队列。state是个volatile int字段代表同步状态。对不同的同步器这个state的含义完全不一样。在ReentrantLock里它表示持有锁的次数0代表没人持有大于0代表重入了几次在Semaphore里它表示剩余的许可证数量在CountDownLatch里它表示还需要倒计数的次数。怎么修改state呢AQS提供了getState、setState和compareAndSetState三种基本操作底层依赖CAS来保证原子性。有线程申请锁、但state不满足条件时这个线程不能就这么干等它必须有个地方去“排队”。AQS内部维护了一个双向链表组成的队列每个排队线程被封装成一个Node节点。入队和出队都通过CAS操作完成支持线程的阻塞和唤醒。这个队列用的是CLH锁的变体后面我会单独讲它跟原始CLH锁的差异。1.3 模板方法模式框架和子类各干各的活AQS最漂亮的设计是用模板方法模式把“流程”和“决策”拆开。父类把获取锁、释放锁的整体流程用final方法写死子类只需要重写几个空方法来决定某一种同步器的具体逻辑。这几个需要子类实现的方法是tryAcquire(int arg)、tryRelease(int arg)、tryAcquireShared(int arg)、tryReleaseShared(int arg)、isHeldExclusively()。前两个用于独占锁比如ReentrantLock中间两个用于共享锁比如Semaphore、CountDownLatch最后一个用于判断当前线程是否持有独占资源。AQS的公开方法比如acquire(int arg)、release(int arg)、acquireShared(int arg)、releaseShared(int arg)它们负责完成“尝试失败后入队、入队后自旋、必要时阻塞、释放后唤醒后继”这些复杂流程。子类只管回答“我能不能拿到这个锁”“我能不能释放这个锁”。这种设计的好处在于不管实现多少种同步器队列管理、线程调度、中断处理的代码只存在一份不容易出错而且每个同步器只要写很少的逻辑就能复用完整能力。2. 核心机制逐层拆解state、Node节点与CLH变体2.1 state状态位所有同步逻辑的中枢先说state这个字段。在AQS里它被定义为private volatile int state修饰词很讲究。volatile保证多线程之间的可见性int而不是long是因为32位就够了而且在大多数平台上int的CAS操作非常高效。AQS暴露了三个操作state的方法getState、setState、compareAndSetState。compareAndSetState依赖sun.misc.Unsafe的compareAndSwapInt实现这是一个CPU级别的原子操作底层指令是cmpxchg。当多个线程同时对state做CAS操作时只有一个线程能成功其他线程会失败失败的线程就会进入后面的排队流程。这种“先试一把CAS不行再排队”的思路在后面的非公平锁里会被用到极致。state的语义由子类自行定义这是AQS灵活性所在。举个例子ReentrantLock刚初始化时state为0线程第一次lock成功通过CAS把state改成1同时记录当前独占线程是自己同一个线程再次lockstate变成2代表重入了一次释放时state递减减到0才真正释放锁。而Semaphore初始化时state就是许可证总数每次acquire相当于做一次state减1的CAS减到负数说明许可证不够了线程去排队每次release是state加1唤醒等待线程。2.2 Node节点等待队列里的“人”每个排队等待的线程在AQS里都被包装成一个Node对象这个Node是AQS内部类字段不少每个字段都有明确的含义。thread当前被阻塞的线程本身入队时赋值被唤醒获取到锁后置为null。waitStatus节点当前的等待状态包括CANCELLED(1)、SIGNAL(-1)、CONDITION(-2)、PROPAGATE(-3)和初始值0。prev和next双向队列的前驱和后继指针。nextWaiter在条件队列里使用时指向下一个等待条件满足的节点在普通同步队列里也有特殊用途比如标记当前节点是共享模式还是独占模式。waitStatus是很多人容易忽略但又特别重要的字段我单独做张表说明状态值名称含义1CANCELLED节点等待超时或被中断需要从队列中移除-1SIGNAL当前节点的后继节点处于等待状态当前节点释放锁后需要唤醒后继-2CONDITION节点在条件队列中等待不在同步队列中-3PROPAGATE共享模式下唤醒要向后传播0无状态新节点初始状态很多讲AQS的文章只在讲“入队出队”时介绍waitStatus但其实它才是保证队列正确运行的关键。特别是SIGNAL状态一个节点在想睡眠之前必须先把自己的前驱节点状态置为SIGNAL意思是告诉前驱“你释放锁的时候记得叫醒我。”如果不设置这个状态前驱释放锁时根本不知道后面还有人等着就会漏唤醒。2.3 入队与出队CLH变体队列到底改了什么AQS的等待队列脱胎于CLH锁CLH锁是Craig、Landin、Hagersten三位作者提出的一种自旋锁核心思路是让每个线程通过CAS把自己追加到队列尾部并且不断轮询前驱节点的状态来判断自己是否获得了锁。原始的CLH锁有几个问题它主要面向自旋场景所有线程都在CPU上轮询非常浪费资源而且它一般没有处理线程取消、超时的逻辑。AQS在这个基础上做了变体所以源码注释里叫“CLH variant”。关键改动有三点。第一由自旋改为阻塞加自旋结合。新入队的线程不会无限自旋它先自旋一小段时间尝试获取锁如果还拿不到就让出CPU进入阻塞状态依赖前驱节点在释放锁时去唤醒它。第二增加显式的prev和next双向指针。原始CLH锁通常只需要前驱指针AQS增加了后继指针这样阻塞唤醒时可以从head节点一路往后找到真正需要唤醒的节点。这里有一个细节入队时prev指针用CAS更新所以prev是可靠的而next指针在节点被取消时可能断掉因此遍历队列找后继时如果发现next为null需要从tail往前找这是很多源码分析文章反复强调的“从后往前找”的原因。第三用head节点作为哨兵。队列中第一个实际等待的节点不是headhead是一个已经获取到锁的线程的节点获取成功后setHead会把自己的thread清空真正的等待线程从head.next开始。这么做的好处是唤醒时判断条件变得简单且安全。3. 从源码看获取锁与释放锁的完整链路3.1 获取锁tryAcquire失败后到底发生了什么以独占锁为例AQS对外提供的最核心方法是acquire(int arg)源码逻辑很紧凑public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }先调用子类实现的tryAcquire尝试获取一次锁。如果成功整个流程直接结束线程继续执行这是最理想的情况性能最好。如果失败先调用addWaiter包装一个独占模式的Node入队再调用acquireQueued对当前线程进行“自旋等待获取”的过程。最后如果acquireQueued返回true说明等待期间线程被中断过但AQS不会直接在中断时抛出异常而是补上selfInterrupt把中断状态重新设置给调用者让外层代码自行判断。addWaiter的逻辑是先把当前线程包装成Node然后通过CAS把自己追加到队列尾部。如果尾部还没初始化或者CAS失败就进入enq方法。enq里是一个for循环配合CAS不断重试直到成功入队。这实际上是AQS里最常见的“CAS自旋”套路并发修改队列尾部时多个线程只有一个能成功失败的在下个循环继续试不会死锁也不会丢线程。3.2 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说明轮到自己了就再试一次tryAcquire。这里有一个容易被忽略的设计为什么不是一入队就直接阻塞等待唤醒因为从入队到真正park之间是有时间差的等待唤醒的开销很大不如在自旋里多试几次如果恰好锁被释放了直接拿锁更好。这就是前面说的“自旋加阻塞结合”的体现。tryAcquire失败后进入shouldParkAfterFailedAcquire。这个方法检查前驱节点的waitStatus决定当前线程要不要真的阻塞。如果前驱是SIGNAL状态说明前驱释放锁时会唤醒自己那就放心地park。如果前驱是CANCELLED状态说明前驱已经放弃了等待那就往前跳过这些取消节点重新调整队形。如果前驱状态是0或PROPAGATE则通过CAS把前驱状态更新为SIGNAL然后进入下一次循环。为什么要先把前驱改成SIGNAL再去park因为如果不设置这个标记释放锁的线程看不出队列里还有人等着就不会调用unpark这样当前线程可能永远睡不醒。parkAndCheckInterrupt做的就是LockSupport.park阻断当前线程线程在这里停住直到被前驱节点unpark唤醒。唤醒后再回到循环开头重新执行“前驱是head再试一次tryAcquire”的逻辑。如果获取失败就继续park如果成功就setHead。3.3 释放锁唤醒后继者的细节处理释放锁的入口是release(int arg)同样很短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由子类实现只有在独占锁彻底释放时才会返回true。以ReentrantLock为例state需要减到0才算真正释放仅仅从2变成1不会触发唤醒因为锁还被重入一次。这也是可重入锁在“释放次数不匹配”时容易坑人的原因lock了两次却只unlock一次锁永远不释放其他线程永远等下去。真正要唤醒时并不是唤醒队首的第一个等待节点而是通过unparkSuccessor找到距离head最近的一个非取消节点来唤醒。实现里有个关键细节从头节点往后找后继时如果next为null或者后继节点已经是CANCELLED就要从tail往回遍历找。原因前面提过取消节点的next链接可能断裂但prev指针是可靠更新的所以反方向扫描更稳妥。唤醒也不是直接让线程去tryAcquire而是执行LockSupport.unpark让线程从parkAndCheckInterrupt里醒来回到acquireQueued的循环里重新争抢。这样设计的好处是把唤醒和抢锁解耦锁状态的管理始终保持在AQS内部不会出现刚唤醒了才发现锁又被别人抢走还是要重新park的状态错乱。3.4 中断与超时获取两个常用变体方法独占锁除了标准的acquire还有acquireInterruptibly和tryAcquireNanos一个支持响应中断一个支持超时。它们的核心实现分别是doAcquireInterruptibly和doAcquireNanos流程跟acquireQueued非常像区别在于park醒来后如果发现线程被中断doAcquireInterruptibly会直接抛出InterruptedException而不是默默设置中断标志。doAcquireNanos会先检查剩余时间是否足够如果剩余时间小于一个阈值spinsForTimeoutThreshold默认1000纳秒就放弃park直接进入自旋快速尝试因为park/unpark的系统调用开销可能比自旋还大。这个阈值判断非常实用也是AQS里一个很值得借鉴的性能优化点当等待时间极短时阻塞唤醒的开销大于自旋轮询干脆不阻塞了。理解了这一点在写自己的一些并发控制逻辑时也能用到类似思路。4. 公平、非公平与多种同步器实现4.1 非公平锁为什么“快”ReentrantLock的默认实现是非公平锁内部是NonfairSync。它跟公平锁最大的区别在于lock方法的第一行直接做了一次CAS尝试把state从0改成1。如果成功不管队列里有多少线程在排队这个新来的线程直接拿到锁。为什么允许这种“插队”行为因为大多数情况下锁竞争并不激烈新线程直接抢一次能省掉入队、park、unpark、再抢锁这一整套过程整体吞吐量更高。代价是队列里的线程可能被长时间饿着但统计上来说线程总有轮到的时候所以工程上默认采用非公平是合理的。我把两者对比写出来对比项非公平锁公平锁首次获取锁先直接CAS抢一次先判断队列中是否有前驱等待是否排队不完全按先来后到严格FIFO吞吐量更高略低适用场景默认场景、追求性能对接业务要求严格公平、避免线程饿死实现关键lock里先compareAndSetStatehasQueuedPredecessors判断4.2 可重入计数是怎么实现的ReentrantLock的可重入体现在两个细节。第一重入时state不是置为1而是累加当前值加1在线程已经持有锁的情况下再次lock会执行“如果当前线程是独占线程则setState(nextc)”把state加1。第二释放时state递减直到decrement到0才认为锁真正释放同时把独占线程owner清空。这时候再看isHeldExclusively方法就清楚了它判断当前线程是不是owner线程。ReentrantLock里的tryAcquire会先判断当前state是否大于0如果是再看当前线程是否是独占线程不是的话直接返回false线程就要去排队而不会发生一个线程暴力改掉别人持有中的锁这种事故。这套逻辑在ReentrantReadWriteLock里也类似只是state被拆成高16位和低16位分别表示读锁和写锁的持有情况。4.3 从AQS派生出的常见同步器Semaphore使用共享模式。state表示剩余许可数acquire时tryAcquireShared做“当前许可数减1”的CAS如果结果小于0就入队等待release时tryReleaseShared加1并唤醒等待线程。CountDownLatch同样共享模式。初始化state为倒计数总数countDown时releaseShared逐次递减等state归零时所有await的线程会被统一唤醒。ReentrantReadWriteLock把state拆成两部分高16位是读锁计数低16位是写锁计数。读写、写写互斥读读不互斥代码实现要比ReentrantLock复杂得多但AQS依然只提供一套队列管理机制来支撑。ThreadPoolExecutor中的WorkerWorker继承AQS实现了一个不可重入的独占锁。它用state从0变1来标记工作线程是否空闲这样在shutdown时可以防止正在执行任务的线程被中断而空闲线程不受影响。5. ConditionObjectAQS里的条件队列5.1 await和signal的内部流转除了同步队列AQS还通过内部类ConditionObject实现了Condition接口这就是Java里经典的生产者消费者等待机制。每个Condition对象内部维护着一个由Node节点串起来的条件队列用的是nextWaiter指针不是同步队列里的next指针。调用condition.await()时当前线程必然是持有锁的。它会被封装成一个waitStatus为CONDITION的Node加入到条件队列尾部然后调用fullyRelease把持有的锁全部释放掉要处理重入所以是全量释放最后通过LockSupport.park阻塞自己。这一步很关键必须先释放锁再阻塞否则其他线程没法拿到这把锁去执行signal就永远唤不醒它。signal()做的事恰好相反找到条件队列里的第一个节点调用transferForSignal把它从条件队列移到同步队列尾。这个转移过程核心是把节点的waitStatus从CONDITION改成0再用CAS把它追加到同步队列尾部。转移到同步队列后这个节点并不会被立刻唤醒而是等它的前驱节点释放锁时走正常的unparkSuccessor流程来唤醒它。这也解释了为什么signal之后需要很多线程重新抢锁而不是立刻执行。5.2 条件队列与同步队列的分工很多初学者会把两个队列搞混。同步队列里排队的线程是想获取锁而没拿到锁的线程条件队列里排队的线程是已经拿到了锁、但是因为某个业务条件不满足而主动让出锁等待的线程。一个线程在同一时刻只能位于其中一个队列。await把线程从同步队列“挪”到条件队列signal再把线程从条件队列“挪”回同步队列。两个队列各自独立但都复用Node这个结构AQS用一个类把这套机制统一了起来。我在真正搞懂这段逻辑之前一直很奇怪明明是同一个线程在等为什么waitStatus会有两种情况理解了条件队列后就很自然了在条件队列里是CONDITION状态一旦被signal转移到同步队列状态就变成0等它的前驱释放锁时又会被改成SIGNAL。每一环都指向一个明确的语义。6. 常见的坑与排查经验6.1 一眼定位线程卡在AQS哪里线上遇到线程一直不返回最常见的手段是生成jstack线程转储文件。如果是卡在锁上状态会显示为WAITING (parking)或者BLOCKED堆栈里会往下找到LockSupport.park、AbstractQueuedSynchronizer.acquireQueued这些方法。我踩过的一个比较隐蔽的坑是两个线程互相持有对方需要的锁形成死锁。jstack能直接检测出来它会打印“Found one Java-level deadlock”并列出等待环。但还有一种情况更麻烦多个线程都在AQS的队列里等待队列里的节点因为某些原因一直没有被唤醒。这种问题不太可能是AQS本身的bug而是业务代码的问题比如Condition的signal没有被执行或者Unlock少了一次导致state没有归零。遇到这种问题我会先看线程状态是WAITING还是BLOCKED再看这个线程有没有对应的持有者线程在正常运行。从一个锁关联的所有线程整体分析思路会更清楚。另外Arthas是我排查并发问题时的常备工具。它可以直接看某个对象的内部字段我经常用它查看AQS的head、state和tail字段能直观看到排队线程数和锁状态。在分析压测中的锁竞争时比看堆栈效率高很多。6.2 五个容易踩的坑可重入锁的lock/unlock次数不匹配。lock两次却只unlock一次锁永远不会真正释放。用try-finally包裹业务代码时finally里只放一个unlock但业务里可能调用了多次lock的重入方法这种不对称会导致其他线程全部阻塞。在持锁时做耗时的网络IO或数据库查询。这会把锁的竞争时间拉长队列里的线程大量堆积。别人看起来是锁没释放本质是临界区太大。Condition判断条件时不用while循环。await被唤醒是“可能条件满足了”不是“一定满足了”要用while重新检查条件。比如在等待队列大小的场景多个生产者消费者协作时很容易出问题。把AQS当成自旋锁用让大量线程在acquireQueued里长时间自旋。AQS的默认行为会让线程很快park但如果前驱节点更新SIGNAL状态失败或者重写了tryAcquire导致CAS一直失败线程可能在循环里出不来。直接继承AQS而不是使用已经封装好的同步器。AQS设计出来是给同步器开发者用的框架业务代码直接继承AQS是极不推荐的因为你很难把所有边缘情况处理好。6.3 学习AQS的几条实战心得如果想让AQS这块知识真正变得可用我建议动手做三件事第一源码阅读不要逐行看先抓住state和队列这两个锚点然后把acquire和release的流程画出来每次方法调用要搞清楚它改了什么字段。第二自己写一个自定义同步器比如实现一个只允许N个线程同时通过的门禁只需要重写两个方法按我前面的思路用state计数就能跑起来比看十遍源码都有效。第三遇到并发问题优先怀疑业务代码而不是框架代码AQS在JDK里运行了这么多年绝大多数问题都出在线程间协作逻辑上。个人体会最深的一点AQS并不是一个需要你“背下来”的算法而是一个关于“如何高效地让线程排队和唤醒”的工程答案。它的每一项设计几乎都能在现实世界里找到对应银行窗口办业务有人成功取号有人排队等待叫号员只喊下一位过号要重新排队。理解了真实场景里“等待”这件事需要什么回看源码时就不会觉得晦涩了。