ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入AQS:从设计原理到手写同步器与线上问题排查

深入AQS:从设计原理到手写同步器与线上问题排查

1. 为什么AQS是Java并发里绕不开的那道坎

刚入行那会儿,我对ReentrantLock、CountDownLatch、Semaphore这些工具类的态度就是“会用就行”,直到有一次线上一个定时任务莫名其妙卡死,线程堆栈打出来一看,一堆线程停在LockSupport.park上,我才意识到——不把AQS(AbstractQueuedSynchronizer,队列同步器)搞明白,Java并发这块永远是黑盒。AQS是整个java.util.concurrent包的基石,ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch、CyclicBarrier(底层用ReentrantLock)、ThreadPoolExecutor里的Worker加锁,全都建立在它之上。你不需要背下它每一行源码,但你必须理解它解决问题的思路:用一个volatile int state表示同步状态,用一个CLH变体的双向队列管理等待线程,用CAS保证状态变更的原子性,用LockSupport.park/unpark做线程阻塞与唤醒。这套设计把“怎么抢资源”和“抢不到怎么办”这两件事彻底解耦,子类只需要关心state的语义,排队、阻塞、唤醒这些脏活累活AQS全包了。这篇文章我会从设计动机讲到源码细节,再讲到实际排查问题的经验,适合已经会写并发代码但想搞清楚底层原理的开发者,也适合准备面试想把这部分讲透的同学。看完你至少能做到:自己写一个基于AQS的同步器,看懂ReentrantLock的公平与非公平差异,遇到线程卡死能快速定位是不是AQS队列出了问题。

2. AQS的整体设计思路拆解

2.1 一个state加一条队列,解决所有同步问题

AQS的核心抽象非常简洁,就两个东西:一个是volatile int state,一个是内部类Node构成的双向链表队列。state的含义完全由子类定义——ReentrantLock里它表示重入次数,Semaphore里它表示可用许可数,CountDownLatch里它表示还没完成的计数。这种设计叫“模板方法模式”,AQS把同步器的骨架搭好,把state的获取和释放逻辑留给子类通过tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared、isHeldExclusively这几个方法去实现。

为什么用int而不是boolean或者long?因为int足够表达大多数场景的语义,重入次数、许可数量、计数都能覆盖,而且int的CAS操作在各平台都有良好支持。为什么用volatile?保证state的可见性,一个线程修改了state,其他线程能立刻看到。但volatile本身不保证原子性,所以修改state必须配合CAS,这就是AQS里大量出现compareAndSetState的原因。

队列的设计更值得说道。AQS内部维护的是一个CLH(Craig, Landin, and Hagersten)锁队列的变体。原版CLH队列是自旋锁队列,每个线程在自己的前驱节点上自旋等待。AQS把它改造成了阻塞队列——每个节点代表一个等待线程,节点里有waitStatus表示线程状态(CANCELLED、SIGNAL、CONDITION、PROPAGATE),有prev和next指针构成双向链表。为什么改成双向?因为取消节点时需要从队列中摘除,单向链表做不到。为什么用阻塞而不是自旋?自旋在竞争激烈时白白消耗CPU,阻塞让出CPU给其他线程更划算。

注意:AQS的队列是一个“虚拟”队列,head节点本身不存储等待线程(或者说head代表当前持有锁的线程),真正等待的线程从head.next开始。理解这一点对看懂acquire和release的源码至关重要。

2.2 独占模式与共享模式的分野

AQS支持两种同步模式:独占模式(Exclusive)和共享模式(Shared)。独占模式一次只允许一个线程持有同步状态,比如ReentrantLock;共享模式允许多个线程同时持有,比如Semaphore和CountDownLatch。

这两种模式在AQS里对应两套方法:独占模式用acquire/release,共享模式用acquireShared/releaseShared。它们的核心区别在于:独占模式下,一个线程拿到state后其他线程必须等待;共享模式下,只要state还有剩余,后续线程可以继续获取。体现在队列上,独占模式释放时会唤醒后继节点,共享模式释放时会传播唤醒(PROPAGATE状态就是为此而生)。

为什么要分两种模式而不是统一用一种?因为语义完全不同。独占锁的释放只需要唤醒一个后继,共享锁的释放可能需要唤醒多个。如果强行统一,要么共享场景下唤醒不足导致线程饥饿,要么独占场景下过度唤醒造成不必要的竞争。分开设计让每种模式的逻辑都足够清晰。

2.3 公平与非公平:一场关于吞吐量和延迟的权衡

ReentrantLock可以构造为公平锁或非公平锁,这个选择直接体现在AQS的tryAcquire实现上。公平锁的tryAcquire会先检查队列里有没有前驱节点,如果有就老老实实排队;非公平锁则直接尝试CAS抢state,抢到了就插队执行。

非公平锁的吞吐量通常更高,因为减少了线程上下文切换——刚释放锁的线程很可能立刻再次获取锁,如果让它插队,就省去了一次唤醒等待线程的开销。但非公平锁可能导致队列中的线程长时间得不到执行,也就是“饥饿”。公平锁保证了FIFO顺序,延迟更可预测,但吞吐量会下降。

实际选型时,绝大多数场景用非公平锁就够了,ReentrantLock默认也是非公平的。只有在对公平性有严格要求、且竞争不激烈的场景下才考虑公平锁。我个人的经验是:如果你不确定该用哪个,用非公平的,性能更好,而且大多数业务场景对那点延迟差异并不敏感。

3. AQS核心源码细节与实操要点

3.1 Node节点的waitStatus到底怎么理解

Node里的waitStatus是AQS里最容易让人迷糊的地方,它有五个取值,每个都有特定含义:

状态值名称含义
1CANCELLED节点已取消,线程因超时或中断放弃等待
-1SIGNAL后继节点需要被唤醒,当前节点释放锁时要unpark后继
-2CONDITION节点在Condition队列中等待
-3PROPAGATE共享模式下释放需要传播唤醒
0初始状态新节点入队时的默认值

关键要理解SIGNAL:当一个节点把前驱的waitStatus设为SIGNAL时,意思是“前驱你释放锁的时候记得叫醒我”。所以shouldParkAfterFailedAcquire方法里,如果前驱状态是SIGNAL就返回true,当前线程可以安心park;如果前驱状态是CANCELLED就往前找有效前驱;否则用CAS把前驱状态设为SIGNAL再重试。

实操心得:调试AQS相关问题时,打印队列里每个节点的waitStatus能快速判断队列是否健康。如果看到大量CANCELLED节点堆积,说明有线程频繁超时或中断,可能需要检查业务逻辑里的超时设置是否合理。

3.2 acquire方法的完整执行链路

acquire(int arg)是独占模式获取锁的入口,它的逻辑可以拆成三步:

public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }

第一步,调用子类实现的tryAcquire尝试获取。成功就直接返回,整个方法结束,连队列都不用碰。这是AQS的快路径设计——无竞争时性能极高。

第二步,tryAcquire失败,调用addWaiter把当前线程包装成Node加入队列尾部。入队用CAS操作tail指针,保证并发安全。如果CAS失败(说明有其他线程也在入队),就进入enq方法用自旋+CAS的方式完成入队。

第三步,acquireQueued让节点在队列中等待。这个方法是个循环,每次循环先检查前驱是不是head,如果是就再试一次tryAcquire(因为head释放锁了,有机会了)。如果获取成功,把当前节点设为head并返回。如果失败,判断是否需要park,需要就调用LockSupport.park阻塞,醒来后继续循环。

整个链路的设计哲学是:能抢就抢,抢不到就排队,排队时该阻塞就阻塞,被唤醒后继续抢。每一步都有明确的退出条件,不会无限自旋。

3.3 release方法如何唤醒后继节点

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,只有完全释放(比如重入次数减到0)才返回true。然后检查head节点,如果head不为空且waitStatus不为0,就调用unparkSuccessor唤醒后继。

unparkSuccessor里有个细节:如果head.next是null或者状态是CANCELLED,就从tail往前遍历找到最靠近head的有效节点。为什么从后往前找?因为入队时是先设置node.prev再CAS设置tail,所以从tail往前遍历一定能遍历到所有已入队的节点,而从head往后可能因为next指针还没设置而漏掉节点。

注意:unparkSuccessor唤醒的是队列里第一个有效节点,但被唤醒的线程不一定能立刻拿到锁,它还要和其他新来的线程竞争。这就是非公平锁的体现。

3.4 共享模式的传播机制

共享模式的releaseShared和acquireShared比独占模式多了一个传播逻辑。当tryReleaseShared返回true时,会调用doReleaseShared,这个方法里有个循环,不断检查head的waitStatus,如果是SIGNAL就CAS设为0并唤醒后继,如果是0就CAS设为PROPAGATE。

PROPAGATE状态的作用是确保共享模式下唤醒能正确传播。考虑一个场景:多个线程同时释放共享锁,如果不加PROPAGATE,可能出现唤醒丢失——一个线程释放后唤醒了后继,但后继还没来得及执行,另一个线程又释放了,此时head状态已经变了,可能导致后续等待线程无法被唤醒。PROPAGATE就是用来标记“还有唤醒需要传播”。

这个机制比较绕,实际开发中很少需要直接操作,但理解它有助于排查共享锁相关的诡异问题。

4. 手写一个基于AQS的同步器

4.1 需求定义:实现一个不可重入的互斥锁

光看源码不够,自己写一个才能真懂。我们来实现一个最简单的不可重入互斥锁,state为0表示未锁定,为1表示已锁定。

public class SimpleMutex implements Lock { private static class Sync extends AbstractQueuedSynchronizer { @Override protected boolean tryAcquire(int arg) { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } @Override protected boolean tryRelease(int arg) { if (getState() == 0) { throw new IllegalMonitorStateException(); } setExclusiveOwnerThread(null); setState(0); return true; } @Override protected boolean isHeldExclusively() { return getState() == 1; } Condition newCondition() { return new ConditionObject(); } } private final Sync sync = new Sync(); @Override public void lock() { sync.acquire(1); } @Override public void unlock() { sync.release(1); } @Override public boolean tryLock() { return sync.tryAcquire(1); } @Override public Condition newCondition() { return sync.newCondition(); } // 其他Lock接口方法省略 }

这个实现里,tryAcquire用CAS把state从0改成1,成功就记录当前线程为持有者。tryRelease把state设回0并清除持有者。注意tryRelease里没有用CAS,因为释放锁的线程一定是持有锁的线程,不存在竞争。

4.2 关键点:为什么释放时不需要CAS

很多初学者会问:为什么tryAcquire用CAS而tryRelease直接set?因为获取锁时可能有多个线程同时竞争,必须用CAS保证只有一个成功;而释放锁时,只有持有锁的线程才会调用release,其他线程根本没资格释放,所以不存在并发问题。这是AQS设计里“获取要防并发,释放不用防”的典型体现。

但有个例外:共享模式下释放也可能有并发,因为多个线程可能同时持有共享状态,所以tryReleaseShared通常需要用CAS循环。比如Semaphore的释放就是CAS增加state。

4.3 测试验证:多线程竞争下的正确性

写个测试验证一下:

public class MutexTest { private static int counter = 0; private static final SimpleMutex mutex = new SimpleMutex(); public static void main(String[] args) throws InterruptedException { Thread[] threads = new Thread[10]; for (int i = 0; i < 10; i++) { threads[i] = new Thread(() -> { for (int j = 0; j < 10000; j++) { mutex.lock(); try { counter++; } finally { mutex.unlock(); } } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println("counter = " + counter); } }

如果输出100000,说明互斥锁工作正常。如果小于100000,说明有并发问题。这个测试虽然简单,但能验证AQS的基本功能。

实操心得:自己写同步器时,最容易犯的错误是忘记在tryAcquire里调用setExclusiveOwnerThread,导致isHeldExclusively判断错误。另外,tryRelease里一定要检查当前线程是否是持有者,否则可能被其他线程误释放。

5. 常见问题与排查技巧实录

5.1 线程卡在park上不动了怎么办

这是AQS相关最常见的线上问题。现象是线程堆栈显示大量线程停在LockSupport.park,业务逻辑不推进。排查思路分三步:

第一步,确认是哪个同步器导致的。看堆栈里park的上层调用,是ReentrantLock.lock还是Semaphore.acquire还是别的。不同同步器的语义不同,排查方向也不同。

第二步,检查是否有线程持有锁但一直不释放。用jstack找到持有锁的线程,看它在干什么。常见原因是持有锁的线程在执行耗时操作(比如IO、远程调用)时没有设置超时,或者死锁了。

第三步,检查AQS队列状态。如果能看到队列里有大量CANCELLED节点,说明很多线程等待超时或被中断了,可能是锁的持有时间过长。如果队列正常但head一直不释放,那就是持有锁的线程出了问题。

现象可能原因排查方法
大量线程park锁持有时间过长检查持有锁线程的堆栈
队列大量CANCELLED超时设置过短调整tryLock超时时间
线程永远不醒唤醒丢失检查release逻辑是否正确
公平锁下吞吐骤降频繁上下文切换考虑改用非公平锁

5.2 为什么我的Condition.await不生效

Condition是AQS提供的等待/通知机制,用的时候有几个坑:

第一个坑,await必须在持有锁的情况下调用,否则抛IllegalMonitorStateException。因为Condition内部会检查当前线程是否持有锁。

第二个坑,await会释放锁并进入Condition队列,被signal唤醒后会从Condition队列转移到AQS同步队列,重新竞争锁。这个过程涉及两次队列操作,如果理解不清容易误判。

第三个坑,signal只是把节点从Condition队列转移到同步队列,并不会立刻唤醒线程。真正唤醒发生在同步队列里前驱释放锁的时候。所以signal之后调用unlock是必须的,否则被唤醒的线程拿不到锁。

注意:Condition的await和Object.wait语义类似但有本质区别——await可以有多个等待队列(每个Condition一个),而wait只有一个。这让Condition能实现更精细的线程协作。

5.3 重入锁的重入次数溢出问题

ReentrantLock的state是int类型,重入次数理论上限是Integer.MAX_VALUE。正常业务不可能重入这么多次,但如果代码里有递归加锁且递归深度失控,可能触发溢出。溢出后state变成负数,tryAcquire的判断逻辑会出错。

排查这种问题,看堆栈里是否有异常深的递归调用,或者用反射读取state值看是否异常。预防措施是避免在递归里加锁,或者用tryLock设置超时。

5.4 共享模式下唤醒丢失的诡异现象

共享模式比独占模式更容易出问题,因为唤醒需要传播。典型现象是:明明有线程释放了共享锁,但等待的线程就是不醒。这通常是因为doReleaseShared里的CAS操作失败后没有重试,或者PROPAGATE状态处理不当。

排查时重点看doReleaseShared的执行路径,确认每次释放都正确设置了head的waitStatus。如果用的是自己实现的共享同步器,检查tryReleaseShared是否返回了正确的布尔值——返回true才会触发唤醒传播。

6. 从AQS延伸出去的设计启示

6.1 模板方法模式在框架设计中的威力

AQS是模板方法模式的教科书级应用。它把不变的部分(队列管理、阻塞唤醒、状态CAS)放在基类,把变化的部分(state语义)留给子类。这种设计让新增一个同步器变得极其简单——你只需要实现几个try方法,剩下的AQS全帮你搞定。

这种思路在实际开发中很值得借鉴。比如你做的是一个任务调度框架,可以把任务排队、线程管理、超时控制这些通用逻辑放在基类,把任务的具体执行逻辑留给子类。这样新增任务类型时不用重复造轮子。

6.2 无锁编程的边界在哪里

AQS大量使用CAS,但并不是所有地方都用无锁。入队操作在CAS失败时会退化成自旋,park/unpark本身是重量级操作。这说明无锁不是银弹,AQS的设计者很清楚这一点——快路径用CAS,慢路径用锁和阻塞,两者结合才能兼顾性能和正确性。

实际开发中,不要盲目追求无锁。CAS适合竞争不激烈的场景,竞争激烈时自旋开销可能比锁还大。判断标准很简单:如果CAS失败率超过一定阈值(比如30%),就该考虑用锁了。

6.3 读懂AQS对日常开发的直接价值

很多人觉得AQS离业务开发很远,其实不然。你用的线程池、你写的并发工具、你排查的线上问题,背后都可能是AQS。理解AQS之后,你看ReentrantLock的文档能看懂公平性参数的含义,看Semaphore能理解许可的获取释放逻辑,看线程池能明白Worker为什么要用AQS而不是synchronized。

更重要的是,AQS教会你一种思维方式:把复杂问题拆解成状态管理和排队等待两个独立问题,分别用最合适的机制解决。这种思维方式比具体的API用法有价值得多。

我在实际项目里踩过最深的坑,是一个基于ReentrantLock的定时任务因为持有锁期间调用了外部服务,外部服务超时导致锁一直不释放,最终整个线程池被拖垮。后来改成tryLock加超时,问题才解决。这件事让我明白,AQS给了你强大的工具,但怎么用、用多久、什么时候放弃,还是得靠开发者自己判断。工具再好,也替代不了对业务场景的思考。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进