☰
深入AQS源码:独占锁获取流程与实战排查
2026/10/14 1:49:30 网站建设 项目流程

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状态位,原因有两个:

  1. 双向队列方便取消操作。如果一个线程等待超时或者被中断,需要从队列中移除,双向链表能O(1)找到前驱和后继,单向链表做不到。
  2. 引入阻塞机制。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):尝试获取锁,成功返回true
  • tryRelease(int arg):尝试释放锁,成功返回true
  • isHeldExclusively():判断是否被当前线程独占

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(); }

短短四行代码,信息量极大。我把它拆成四个问题来回答:

  1. tryAcquire失败了会怎样?
  2. addWaiter怎么把线程塞进队列?
  3. acquireQueued里的自旋和阻塞是怎么配合的?
  4. 最后的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,准备阻塞true
CANCELLED (>0)跳过取消节点,重新挂链false
0 或 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是0,SIGNAL是前驱节点在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 prio=5 os_prio=0 tid=0x00007f8a4c1a8000 nid=0x5a3c 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的整个设计思想也就掌握了大半。下次再遇到线程卡死,你至少知道该往哪个方向去查了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询