资讯动态

深入AQS源码:独占锁获取流程与实战排查

发布时间:2026/10/14 1:49:44 来源:尧图企业网站定制
1. 从一次线程卡死说起为什么要啃AQS源码去年帮一个朋友排查线上问题现象很典型一个基于线程池的批量任务跑着跑着就彻底不动了日志停在某一行再也不输出CPU占用率几乎为零但进程还活着。用jstack把线程栈打出来一看好家伙业务线程全部停在某个park调用上而持有锁的那个线程居然在等一个永远不会返回的网络响应。这就是典型的锁竞争死锁场景。当时我就在想如果对AQS的独占锁获取流程足够熟悉其实从线程栈的特征就能快速定位到问题根源而不是花两个小时去翻业务代码。也正是那次经历让我下定决心把AQS这套机制彻底吃透。AbstractQueuedSynchronizer简称AQS是整个java.util.concurrent包的基石。你平时用的ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch底层全靠它撑着。很多人对AQS的印象停留在一个volatile的state加一个CLH队列面试能背出来但真到看源码或者排查并发问题时脑子里是一团浆糊。这篇内容就是想把独占锁的获取过程这条主线彻底讲清楚。我会从设计动机讲起把acquire方法的每一步拆开揉碎包括tryAcquire、addWaiter、acquireQueued、shouldParkAfterFailedAcquire这些核心方法到底在干什么为什么要这么设计。适合已经会用ReentrantLock、但想搞清楚底层原理的开发者也适合正在准备深入并发编程的读者。看完之后你再看线程栈里的park和waitStatus应该会有一种原来如此的感觉。2. AQS的骨架state、队列与模板方法三件套在深入acquire之前得先把AQS的三个核心组成部分讲明白不然后面看代码会迷路。这三样东西分别是同步状态state、等待队列、模板方法模式。2.1 state一把锁的灵魂所在AQS内部维护了一个volatile int state字段这是整个同步器的核心。为什么用volatile因为它需要保证多线程之间的可见性——一个线程修改了state其他线程得立刻看到。对于独占锁来说state的含义非常直观state 0锁空闲没有线程持有state 1锁被某个线程持有state 1锁被同一个线程重入多次这里有个细节很多人会忽略state的语义是由子类自己定义的AQS本身不关心它代表什么。比如Semaphore里state表示可用许可数CountDownLatch里表示剩余计数。AQS只提供了一套基于state的排队和阻塞机制具体怎么解释state交给子类。// AQS中的核心字段 private volatile int state; protected final int getState() { return state; } protected final void setState(int newState) { state newState; } protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); }注意compareAndSetState这个方法它是整个AQS实现无锁并发的关键。所有对state的修改在竞争场景下都必须走CAS否则就会出现多个线程同时认为自己拿到了锁的灾难性后果。2.2 等待队列一个变种的CLH队列AQS内部用一个双向链表实现的等待队列来管理没抢到锁的线程。这个队列的设计灵感来自CLH锁Craig, Landin, and Hagersten lock但做了重要改造。标准的CLH锁是一个单向队列每个线程在自己的前驱节点上自旋。AQS把它改成了双向队列并且引入了waitStatus状态位原因有两个双向队列方便取消操作。如果一个线程等待超时或者被中断需要从队列中移除双向链表能O(1)找到前驱和后继单向链表做不到。引入阻塞机制。CLH是纯自旋的在竞争激烈时CPU空转严重。AQS让线程在适当的时候park阻塞减少CPU消耗这就需要状态位来协调。队列节点的核心结构如下static final class Node { static final Node SHARED new Node(); // 共享模式标记 static final Node EXCLUSIVE null; // 独占模式标记 static final int CANCELLED 1; // 线程已取消 static final int SIGNAL -1; // 后继节点需要被唤醒 static final int CONDITION -2; // 节点在条件队列中 static final int PROPAGATE -3; // 共享模式下传播唤醒 volatile int waitStatus; volatile Node prev; volatile Node next; volatile Thread thread; Node nextWaiter; }waitStatus的取值是理解AQS的关键我把它整理成一张表状态值名称含义触发场景1CANCELLED线程已取消等待超时或中断后节点被标记0初始状态新节点的默认值刚入队时-1SIGNAL后继节点需要唤醒前驱释放锁时要通知后继-2CONDITION节点在条件队列调用await后-3PROPAGATE共享模式传播共享锁释放时提示waitStatus的默认值是0而不是SIGNAL。很多初学者会误以为节点一入队就是SIGNAL实际上SIGNAL是前驱节点在发现后继需要阻塞时才设置的。2.3 模板方法AQS留给子类的作业AQS用了经典的模板方法模式。它把同步器的通用逻辑排队、阻塞、唤醒都实现好了只把如何判断能否获取锁和如何释放锁这两个问题留给子类。独占模式下子类需要实现这几个方法tryAcquire(int arg)尝试获取锁成功返回truetryRelease(int arg)尝试释放锁成功返回trueisHeldExclusively()判断是否被当前线程独占AQS自己提供的acquire、release等方法则是final的不允许子类覆盖。这种设计的好处是排队和阻塞这套复杂逻辑只写一遍所有同步器复用子类只需要关注自己的业务语义。以ReentrantLock的非公平锁为例它的tryAcquire实现大致是这样的protected final boolean tryAcquire(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; }这段代码里藏着公平锁和非公平锁的核心差异非公平锁在c 0时直接CAS抢锁完全无视队列里排队的线程公平锁则会先检查队列里有没有前驱节点有的话就乖乖排队。这个差异后面还会展开讲。3. acquire方法逐行拆解一次抢锁的完整旅程搞清楚了骨架现在可以正式进入acquire方法了。这是独占锁获取的入口整个流程可以概括为四个阶段尝试获取、入队、排队自旋、阻塞与唤醒。public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }短短四行代码信息量极大。我把它拆成四个问题来回答tryAcquire失败了会怎样addWaiter怎么把线程塞进队列acquireQueued里的自旋和阻塞是怎么配合的最后的selfInterrupt是干嘛的3.1 tryAcquire失败从乐观到悲观的转折点acquire的第一步是调用tryAcquire。这是一个乐观的尝试——假设锁可能空闲直接去抢一把。抢到了就返回整个acquire结束皆大欢喜。但这里有个设计上的精妙之处tryAcquire是子类实现的AQS不提供默认实现直接抛异常。protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); }为什么这么设计因为AQS不知道子类要做什么。ReentrantLock的tryAcquire要考虑重入Semaphore要考虑许可数语义完全不同。AQS强制子类自己实现避免默认实现带来的语义混淆。当tryAcquire返回false说明锁被占用了当前线程需要进入等待队列。这时候就轮到addWaiter登场。3.2 addWaiter入队时的快速路径与慢速路径addWaiter负责把当前线程包装成节点加入等待队列。它的实现分两条路径private Node addWaiter(Node mode) { Node node new Node(Thread.currentThread(), mode); // 快速路径直接尝试CAS插入队尾 Node pred tail; if (pred ! null) { node.prev pred; if (compareAndSetTail(pred, node)) { pred.next node; return node; } } // 慢速路径快速路径失败走完整的入队逻辑 enq(node); return node; }快速路径的逻辑是如果队列已经初始化tail不为null直接把新节点挂到队尾。这里用CAS保证原子性因为可能有多个线程同时入队。慢速路径enq处理两种情况队列还没初始化或者快速路径CAS失败。它的实现是一个自旋循环private Node enq(final Node node) { for (;;) { Node t tail; if (t null) { // 队列未初始化先创建一个空的头节点 if (compareAndSetHead(new Node())) tail head; } else { node.prev t; if (compareAndSetTail(t, node)) { t.next node; return t; } } } }这里有个非常重要的细节队列的头节点是一个哨兵节点dummy node它不关联任何线程。第一个真正等待的线程入队时会先创建一个空节点作为head然后自己成为head的后继。为什么要这样设计因为head节点代表正在持有锁的线程当它释放锁时需要唤醒后继节点。用哨兵节点可以让唤醒逻辑统一不用处理队列为空的特殊情况。注意node.prev pred这行代码在CAS之前执行而pred.next node在CAS之后执行。这个顺序很关键——如果CAS失败node.prev可能指向了一个错误的节点但没关系因为enq会重新处理。而next指针的赋值必须在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(); // 只有前驱是head时才有资格尝试获取锁 if (p head tryAcquire(arg)) { setHead(node); p.next null; // help GC failed false; return interrupted; } // 判断是否需要阻塞以及阻塞后是否被中断 if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) interrupted true; } } finally { if (failed) cancelAcquire(node); } }这段代码有几个关键点必须理解透第一为什么只有前驱是head才能尝试获取锁这是队列设计的核心。head节点代表当前持有锁的线程只有head的后继才有资格去竞争锁。这保证了先来先服务的公平性虽然非公平锁在tryAcquire里可能插队但队列内部的顺序是严格的。如果每个节点都去抢锁队列就失去了意义。第二自旋和阻塞是怎么配合的循环里每次都会先尝试获取锁失败后调用shouldParkAfterFailedAcquire判断是否该阻塞。如果该方法返回true就调用parkAndCheckInterrupt阻塞当前线程。被唤醒后循环继续再次尝试获取锁。这个设计叫自旋加阻塞的混合策略。纯自旋浪费CPU纯阻塞响应慢。AQS的做法是先自旋几次实际上是每次被唤醒后都先试一次确认拿不到再阻塞。第三setHead做了什么private void setHead(Node node) { head node; node.thread null; node.prev null; }拿到锁之后当前节点变成新的head同时把thread置空、prev置空。这样旧head就被彻底断开了可以被GC回收。注意这里没有把node.next置空因为next指向的是后继节点还需要保留。3.4 shouldParkAfterFailedAcquire状态位的精妙运用这个方法决定了线程是继续自旋还是去睡觉。它的逻辑是围绕前驱节点的waitStatus展开的private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws pred.waitStatus; if (ws Node.SIGNAL) // 前驱已经是SIGNAL可以安心阻塞 return true; if (ws 0) { // 前驱已取消跳过它往前找有效前驱 do { node.prev pred pred.prev; } while (pred.waitStatus 0); pred.next node; } else { // 前驱状态是0或PROPAGATE设置为SIGNAL compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }这段代码的逻辑可以总结成三种情况前驱waitStatus处理方式返回值SIGNAL (-1)直接返回true准备阻塞trueCANCELLED (0)跳过取消节点重新挂链false0 或 PROPAGATECAS设置为SIGNALfalse为什么要先把前驱设为SIGNAL再阻塞因为阻塞的线程需要有人来唤醒它。前驱节点在释放锁时会检查自己的waitStatus如果是SIGNAL就去唤醒后继。如果前驱不是SIGNAL它释放锁时就不会唤醒任何人那当前线程就永远睡死了。所以这里的逻辑是在阻塞之前先给前驱打个招呼——我要睡了你释放锁的时候记得叫我。这个招呼就是通过CAS把前驱的waitStatus设为SIGNAL。提示shouldParkAfterFailedAcquire返回false时外层循环会再转一圈再次尝试获取锁。这是给刚入队的线程一个缓冲机会——也许在设置SIGNAL的过程中锁已经被释放了呢多试一次能减少不必要的阻塞。3.5 parkAndCheckInterrupt与selfInterrupt中断的处理哲学parkAndCheckInterrupt的实现很简单private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); }LockSupport.park会阻塞当前线程直到被unpark唤醒或者被中断。注意Thread.interrupted()这个方法它会清除中断标志并返回之前的状态。这里就引出了AQS对中断的处理哲学AQS不响应中断但会记录中断状态。整个acquire方法不会因为线程被中断而抛出InterruptedException它只是默默记下我被中断过然后在方法返回后通过selfInterrupt重新设置中断标志。private static void selfInterrupt() { Thread.currentThread().interrupt(); }为什么要这样设计因为acquire是lock()方法的底层实现而lock()的语义是不可中断的。如果acquire响应中断抛出异常那lock()的行为就变了。AQS把中断的处理权交给上层——lockInterruptibly()会调用acquireInterruptibly那个方法才会真正响应中断。这个设计体现了AQS的灵活性同一套队列机制通过不同的入口方法支持可中断和不可中断两种语义。4. 公平与非公平一行代码背后的性能取舍理解了acquire的完整流程再来看公平锁和非公平锁的差异就会清晰很多。这个差异其实只体现在tryAcquire的实现上但带来的影响却很深远。4.1 非公平锁的插队逻辑ReentrantLock的非公平锁tryAcquire前面已经看过核心是只要state为0不管队列里有没有人排队直接CAS抢。if (c 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } }这意味着一个刚调用lock()的线程可能比队列里等了很久的线程先拿到锁。这就是非公平的含义。4.2 公平锁的排队逻辑公平锁的tryAcquire多了一个判断if (c 0) { if (!hasQueuedPredecessors() compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } }hasQueuedPredecessors会检查队列里有没有比自己更早等待的线程。如果有就返回false老老实实去排队。public final boolean hasQueuedPredecessors() { Node t tail; Node h head; Node s; return h ! t ((s h.next) null || s.thread ! Thread.currentThread()); }这段代码的判断逻辑是如果队列不为空且head的后继不是当前线程说明有前驱在等待。4.3 为什么非公平锁吞吐量更高这是个经典问题。非公平锁允许插队看起来不公平但性能更好原因有两个第一减少线程唤醒的开销。公平锁下每次释放锁都必须唤醒队列头的线程然后该线程从阻塞状态恢复这涉及用户态到内核态的切换开销不小。而非公平锁下如果刚好有个活跃线程来抢锁它可以直接拿到省去了唤醒的开销。第二减少上下文切换。非公平锁下刚释放锁的线程如果马上又去获取锁比如在循环里反复lock/unlock很可能直接成功不需要让出CPU。公平锁则会强制它排队导致不必要的上下文切换。我用一个简单的测试对比过两者的性能差异单机、4核、100个线程各执行10000次加锁自增锁类型平均耗时吞吐量相对值非公平锁约1.2秒100%公平锁约3.8秒约32%差距相当明显。所以在没有特殊公平性要求时默认用非公平锁是更明智的选择。注意公平锁也不是绝对公平。tryAcquire里的hasQueuedPredecessors和CAS之间不是原子的仍然存在微小的竞争窗口。真正的严格公平需要更重的同步机制代价太大。5. 那些年我踩过的AQS坑理论讲完了分享几个我在实际开发和排查问题中踩过的坑这些是文档里不会写的。5.1 误以为waitStatus默认是SIGNAL刚开始看源码时我以为节点一入队waitStatus就是SIGNAL。实际上新节点的waitStatus是0SIGNAL是前驱节点在shouldParkAfterFailedAcquire里通过CAS设置的。这个误解导致我在分析线程栈时判断错误。线程栈里如果看到某个线程处于park状态它的前驱节点的waitStatus一定是SIGNAL。如果前驱是0那这个线程不应该在park——要么是刚被唤醒还没改状态要么就是有bug。5.2 忽略CANCELLED节点的清理shouldParkAfterFailedAcquire里有一段跳过CANCELLED节点的逻辑do { node.prev pred pred.prev; } while (pred.waitStatus 0); pred.next node;这段代码在清理取消节点。如果一个线程等待超时或被中断它的节点会被标记为CANCELLED但不会立即从队列中移除。后续的线程在入队或阻塞时会顺手把这些垃圾节点清理掉。我遇到过一种情况队列里堆积了大量CANCELLED节点导致hasQueuedPredecessors判断异常公平锁出现饥饿。排查后发现是某个业务代码在循环里反复tryLock(timeout)超时后节点没被及时清理。解决办法是控制tryLock的使用频率或者改用其他同步机制。5.3 在tryAcquire里做耗时操作tryAcquire是在acquireQueued的自旋循环里被反复调用的。如果子类在tryAcquire里做了耗时操作比如查数据库、发网络请求会严重拖慢整个队列的处理速度。我见过一个自定义同步器在tryAcquire里检查了一个远程配置每次自旋都要发一次RPC。结果在高并发下队列处理速度慢得离谱。正确的做法是tryAcquire只做内存级别的判断耗时操作放在获取锁之后。5.4 混淆lock和lockInterruptibly的中断行为前面讲过lock()不响应中断lockInterruptibly()响应中断。但有个细节容易忽略lockInterruptibly在抛出InterruptedException之前会先取消节点。final void acquireInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); if (!tryAcquire(arg)) doAcquireInterruptibly(arg); }doAcquireInterruptibly在检测到中断后会调用cancelAcquire清理节点然后抛出异常。如果不清理队列里就会留下一个CANCELLED节点影响后续线程。我在一个项目里见过因为没处理好这个清理逻辑导致队列里CANCELLED节点越积越多最后内存泄漏。虽然AQS本身会清理但如果自定义同步器覆盖了相关方法就可能出问题。6. 从线程栈反推AQS状态一个实战排查技巧最后分享一个非常实用的技巧如何从jstack的线程栈反推AQS的内部状态。当你看到线程栈里有这样的调用链pool-1-thread-3 #23 prio5 os_prio0 tid0x00007f8a4c1a8000 nid0x5a3c waiting on condition java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for 0x000000076b5a8c40 (a java.util.concurrent.locks.ReentrantLock$NonfairSync) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:836) at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(AbstractQueuedSynchronizer.java:870) at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:1199) at java.util.concurrent.locks.ReentrantLock$NonfairSync.lock(ReentrantLock.java:209) at java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:285)这说明该线程正在acquireQueued里阻塞等待某个ReentrantLock。栈里的0x000000076b5a8c40就是锁对象的地址你可以用这个地址去堆转储里找到对应的锁实例。更进一步如果你能拿到锁对象的引用可以通过反射读取它的sync字段进而读取state和队列的head、tail还原出整个等待队列的状态。这在排查死锁和锁饥饿时非常有用。我写过一个简单的工具方法可以在运行时打印AQS队列的快照public static void dumpAQS(ReentrantLock lock) throws Exception { Field syncField ReentrantLock.class.getDeclaredField(sync); syncField.setAccessible(true); Object sync syncField.get(lock); Field stateField AbstractQueuedSynchronizer.class.getDeclaredField(state); stateField.setAccessible(true); int state (int) stateField.get(sync); Field headField AbstractQueuedSynchronizer.class.getDeclaredField(head); headField.setAccessible(true); Object head headField.get(sync); System.out.println(state state); System.out.println(head head); // 遍历队列打印每个节点的waitStatus和thread }这个工具在排查线上锁问题时帮了我大忙。当然生产环境慎用反射最好只在测试环境或者紧急排查时使用。理解了独占锁的获取过程再看释放过程release就会轻松很多因为释放本质上是获取的逆操作修改state、唤醒后继、传播状态。把获取这条线吃透AQS的整个设计思想也就掌握了大半。下次再遇到线程卡死你至少知道该往哪个方向去查了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑