1. 从上一章的尾巴说起:state与队列的关系
上一章我们把AQS的顶层设计捋了一遍,知道了它内部维护了一个volatile int state,通过CAS对state做修改来实现锁的语义。但当时留了个问题没展开:如果CAS失败,线程去哪儿排队?队列长什么样?节点之间怎么串联?这一章专门把这部分啃干净。
先说结论:AQS内部维护的是一个CLH锁队列的变体,不是JDK自带的BlockingQueue那种生产消费队列,而是一个专门用来存放“抢锁失败线程”的双向链表。每个抢锁失败的线程都会被包装成一个Node节点,挂到这个双向链表尾部,然后通过LockSupport.park让自己进入等待状态。当持有锁的线程释放锁时,会从队列头部挑一个合适的线程,调用LockSupport.unpark唤醒它。
这段流程就是AQS队列机制的全部骨架。但骨架之下,细节多到能写一本书。我见过不少面试候选人对AQS的认知停留在“有个队列,抢不到锁就排队”这个层面,一问到Node的状态流转、shouldParkAfterFailedAcquire里为什么那么写、unparkSuccessor为什么要从tail往前遍历,就答不上来了。这一章就是把这些细节逐一抠开,确保你读完能给别人讲明白,面试也能扛住连环追问。
先明确一个核心概念:AQS的队列不是用来存储数据的,它是用来存储线程的。每个节点代表一个线程,节点与节点之间通过prev和next指针串联。队列本身不做任何业务逻辑,它只干一件事——公平地管理一群“抢不到锁暂时歇着”的线程。
这个队列设计上有几个非常关键的特征,先列出来,后面会逐一展开:
- 队列是双向链表,但head节点是哨兵节点,不绑定实际线程;
- 节点入队通过CAS自旋完成,保证并发安全;
- 节点被唤醒后会自旋检查前驱节点是否为head,是才有资格抢锁;
- 取消等待的节点会被标记为CANCELLED,后续在合适时机被清除;
- 唤醒后继节点时,从tail往head方向找最靠近head的有效节点。
这几个特征单看都简单,组合起来就是一套精巧的线程阻塞与唤醒机制。下面我们一个个拆。
2. Node节点到底存了什么:五个字段一个状态
先看Node的数据结构。这段代码在JDK 8到JDK 17里基本没变过,属于稳定得不能再稳定的核心类。
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是节点的状态位,取值就是上面那四个常量加0。0是初始状态,表示当前节点啥事没有,正常排队中。CANCELLED表示这个节点的线程已放弃抢锁,后续会被清理出队列。SIGNAL表示当前节点的后继节点正在(或即将)park,当前节点在释放锁或取消时需要唤醒后继节点。CONDITION表示节点在条件队列里,不在同步队列中。PROPAGATE用在共享锁场景,表示唤醒状态需要向后传播。
这四个状态里,SIGNAL是最核心的,它建立了一种“责任链”:每个节点在park之前,都要确保前驱节点的waitStatus被设置成SIGNAL,意思就是“我睡了,你走的时候记得叫我”。这个机制保证了锁释放时唤醒操作一定能传递到正确的线程上,不会出现线程睡了没人叫的情况。
prev和next就是双向链表的前后指针。但AQS的队列和普通双向链表有个区别,它的prev指针在取消操作中会被特殊使用,这也是后面unparkSuccessor为什么要从tail往前遍历的原因。
thread字段存的是当前节点绑定的线程。注意,head节点的thread永远是null,因为head是哨兵,代表“当前持有锁的线程”的抽象位置。
nextWaiter在独占模式下恒为null,在共享模式下指向下一个共享节点,在条件队列里指向下一个等待条件成立的节点。平时看独占锁流程基本可以忽略它。
这里有个容易忽略的细节:waitStatus初始值是0,但节点入队后第一次shouldParkAfterFailedAcquire会尝试把前驱节点的状态改成SIGNAL。如果改成功,线程才会真正park。如果前驱节点恰好是CANCELLED状态,就跳过它往前找。这个细节在后面的流程里会反复出现。
3. 入队逻辑详解:CAS自旋与尾指针的博弈
先明确一个背景:AQS队列在没有任何竞争时,head和tail都指向null。第一个线程抢锁失败需要入队时,会先执行一次初始化——创建一个空的哨兵节点,让head和tail都指向它,然后再把当前线程的节点接上去。这也就是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; } } } }这个方法有两个细节值得掰扯。
第一个细节:为什么入队要放在for(;;)自旋里?因为compareAndSetTail可能失败。多个线程同时入队时,大家都把node.prev指向同一个tail,但CAS只允许一个成功。失败的线程下一轮循环重新读取tail,再把自己的prev指向新的tail。这个过程中prev指针会被反复修改,但没关系,因为prev的写入不参与并发安全决策,真正的并发安全点是compareAndSetTail这个原子操作。
第二个细节:t.next = node这一步不在CAS保护范围内。也就是说,一个线程CAS成功把tail从t改成node之后,t.next可能还没来得及赋值,另一个线程已经通过tail拿到了新的tail节点。这会不会导致链表断裂?不会。因为AQS在遍历队列时,从不依赖next指针从前往后找有效节点——这一点在unparkSuccessor里体现得淋漓尽致。AQS的队列遍历逻辑,只需要从tail往前通过prev指针回溯就一定能找到所有节点。next指针只是辅助,不是遍历的必需品。
再来看addWaiter,它是入队流程的入口:
private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); 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的自旋流程。这也是AQS性能优化的小心思——大部分场景下入队竞争并不激烈,一次CAS就能搞定,没必要上来就跑自旋。
有个问题值得思考:为什么队列要设计成“head是哨兵节点”而不是“head直接指向第一个排队的线程”?原因至少有两点。第一,如果head直接指向排队线程,那head节点自己在排队,它被唤醒后需要把自己从队列里摘除,这涉及head的变更,操作成本高且并发风险大。第二,哨兵节点的存在让“抢锁成功”这个操作变得非常简单——只需要把head指向后继节点,再把新head的thread置空,prev置空即可,不需要动链表结构。这两点在acquireQueued里看得非常清楚。
4. 获取锁的完整过程:acquire、acquireQueued与中断哲学
acquire方法是整个AQS获取锁流程的入口,总共四行代码,但每一行都是精华:
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }拆开来说。第一步调用tryAcquire尝试获取锁。tryAcquire是抽象方法,由子类实现——ReentrantLock的公平锁、非公平锁,Semaphore的共享模式,都是对tryAcquire的不同实现。如果tryAcquire返回true,说明锁已经拿到,整个acquire方法直接返回,线程继续往下执行业务代码。如果返回false,进入addWaiter把当前线程包装成独占模式节点挂到队尾,然后调用acquireQueued进入排队等待。
acquireQueued是入队后线程的主循环,也是整个AQS最核心的方法:
final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }这段代码的逻辑可以概括为:每个排队线程都在自旋,但自旋不是空转,而是由park与unpark驱动的“睡眠-唤醒-检查”循环。
线程被唤醒后,第一步检查自己的前驱节点是不是head。如果是,说明轮到自己抢锁了,立刻调用tryAcquire再试一次。为什么这里要“再试一次”?因为锁的持有者可能刚好释放了锁,当前线程被unpark唤醒就是一个信号——锁可能空出来了。但也有可能锁又被别人抢走了(非公平锁场景下,新来的线程可能插队),那就继续走shouldParkAfterFailedAcquire再睡一轮。
如果前驱不是head,说明前面还有别的线程在排队,自己还不能抢锁,直接进入park逻辑。
这里引出shouldParkAfterFailedAcquire,它的职责是:检查前驱节点的状态,决定当前线程是否应该立即park,还是需要先做点准备工作。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; if (ws == Node.SIGNAL) return true; if (ws > 0) { do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }分三种情况理解。
第一种:前驱节点的waitStatus是SIGNAL,说明前驱承诺“我走的时候会唤醒你”,那当前线程可以放心park了,返回true。
第二种:前驱节点的waitStatus大于0,也就是CANCELLED,说明前驱已经放弃抢锁了,不能指望它唤醒自己。这时候当前节点要沿着prev指针往前跳过所有已取消的节点,把自己挂到第一个有效节点后面。这段代码就是CLH队列的一个核心操作——清理已取消节点,让队列始终保持可用状态。
第三种:前驱的waitStatus是0或PROPAGATE(也就是不大于0且不等于SIGNAL的情况),说明前驱还没有设置唤醒承诺。当前线程通过CAS把前驱的状态改为SIGNAL,然后返回false,下一轮循环里再检查一次。注意这里为什么要多此一举?因为如果不提前设置SIGNAL,当前线程直接park的话,万一在前驱释放锁之后、自己park之前这个时间窗口里,前驱没有义务唤醒自己,就会造成线程永久沉睡。所以必须先完成SIGNAL的设置,确保“有人会叫我”,然后再睡。
这个设计的本质是在做一件很朴素的约定:线程在睡觉之前,必须先跟邻居打好招呼,保证邻居会叫自己起床。这在并发编程里相当重要,是一种极其典型的“happens-before”关系的建立方式。
parkAndCheckInterrupt就更简单了,两件事:调用LockSupport.park挂起当前线程;线程被唤醒后,检查自己是否被中断过。
private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); }注意这个Thread.interrupted()会把中断状态复位。这是AQS中断哲学的关键:AQS默认不响应中断,但会记录中断状态,在拿到锁之后通过selfInterrupt把中断状态补上。为什么要这么做?因为acquire方法语义上不允许“因为中断就放弃抢锁”,但线程的中断状态不能丢,需要在真正拿到锁并返回给调用者时补一次中断标记,让调用者有机会感知到“我曾经被中断过”。
这里跟acquireInterruptibly有个重要区别:后者在中断发生时直接抛出InterruptedException,不再继续排队。两者一个是“忽略中断但不丢失”,一个是“立即响应中断”,使用场景完全不同。面试中经常拿这两个方法对比发问,本质上考的就是对中断哲学的理解。
setHead的代码也很短,但很值得细看:
private void setHead(Node node) { head = node; node.thread = null; node.prev = null; }这就是前面说的“抢锁成功无需改链表结构”的体现:直接把head指向当前节点,把当前节点的thread置空,prev置空。此时当前节点从“排队的节点”变成了“新的哨兵节点”。原本的哨兵节点(它的前驱)通过p.next = null断开引用,等待GC回收。
5. 释放锁与唤醒后继:为什么从tail往前找
释放锁的入口是release方法:
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尝试释放锁——这个由子类实现;如果释放成功,检查head节点,如果head不为null且waitStatus不等于0,调用unparkSuccessor唤醒后继线程。
这里有个判断:为什么要求h.waitStatus != 0才去唤醒?因为如果head的waitStatus是0,说明没有后继节点处于SIGNAL状态,也就是没有线程在排队等锁(或者排队线程还没来得及设置SIGNAL),这时候不需要唤醒任何线程。如果head的waitStatus是SIGNAL,说明后面至少有一个线程在park等待,必须唤醒。
unparkSuccessor是AQS里最容易出面试题的方法之一:
private void unparkSuccessor(Node node) { int ws = node.waitStatus; if (ws < 0) compareAndSetWaitStatus(node, ws, 0); Node s = node.next; if (s == null || s.waitStatus > 0) { s = null; for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } if (s != null) LockSupport.unpark(s.thread); }第一个动作:把当前节点(head)的waitStatus从SIGNAL改回0。为什么?因为head马上要变成历史节点了,它的“唤醒职责”即将终结,把状态归零避免后续误判。
第二个动作是重点:正常情况下应该唤醒head.next,但如果head.next为null或者head.next已经CANCELLED,就得想办法找一个有效的后继节点。这里选择了从tail往前遍历。为什么不能从head往后找?原因在于AQS入队时t.next = node这个赋值的非原子性——当一个线程CAS成功把tail更新为新节点后,新节点的prev已经指向旧tail,但旧tail的next可能还没来得及指向新节点。如果此刻有线程恰好在这个时间窗口内从head往后遍历,可能找不到这个新节点。而如果从tail往前通过prev遍历,就永远不会遗漏节点,因为prev的赋值发生在CAS之前,CAS成功的那一刻prev已经不可变了。
这个设计相当精巧。它说明AQS的作者很清楚链表在并发环境下“单向遍历不靠谱、反向遍历才安全”这个核心事实。
还有个细节:唤醒时只唤醒一个线程,不是广播。这正是独占锁的语义——锁同一时刻只允许一个线程持有。被唤醒的线程回到acquireQueued的循环里,检查前驱是不是head,然后尝试抢锁。如果没抢到(比如非公平锁下被新来的线程抢先了),继续park,等待下一次被唤醒。
6. 取消与清理:cancelAcquire的内部逻辑
cancelAcquire是AQS里最容易被忽略但又极其重要的方法。凡是进入过阻塞队列的线程,如果因为中断、超时等原因放弃获取锁,都会走这个方法把自己标记为CANCELLED并做清理。
private void cancelAcquire(Node node) { if (node == null) return; node.thread = null; Node pred = node.prev; while (pred.waitStatus > 0) node.prev = pred = pred.prev; Node predNext = pred.next; node.waitStatus = Node.CANCELLED; if (node == tail && compareAndSetTail(node, pred)) { compareAndSetNext(pred, predNext, null); } else { int ws; if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) { Node next = node.next; if (next != null && next.waitStatus <= 0) compareAndSetNext(pred, predNext, next); } else { unparkSuccessor(node); } node.next = node; } }这段代码的分支比较复杂,但是核心意图很清晰:把当前节点从队列中摘除,同时尽量维护队列的连续性。分几个关键点说。
第一步,把当前节点的thread置空。这一步既是语义需要,也是安全考虑——节点取消后不再代表任何线程。
第二步,跳过所有CANCELLED的前驱节点,找到第一个有效节点作为新前驱。这个清理跟shouldParkAfterFailedAcquire里的清理类似,都是在维护队列的“可用性”。
第三步,把当前节点的waitStatus设为CANCELLED。注意,这一步必须在处理tail和next之前完成,因为后面的分支判断依赖这个状态。
第三段的分支逻辑分三种情况。
情况一:当前节点是tail。如果CAS把tail从当前节点改成前驱成功,说明当前节点是队尾,后面没有其他节点依赖它,直接通过CAS把前驱的next置为null即可。这里用CAS是因为可能存在并发入队的线程正在修改tail。之所以用compareAndSetNext(pred, predNext, null)而不是pred.next = null,就是为了避免覆盖掉并发入队时刚设置好的next引用。
情况二:当前节点不是head的后继,且前驱状态正常。这种情况下,当前节点的前驱有义务在解锁时唤醒当前节点的后继,所以需要把前驱的next直接指向当前节点的后继,跳过已取消的当前节点。这是典型的链表删除操作。
情况三:当前节点是head的后继,或者前驱状态异常,或者前驱线程为null。这种情况下没法安全地把前驱的next指向当前节点的后继,干脆调用unparkSuccessor(node)唤醒后继节点,让它自己去竞争锁。这里之所以走到这个分支就必须唤醒,是因为当前节点取消后,它的后继节点已经失去被唤醒的依据——原本当前节点在park前会把前驱设为SIGNAL,但现在当前节点取消了,需要确保后继节点的“唤醒承诺”不会断掉。
最后一行node.next = node是个小技巧:让已取消节点的next指向自己,有助于GC快速回收,同时避免后续遍历时循环引用造成的死循环风险。
7. 超时获取:doAcquireNanos的边界处理
前面聊了独占锁的主流程,还有一个高频考点是带超时时间的获取:tryAcquireNanos。它走后端的doAcquireNanos方法。
private boolean doAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { if (nanosTimeout <= 0L) return false; final long deadline = System.nanoTime() + nanosTimeout; final Node node = addWaiter(Node.EXCLUSIVE); boolean failed = true; try { for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; failed = false; return true; } nanosTimeout = deadline - System.nanoTime(); if (nanosTimeout <= 0L) return false; if (shouldParkAfterFailedAcquire(p, node) && nanosTimeout > spinForTimeoutThreshold) LockSupport.parkNanos(this, nanosTimeout); if (Thread.interrupted()) throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } }跟acquireQueued相比,差异点有三个。
第一个差异:doAcquireNanos方法声明抛出InterruptedException,而acquire不抛。也就是说,带超时的获取锁操作是响应中断的,一旦线程在等待过程中被中断,立即抛出异常退出。
第二个差异:用System.nanoTime()计算绝对deadline,每一轮循环重新计算剩余时间。这里用nanoTime而不是currentTimeMillis,是因为nanoTime是单调时钟,不受系统时间调整影响,超时计算更可靠。
第三个差异:spinForTimeoutThreshold这个阈值常量,默认值是1000纳秒。当剩余超时时间小于这个值时,不再调用parkNanos,而是直接进入自旋。为什么?因为LockSupport.parkNanos的精度不足以支撑纳秒级别的休眠,如果剩余时间连1000纳秒都不到了,调用park的开销反而比自旋更大,甚至可能出现park时间过长导致错过deadline的情况。
这个细节在Java并发源码里是经典设计——避免在时间粒度极小时引入不必要的上下文切换开销。
还有一个容易踩坑的地方:tryAcquireNanos传入的nanosTimeout是相对时间,而方法内部转成绝对deadline后,每次循环重新计算剩余时间。如果线程在park期间刚好被打断,会先响应中断抛出异常,而不是继续等到超时。这个行为跟acquireInterruptibly一致,面试时可能会跟lockInterruptibly对比着问,这里提前说清楚。
8. 常见问题与排查技巧实录
8.1 线程一直卡在acquireQueued不往下走怎么办
这种情况多半是锁没被释放,或者释放后唤醒没生效。排查思路:先通过jstack看线程栈,如果线程停在parkAndCheckInterrupt的LockSupport.park,说明它确实在等锁。再看持有锁的线程是谁——通过head节点的thread字段,或者直接jstack看哪个线程持有了锁。如果持有锁的线程自己也在等锁,那就形成了死锁。在ReentrantLock场景下,排查此类问题最有效的方式是开启JVM的-XX:+PrintConcurrentLocks,它会在线程转储时打印出当前JVM中的所有锁及其持有线程,比人肉看代码高效得多。
8.2 队列里的CANCELLED节点越来越多,影响性能吗
如果大量线程在排队过程中超时或被中断,队列里会出现不少CANCELLED节点。正常情况下,这些节点会在后续的入队、取消操作中逐步被清理,不会长期积累。但有一种情况需要警惕:如果你的业务代码里频繁出现“创建线程尝试拿锁,拿不到就超时退出”的模式,最好先想清楚是不是锁竞争本来就太激烈,而不是只盯着AQS的清理机制打主意。这时候调整锁粒度或者改用读写锁、分段锁,效果往往比在AQS层面想办法好得多。
8.3 为什么非公平锁的性能比公平锁好
非公平锁在tryAcquire阶段不计入排队顺序,直接尝试抢夺。新来的线程可能插队成功,这会带来两个效果:一是减少了线程park和unpark的次数,二是在高竞争场景下让“刚释放锁的线程”更可能立即重新获取锁——线程还在CPU上热乎着呢,没必要切换。但代价是队列尾部的线程可能出现饥饿,虽然实际概率很低。选公平锁还是非公平锁,核心考虑因素是业务对执行顺序的敏感性,而不是性能。
8.4 条件队列和同步队列的关系总是搞混
简要说一下,ConditionObject维护的是另一个等待队列。调用await会让当前线程放弃锁,进入条件队列;调用signal会把条件队列里的节点转移到同步队列,让它重新参与锁竞争。也就是说,同步队列是“正在等锁”的队列,条件队列是“等条件满足”的队列。两者通过transferForSignal这个操作衔接。面试如果问AQS的队列,把这两层关系讲清楚,比只背Node的几个状态值更有说服力。
8.5 如何在线上定位AQS相关的锁竞争问题
实践中最常用的工具是jstack的线程转储。当某个线程卡在锁上时,转储信息里会显示它在等待一个park对象,并且能看出它对应的Node在哪个队列位置。如果配合-XX:+PrintConcurrentLocks,能看到更完整的锁信息。另外,Arthas的thread -b命令可以直接找出当前JVM中阻塞其他线程的“罪魁祸首”,比人工翻转储文件快得多。我一般在线上排查锁问题时,流程是:先Arthas定位阻塞源头线程,再jstack确认业务调用链,最后结合代码定位锁持有时间过长的原因。
8.6 一个容易被忽略的死锁场景:tryLock和lock混用
有些同学在同一个锁对象上,一部分代码用tryLock,另一部分用lock,还夹杂超时逻辑。这种混用模式很容易在故障时给人造成困惑——因为tryLock拿不到锁不会排队,直接返回false;而lock会排队阻塞。如果业务代码对tryLock的false分支处理不当(比如重试间隔太短),可能造成大量线程在短时间内反复尝试,把CPU打满。这虽然不是AQS本身的问题,但理解了AQS的队列机制后,你会明白:tryLock走的是非阻塞路径,不参与队列排队,所以它的竞争行为和lock完全不同,不能想当然地认为两者可以随便混用。
9. 读完源码之后,我建议你做这几件事
源码读到这里,AQS队列的主干脉络已经通了。我个人认为,真正检验自己是否理解AQS的方式不是背方法名,而是回答下面几个问题:
第一,如果让我自己实现一个简化版AQS,我会怎么设计state、队列和park/unpark的配合?第二,公平锁和非公平锁在tryAcquire上只差一行代码级别的判断,为什么性能差异那么大?第三,如果去掉shouldParkAfterFailedAcquire的SIGNAL设置,会发生什么?答案是线程可能永久沉睡,因为没人有义务叫醒你。
这几个问题都能答上来,说明AQS的队列机制你算是真正吃透了。这一章我们把独占模式的核心链路走完了,下一章可以继续聊共享模式、Semaphore、CountDownLatch以及ConditionObject的条件队列实现。