1. 为什么说AQS是并发包的“珠穆朗玛峰”
搞Java并发编程的人,迟早会撞上AQS这个名字。它全称是AbstractQueuedSynchronizer,中文叫抽象队列同步器,在java.util.concurrent.locks包下面。我见过不少工作三五年的开发,能熟练使用ReentrantLock、CountDownLatch、Semaphore,但一问到AQS就含糊其辞,说“底层太复杂,没细看”。实际上,整个并发包最核心的骨架就是AQS,ReentrantLock、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/unpark,CPU空转。我见过有同事把锁等待超时设为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方法内部会先调用tryReleaseShared,CAS成功后返回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 为什么不要自己直接继承AQS
AQS不是普通的“工具类”,它是为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,别让它成为性能黑洞。你在生产环境踩过的每一个并发坑,最终都会成为你翻越这座珠穆朗玛峰的登山杖。