1. 为什么说AQS是并发包的“珠穆朗玛峰”搞Java并发编程的人迟早会撞上AQS这个名字。它全称是AbstractQueuedSynchronizer中文叫抽象队列同步器在java.util.concurrent.locks包下面。我见过不少工作三五年的开发能熟练使用ReentrantLock、CountDownLatch、Semaphore但一问到AQS就含糊其辞说“底层太复杂没细看”。实际上整个并发包最核心的骨架就是AQSReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock、ThreadPoolExecutor的Worker部分全都建立在这一个类之上。把它啃下来等于一次性打通了并发包的任督二脉。我第一次认真读AQS源码是在排查一个线上性能问题时。那个系统用ReentrantLock做本地分布式锁的互斥结果高峰期CPU打满线程大量阻塞。当时debug到AQS的acquireQueued方法才真正意识到这个类有多精巧。它只用了一个volatile的int状态变量加上一个双向链表队列就支撑起整个Java并发工具族的底层调度。可以说你写的每一行并发代码背后几乎都有AQS在默默兜底。这篇文章不是教你怎么用Lock——那太初级了——而是从一个常年和并发Bug打交道的角度把AQS的设计思路、核心实现、复用场景、以及实战中的坑一次讲透。适合那些已经会用并发工具、但想深入理解底层原理的开发者也适合准备面试中高级岗位的人参考。很多人把AQS比作并发包的“珠穆朗玛峰”因为它确实是整个体系里最难翻越也最有价值的一座山。翻过这座山你会突然发现并发编程不再是零散工具的记忆题而是一张可以推演的地图。2. 设计思路拆解一个状态变量加一条队列凭什么统治并发包2.1 核心模型共享变量state与线程等待队列AQS的本质其实非常朴素。它维护了一个被volatile修饰的int变量state这个变量代表什么由使用者自己定义。在ReentrantLock里state表示锁被重入的次数在Semaphore里state表示剩余许可数量在CountDownLatch里state表示还需要等待的计数。AQS本身不知道state的业务含义它只提供基于这个状态的获取和释放框架。同步队列则是一个CLH锁队列的变种。CLH锁原本是单向链表AQS改造成了双向链表每个节点用一个volatile的waitStatus字段记录线程的状态。之所以用队列是因为竞争激烈时不能让所有线程都在一个自旋上消耗CPU而是应该把没抢到资源的线程挂起进入一个FIFO队列等待。这个设计很像银行柜台排队柜台有空位state允许获取直接办理没空位取号进等候区前面的人办完了叫下一个号。AQS把“能不能通过”的判断逻辑留给子类自己负责“排队”和“叫号”的流程。这就是模板方法模式的核心用法。子类只需要重写tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared、isHeldExclusively这几个方法中的一部分而AQS已经封装好了acquire、release、acquireShared、releaseShared等骨架逻辑。2.2 为什么用模板方法而非直接定义接口我第一次看源码时有个疑问为什么不直接定义接口让每个锁自己实现完整逻辑接口的问题是每个实现都要重复处理线程阻塞、唤醒、中断、队列维护这些公共代码而且很容易写出细微的并发Bug。AQS把可变的部分对state的判断和修改抽象出来把不可变的部分队列管理、阻塞唤醒固化成算法这样来一个文件锁就只需要写几行tryAcquire和tryRelease逻辑绝大多数的正确性由AQS保障。这种设计在实际工程里特别讨喜。比如JDK内部很多自定义同步器包括未来的并发类都可以通过继承AQS快速实现。我在工作中封装过一个简单的限流器基于Semaphore改重写tryAcquireShared加了一个基于时间窗口的判断几十行代码就搞定完全不需要关心线程挂起和唤醒。所以理解AQS的第一要务是分清两个角色AQS是导演子类是演员演员只演自己该演的片段剩下的运镜和剪辑都是导演的事。2.3 state的可见性与内存语义AQS的state是volatile的这保证了读写之间的可见性。更重要的是AQS在acquire成功之后的逻辑和release之前的逻辑天然构成了一个happens-before关系。relelease之前对共享变量的修改在下一个成功acquire的线程里是可见的。这正是Lock能替代synchronized做线程间通信的原因。比如你用一个锁保护一个HashMap线程A往Map里放数据后释放锁线程B获取锁后读Map一定能看到线程A写入的数据。因为state的volatile写发生在两个操作之间volatile写-读建立了内存屏障。很多面试官喜欢问“AQS如何保证内存可见性”答案就在这里不是队列保证的是volatile state保证的。队列只负责线程调度内存语义靠state。理解了这一点你就不会把AQS和普通的数据结构混为一谈。3. 核心细节解析Node状态、中断响应、超时控制的实作内幕3.1 节点waitStatus的四种状态AQS的Node对象里有个int字段waitStatus取值只有四种有效状态CANCELLED值为1、SIGNAL值为-1、CONDITION值为-2、PROPAGATE值为-3初始值为0。CANCELLED线程因为超时或中断而放弃了等待。这个节点的状态一旦进入CANCELLED就再也不会变。SIGNAL表示后继节点需要被唤醒。当一个节点释放锁时如果它的waitStatus是SIGNAL就会唤醒后继节点。这个状态是设置给前驱节点的意思是“我睡着了等你以后唤醒我”。CONDITION只用于条件队列表示节点处于condition await状态。PROPAGATE只用于共享锁模式表示锁的acquireShared可以无条件传播。典型的应用是Semaphore和ReadLock多个线程可能同时通过。这里有个容易踩坑的细节CANCELLED节点不会被马上移除而是在后续的acquireQueued或cancelAcquire流程中遍历清理。所以如果你用Jstack看了阻塞线程的堆栈发现很多线程停在LockSupport.park那只是正常的排队不是死锁。3.2 独占模式获取与释放的完整流程以ReentrantLock的lock()为例入口是AQS的acquire方法。它先调用子类的tryAcquire尝试获取state。如果成功直接返回线程持锁继续执行。如果失败就把当前线程封装成Node节点添加到同步队列末尾然后进入acquireQueued循环。acquireQueued的伪逻辑是取出当前节点的前驱节点。如果前驱是头节点再尝试一次tryAcquire成功就把自己设为头节点返回。如果前驱不是头节点或者获取失败检查是否需要挂起需要就调用LockSupport.park挂起线程。线程被唤醒后检查中断标志。如果发生中断从acquireQueued抛出InterruptedException或者单独记录中断状态取决于是否是interruptible版本。释放流程正好相反。unlock调用release先tryRelease把state减到0然后取出头节点如果头节点状态是SIGNAL就调用LockSupport.unpark唤醒后继线程。这里要注意tryRelease返回true代表锁完全释放。对于重入锁state每次重入加1释放一次减1只有减到0才表示真正无主了才需要唤醒后继节点。这也是ReentrantLock保证“只有持锁线程能释放锁”的方式tryRelease会检查当前线程是否持有锁。3.3 共享模式与独占模式的区别共享模式是另一个分支。acquireShared的返回值有三类负数表示获取失败0表示获取成功但后续共享获取不会再成功正数表示获取成功且后续还可能成功。比如Semaphore初始有5个许可一个线程acquire后state从5变4返回1表示还有可能成功于是AQS会继续唤醒后继节点。这就是“传播”效应。共享锁和独占锁的最大差异就在唤醒策略上。独占锁释放后只唤醒第一个后继节点共享锁释放后可能唤醒一串节点。ReadWriteLock的读锁就是典型共享模式多个读者可以同时进入写锁是独占模式写者进来后所有读者和写者都排队。3.4 中断与超时是怎么实现的AQS有两种获取方式忽略中断acquire如lock()和响应中断acquireInterruptibly如lockInterruptibly()。在acquireQueued中线程被park唤醒后会检查从park返回时是否是因为中断。如果响应中断直接抛出InterruptedException如果忽略中断只设置一个中断标志位等真正获得锁返回后由外部方法自行处理。ReentrantLock.lock()默认就是不屑一顾——哪怕线程被中断了它还是会继续排队等锁只是把线程的中断状态保留下来。这个细节很多人在面试时讲不清其实代码里就一行判断。超时控制则是通过deadline实现的。tryAcquireNanos会计算一个截止时间每次parkNanos指定的纳秒数。每次被唤醒后检查当前时间是否超过deadline超过则清理节点并返回false。这里有一个常见的性能误区把超时时间设得特别小比如1毫秒会让线程在队列中频繁被唤醒又睡过去导致大量无效的park/unparkCPU空转。我见过有同事把锁等待超时设为10微秒结果性能还不如不让它超时。4. 实操过程手写一个共享锁复现AQS的完整开发体验4.1 工具选型与场景定义这一节我们做一个真实的小项目实现一个最多允许三个线程同时访问的共享锁。虽然直接用Semaphore也能做但为了演示AQS的shared模式怎么玩我决定继承AQS手写。这个锁可以用来控制数据库连接池连接、限流本地接口并发数等场景。首先定义Sync内部类继承AQS。需要重写tryAcquireShared和tryReleaseShared。state初始值设为3。tryAcquireShared里用CAS尝试将state减1如果state大于等于0则获取成功返回剩余值负数则失败。tryReleaseShared则CAS加1返回成功。这里不能用简单赋值因为多个线程可能同时释放。4.2 核心代码与逐行解释下面是完整实现我精简了注释便于阅读。import java.util.concurrent.locks.AbstractQueuedSynchronizer; public class SharedLock { private final Sync sync new Sync(3); private static final class Sync extends AbstractQueuedSynchronizer { Sync(int permits) { setState(permits); } Override protected int tryAcquireShared(int arg) { for (;;) { int current getState(); int remaining current - arg; if (remaining 0 || compareAndSetState(current, remaining)) { return remaining; } } } Override protected boolean tryReleaseShared(int arg) { for (;;) { int current getState(); int next current arg; if (compareAndSetState(current, next)) { return true; } } } } public void lock() { sync.acquireShared(1); } public void unlock() { sync.releaseShared(1); } }tryAcquireShared里的死循环是因为CAS可能失败多个线程同时争用state时只有CAS成功才能返回。这里用arg参数而不是固定1是为了兼容一次申请多个许可的情况。releaseShared方法内部会先调用tryReleaseSharedCAS成功后返回true然后AQS的doReleaseShared唤醒排队线程。4.3 测试用例与运行观察我写了个简单测试起10个线程每个线程工作200毫秒用SharedLock保护。再加一个计数器观察最大同时在线数。public class SharedLockTest { public static void main(String[] args) throws InterruptedException { SharedLock lock new SharedLock(); AtomicInteger active new AtomicInteger(); AtomicInteger maxActive new AtomicInteger(); for (int i 0; i 10; i) { new Thread(() - { lock.lock(); try { int cur active.incrementAndGet(); maxActive.accumulateAndGet(cur, Math::max); System.out.println(Thread.currentThread().getName() 进入当前并发 cur); Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { active.decrementAndGet(); lock.unlock(); } }, T i).start(); } Thread.sleep(2000); System.out.println(最大并发: maxActive.get()); } }运行结果最大并发是3这符合预期。如果你把releaseShared的arg改成2意味着每次释放增加两个许可那state会慢慢变大最大并发会超过3。这种bug非常隐蔽所以在release时arg必须和acquire时对应。真实项目中Semaphore的release方法允许添加许可就是调用releaseShared(arg)。4.4 为什么这种实现是线程安全的再审视一遍state是volatile保证了可见性CAS操作保证了原子性。队列的并发入队在AQS底层用的是CAS尾插若CAS失败则重试。整个体系里没有一把额外的“锁保护锁”而是用自旋加CAS来确保入队安全。这种无锁化的设计在高并发下比synchronized等待队列少了很多上下文切换。这也是AQS性能优于老式内置锁的重要原因。5. 从AQS视角看并发工具族你会突然一通百通5.1 ReentrantLock独占模式公平策略加状态计数如果看ReentrantLock的源码内部有FairSync和NonfairSync两个子类。非公平锁的lock方法会先直接尝试CAS设置state如果成功就直接持锁哪怕队列里已经有人在排队。这就是“非公平”的真相新来的线程可以插队。公平锁则严格按照队列顺序如果队列有等待节点新线程一定会入队。非公平锁之所以性能高是因为它减少了线程挂起和唤醒的开销让短任务有机会快速完成但可能造成线线程饥饿。实际项目中我一般默认用非公平锁除非有严格的公平性需求比如任务按请求顺序处理。5.2 Semaphore共享模式可中断限流Semaphore的用法大家都会但要注意它默认也是非公平的。它内部有两个同步器NonfairSync和FairSync。acquire方法通过acquireSharedInterruptibly响应中断。用Semaphore限流时如果把permits设得过大比如几千性能下降很明显因为AQS的传播唤醒会遍历很长一串节点。实际测试里Semaphore的许可数保持在一个较小数值几十以内性能最好。5.3 CountDownLatch一次性计数器的设计CountDownLatch的countDown调用releaseShared(1)await调用acquireSharedInterruptibly(1)。state从N减到0后所有await线程被一起唤醒。这里有个数学细节countDown方法在state变为0后会继续调用doReleaseShared唤醒所有等待者。再多的countDown都无效因为state已经为0下一次CAS会失败。所以CountDownLatch是不可重置的如果你需要重置请用CyclicBarrier或者干脆new一个新的。5.4 ReadWriteLock与StampedLock的取舍读写锁中读锁是共享模式写锁是独占模式。它的内部使用了两个AQS同步器实际上ReentrantReadWriteLock的Sync继承AQS并用一个state变量同时保存读锁计数和写锁计数。它把state的高16位当作读锁数量低16位当作写锁重入次数。这种位拆分的技巧很巧妙避免了用两个变量带来的原子性难题。你在debug时看到state值莫名其妙的数字比如“66048”别慌十六进制换算一下就知道高16位是1即一个读锁。StampedLock没有继承AQS它用的是自旋和CLH队列的组合。它的读写锁都基于state的位运算而且引入了乐观读性能在某些场景下比ReadWriteLock高但在锁竞争激烈时容易出现自旋膨胀CPU。所以不是越新的锁越好要看竞争程度。6. 常见问题与排查技巧实录6.1 死锁还是活锁线程dump里怎么定位AQS等待线上遇到线程打满先jstack一下。看到“parking to wait for (a java.util.concurrent.locks.ReentrantLock$NonfairSync)”说明线程在锁等待。如果同时多个线程互相等待对方持有的锁就是经典死锁。比如线程A持有lock1等待lock2线程B持有lock2等待lock1。jstack会表现出两个线程各有一条“waiting for”记录。此时最核心的命令是jstack -l它会打印出锁的owner和blocked线程。我遇到过一次诡异的CPU飙高jstack里看到大量线程处于WAITING状态但锁的owner已经不存在。最后发现是某段代码tryLock超时失败后没有正确清理局部变量导致资源漏释放。所以排查AQS问题一定要结合锁owner和程序日志一起看。6.2 tryLock超时参数到底该怎么调tryLock被很多人当作“必杀技”不管多长的等待时间都敢传。我见过传1纳秒的结果就是锁竞争稍微激烈一点几乎永远抢不到锁业务上表现为大量失败重试。传Long.MAX_VALUE更噩梦等于无限等待和lock()没什么区别。经验值如果是缓存更新这类短任务建议50到200毫秒如果是要调用下游HTTP接口建议等下游超时时间的一半避免锁等待和接口等待叠加导致线程堆积。参数的本质是兜底不是给你随便拍脑袋的。6.3 条件队列Condition的理解误区很多人把Condition和Wait/Notify搞混。Condition是AQS内部类ConditionObject实现的它的每个await操作的线程会进入一个单向条件队列然后释放锁。signal操作是把条件队列的头节点转移到同步队列的尾节点。注意不是直接唤醒线程只是转移队列。被signal的线程要等重新获取到锁才能从await返回。这里有个经典坑在await之前一定要用while循环判断条件不能只用if因为线程被唤醒后条件可能已经被其他线程抢走。我在工作中就遇到过这种“虚假唤醒导致数据错乱”应用while条件判断后问题消失。另外Condition的await和Object.wait一样必须在已经持有锁的前提下调用否则抛IllegalMonitorStateException。同理signal也必须持有条件对应的锁。6.4 为什么不要自己直接继承AQSAQS不是普通的“工具类”它是为JDK内部和少数框架作者准备的底层框架。直接继承它意味着你要自己处理内部结构的兼容、序列化等一堆隐藏规则。如果你只是想实现一个业务锁更合理的做法是组合ReentrantLock或者用Semaphore、CountDownLatch这些现成工具。在团队代码里我看到过不少自己写的“优雅”锁最后都因为边界条件处理不全而返工。AQS学习价值极高但生产环境中滥用继承是不可取的。7. 写在最后AQS给我留下的几个后遗症我仔细读过AQS源码之后写并发代码的习惯变了很多。第一我很少再随意使用synchronized因为AQS体系提供的interrupt、timeout、tryLock这些能力更精细尤其在需要公平性和可中断等待的场景。第二我对LockSupport的park/unpark机制格外敏感凡是看到自定义的LockSupport代码都会认真核对线程是否会因为丢失unpark而永久沉睡。第三设计任何并发组件时我不再一股脑上重量级锁而是先思考这个状态变量能不能抽象成一个int通过CAS去守护这个思想就是AQS带给我的最大财富。最后再分享一个调优小技巧。如果你的系统大量使用AQS组件可以在JVM启动参数里增加-XX:-UseBiasedLocking虽然JDK 15以后偏向锁已经被默认废弃但对于老版本JDK关闭偏向锁能减少某些场景下的锁撤销开销特别是AQS队列竞争时。这个参数不是银弹但可以在压测时对比试试。遇到锁竞争相关的难题别急着加锁先问自己一句这段代码真的需要锁吗如果必须用那就用对AQS别让它成为性能黑洞。你在生产环境踩过的每一个并发坑最终都会成为你翻越这座珠穆朗玛峰的登山杖。