news 2026/10/1 13:01:27

Java并发锁核心:AQS与ReentrantLock源码深度解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java并发锁核心:AQS与ReentrantLock源码深度解析

如果你写过一段时间 Java 并发代码,大概率在某次排查线程卡死时,对着 jstack 里一排“WAITING (parking)”发过愁;如果你又恰好打开过 ReentrantLock 的源码,很容易被 AQS(AbstractQueuedSynchronizer)那一整套基于队列的同步机制绕晕。AQS 作为 Java 并发包的底座,它的价值怎么强调都不为过。今天这篇我试着用一个“抢座位”的场景当主线:一个座位就是锁,一堆人想坐就是线程,成功坐下的人拿到了锁,没坐下的人去候客厅排队,AQS 队列就是那个候客厅,管理员叫号就是 LockSupport.unpark。适合两类人看:一是面试前想把 AQS 讲出深度的人,二是线上遇到过线程大量 WAITING 却不知道怎么定位的人。跟着这条线走完,ReentrantLock 的加锁、解锁、可重入这些源码细节都能对得上号。

1. 整体设计思路:ReentrantLock 为什么要把重活交给 AQS

1.1 为什么不用 synchronized:从 JVM 黑盒到灵活控制

讲 ReentrantLock 源码之前,先回答一个很多人纠结的问题:synchronized 经过这么多年的锁升级优化,性能早就追平甚至反超了,为什么还要有 Lock 接口?答案在于 synchronized 的能力边界。synchronized 的加锁和释放必须包在同一个方法块的开始和结束,获取过程不可中断、不能设置超时。线程一旦进入 synchronized 阻塞,要么等到持有者释放,要么永远等下去。对于“抢不到就放弃”“带超时抢锁”“多路条件等待通知”这类场景,synchronized 做不了。Lock 接口就是把这些能力补全,而 ReentrantLock 是这个接口最经典、使用最广泛的一种实现。你去看它的内部结构会发现,它自己的加锁解锁代码其实很薄,真正干重活的是一个叫 AQS 的抽象类——java.util.concurrent.locks.AbstractQueuedSynchronizer。

1.2 AQS 到底是个什么东西:一个状态量加一条双向队列

AQS 抽象类只做两件事。第一,维护一个 volatile int state,表示同步状态;第二,维护一条 FIFO 的双向等待队列,所有没抢到锁的线程都会变成队列上的一个节点。state 的含义完全由子类定义:在 ReentrantLock 里它表示“锁被重入了几次”,0 是无锁,大于 0 表示持有者重入的次数;在 Semaphore 里它表示剩余许可数量;在 CountDownLatch 里它表示待倒计数的次数。AQS 本身不关心 state 是什么,它只保证两件事:state 的变化是原子的,并且当线程获取不到资源时,能有序地把线程塞进队列,在资源释放后按规则唤醒。

AQS 把“要不要让我拿到锁”这个策略开放给子类,也就是 tryAcquire 和 tryRelease(独占模式),以及 tryAcquireShared 和 tryReleaseShared(共享模式)。子类只要实现这几个方法,就定义了锁的获取规则;至于“没拿到锁之后怎么排队、怎么阻塞、怎么唤醒”,AQS 已经把骨架写死了,这就是模板方法模式在并发包里最经典的落地。你可以这么理解:AQS 相当于一个正经的叫号系统,它不管你按什么规则发号,只管没号的人排队,叫到号的人进去。另外纠正一个常见混淆:这里说的 AQS 队列,和 BlockingQueue、消息队列完全是两码事。阻塞队列管的是生产者消费者之间的数据缓冲,AQS 队列管的是“抢锁线程的等待顺序”。名字都叫队列,读源码时别混。

1.3 阅读源码的路线图

读之前先看 ReentrantLock 的内部结构:一个抽象父类 Sync 继承 AQS,下面挂两个子类 NonfairSync(非公平锁)和 FairSync(公平锁)。它们两个的差异只在 lock() 和 tryAcquire() 上,其余行为全部由父类 Sync 和 AQS 接管。所以阅读路线可以固定成三步:第一步走 lock() 到 acquire() 的入口,看线程是怎么尝试加锁的;第二步走 addWaiter 和 acquireQueued,看没抢到锁的线程怎么入队、怎么排队、怎么被阻塞;第三步走 unlock() 到 release(),看锁释放时如何唤醒队首的下一个线程。下面几章就按这个路线展开。

2. 加锁链路:从“抢座位”到“排队叫号”的完整流程

2.1 非公平锁的 lock():上来就 CAS 抢一次

进入正题。默认构造的 ReentrantLock 用的是 NonfairSync,它的 lock() 实现非常短:

final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }

compareAndSetState(0, 1) 是一个原子的 CAS 操作:如果当前 state 是 0,就把它改成 1,表示“座位上没人,我坐下了”。同时 setExclusiveOwnerThread 记录当前线程为锁的持有者,这是 ReentrantLock 实现可重入的基础。如果 CAS 失败,说明锁已经被别人持有,于是走 acquire(1)。

这里藏着第一个关键设计:非公平锁在进队列之前,会先“插队”尝试一次。为什么要插队?因为队列里的线程被唤醒需要 park/unpark,线程上下文切换的开销比一次 CAS 大得多。如果持锁线程恰好马上释放,新来的线程直接抢到,就省掉了完整的挂起加唤醒流程。这就是“非公平”的含义——不是完全不排队,而是入场时先找机会插队,进了队之后照样老实排队。对于临界区很短的场景,这种插队能明显提升吞吐。CAS 之所以能保证原子性,是因为它最终落到 CPU 的 cmpxchg 指令(x86 架构),硬件层面保证比较和交换不可分割,这是整个 AQS 并发安全的基石。

2.2 addWaiter:抢不到座位的人是怎么排队的

CAS 失败后调用 acquire,它属于 AQS 的模板方法:

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

tryAcquire 在 NonfairSync 里会再试一次,为什么还要试?因为从上次 CAS 失败到走到这里,中间有微小的时间窗口,持锁线程可能已经释放锁了,所以需要再给一次机会。如果第二次也失败,就调用 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; }

Node 是双向链表节点,有 prev、next、thread、waitStatus 四个核心字段。双向结构不是摆设:当节点因超时或中断被取消时,需要把前后的节点接起来;唤醒时还要从 tail 往 head 方向扫描寻找有效节点。如果只有单向链表,这些操作会很尴尬。注意入队过程的顺序:先设置 node.prev = pred,再 CAS 把 tail 指向新节点,最后才设置 pred.next = node。第二步和第三步之间,队列的 next 指针是不完整的——tail 已经移动,但老尾节点的 next 还是 null。这正是后续不能依赖 next 一定已就位的原因,也是后面 unparkSuccessor 要从 tail 往前扫的根源。

2.3 acquireQueued:排队不是死等,是“先自旋再睡觉”

addWaiter 完成后,线程其实还没阻塞,真正的等待发生在 acquireQueued:

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

读这段代码要抓住两个判断。一是只有当前节点的前驱是 head 时,它才去 tryAcquire。为什么?因为 head 代表“正在持锁的线程”或已经出队的虚拟节点,前驱是 head 说明按 FIFO 顺序已经轮到你,这样保证队列内部的执行顺序是严格的。二是 tryAcquire 失败后并不立刻 park,而是先调 shouldParkAfterFailedAcquire 整理队列状态,返回 true 才 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; }

这里需要先搞明白 Node.waitStatus 的取值含义,我整理成一张表:

值名称含义
0初始状态节点刚创建,没做出任何承诺
-1SIGNAL当前节点的后继在等待被唤醒,当前节点释放资源时必须 unpark 后继
1CANCELLED节点被取消(超时或中断),需要被清扫
-2CONDITION节点在条件队列里等待
-3PROPAGATE共享模式下用于向后传播“释放”信号

shouldParkAfterFailedAcquire 做的事情归纳起来就两件:如果前驱是 CANCELLED,就把这些作废节点从链上摘掉,把当前节点的 prev 一路前移,越过所有已取消节点;否则把前驱状态通过 CAS 改成 SIGNAL,相当于给前驱立下“你释放锁时得叫醒我”的契约。第一次调用通常不会 park,因为前驱状态可能还是 0 或已取消,需要先修正;第二次循环过来前驱已经是 SIGNAL,才能放心 park。这套设计的精髓在于:park 之前先和前驱建立契约,避免线程睡下去却没人负责叫醒,这是并发编程里典型的丢失唤醒问题的解法。唤醒后 parkAndCheckInterrupt 返回中断标记,如果被中断过,acquire 最终会在拿到锁之后 selfInterrupt 补上中断状态。

2.4 公平锁 tryAcquire:先来后到是怎么硬保证的

FairSync 和 NonfairSync 的代码差异极小小,集中在 tryAcquire:

protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedPredecessors() && 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 时多了一个 hasQueuedPredecessors() 判断:

public final boolean hasQueuedPredecessors() { Node t = tail; Node h = head; Node s; return h != t && ((s = h.next) == null || s.thread != Thread.currentThread()); }

一句话总结:只要队列里已经有别人先排队,新线程就不能抢锁。h != t 说明队列非空;s == null 是入队过程的中间状态,此时要保守地认为有人在排队;s.thread != 当前线程说明队首等待者不是自己。只有这些条件都不成立,才允许 CAS 抢锁。公平锁的 lock() 没有像非公平锁那样先 CAS 插队,而是直接 acquire(1),配合 hasQueuedPredecessors 做双重限制,保证了严格先来后到。代价是每次加锁都要多读两次 volatile 变量,高竞争场景下开销更明显。要注意,非公平锁也只是在 tryAcquire 阶段允许插队,一旦线程进入 acquireQueued,队列内部依然是严格的 FIFO 执行顺序,这一点不要搞混。

3. 解锁链路:状态归零与叫号唤醒的细节

3.1 tryRelease:state 减到 0 才算真正释放锁

解锁入口是 ReentrantLock.unlock(),它调用 sync.release(1),AQS 的 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; }

真正的释放逻辑在 Sync.tryRelease 里:

protected final boolean tryRelease(int releases) { int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c); return free; }

这里有几个必须讲透的点。第一,tryRelease 会校验当前线程是不是锁的持有者,不是就直接抛 IllegalMonitorStateException。这和 synchronized 完全不同:synchronized 靠字节码和 monitor 宿主对象保证释放者就是进入者,而 Lock 是纯 Java 代码实现的,如果不校验,线程 B 就能调 unlock 把线程 A 持有的锁丢掉,那并发安全就无从谈起。第二,state 减 1 后如果没减到 0,说明锁被重入了,最外层还没释放完,此时返回 false,release 不会唤醒队列里的任何线程。这就是为什么 lock 和 unlock 必须严格配对。假如你在持有锁的方法里又 Lock 了一次,却只 unlock 一次,state 会一直停留在一个非零值,其他线程会永远等下去。第三,state 减到 0 后把 owner 清成 null,返回 true,release 才去唤醒 head 的后继。另外注意 release 里有个判断:head 的 waitStatus 是 0 时不需要唤醒,说明队列里没有等待者或 SIGNAL 契约还没建立。

3.2 unparkSuccessor:叫号员是怎么工作的

当 tryRelease 返回 true,AQS 就会调 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 清回 0,因为 head 马上要让出位置;然后找下一个要被唤醒的节点;最后 LockSupport.unpark 唤醒它。为什么要从 tail 往前遍历找下一个节点,而不是直接用 head.next?原因有两个。一是新节点刚入队时 next 指针可能还没建立,head.next 可能是 null,直接取会漏掉等待者;二是 head.next 可能已经 CANCELLED,这种作废节点不能唤醒。所以代码选择从 tail 往前扫到 head,找 waitStatus <= 0 且离 head 最近的节点。理解入队的非原子三步之后,这个“从后往前找”的设计就顺理成章了:每个节点的 prev 在入队时就固定,向后遍历总是可靠的,而 next 在并发下可能是断的。这里也要注意,unpark 只是把线程从挂起状态变成可运行状态,并不直接给锁。被唤醒的线程回到 acquireQueued 循环里,检查前驱是不是 head,再走 tryAcquire。所以解锁过程中谁最终拿到锁,还是由规则本身(公平或非公平)决定的。

3.3 重入与嵌套:Lock 套 Lock 会出什么事

可重入是 ReentrantLock 最常用的特性,同一个线程持有锁后,再进入同一把锁保护的代码块,不需要先释放锁,state 加一就行。回到 tryAcquire 的源码,else if 分支里判断 current == getExclusiveOwnerThread() 时,直接 setState(c + 1),这里不需要 CAS。为什么不需要?因为能进入这个分支,说明锁一定已经被当前线程持有,state 的写只会发生在持锁线程自己身上,没有并发写竞争,直接赋值就安全。

可重入带来的一个实用结论:在持锁方法里调用另一个加锁方法,不会死锁。常见例子:

class Service { private final ReentrantLock lock = new ReentrantLock(); public void outer() { lock.lock(); try { inner(); } finally { lock.unlock(); } } public void inner() { lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); } } }

inner() 能拿到锁,因为 state 是计数而不是简单布尔位。如果你自己用 AtomicInteger 和 CAS 实现互斥,可重入这个需求就得另用 ThreadLocal 记录持有次数,非常麻烦,这也是 ReentrantLock 用 state 计数而不用 boolean 的原因。可重入的极端情况也有防护:源码里 if (nextc < 0) throw new Error("Maximum lock count exceeded"),当 int 溢出会抛 Error,正常业务基本触发不了,但这行代码堵死了极端场景下的溢出静默问题。

4. 进阶机制:中断、超时与条件变量

4.1 lock() 到底能不能被 interrupt 打断

这是 ReentrantLock 和 synchronized 的重要差异。很多人以为 lock() 可以响应中断,其实不对。默认 lock() 对中断是“装傻”的:线程在 AQS 队列里 park 后,如果另一个线程调 interrupt(),parkAndCheckInterrupt 会捕获中断标记并返回 true,但 acquireQueued 只是把 interrupted 记为 true,继续循环等锁。等它终于拿到锁,才通过 selfInterrupt() 把中断状态补回当前线程。也就是说,lock() 不会因为中断放弃抢锁,只是“记仇”——拿到锁之后再把中断状态还给你,由你的业务代码自己处理。

如果你希望等待过程中一被中断就立刻放弃,要用 lockInterruptibly(),它会走 acquireInterruptibly:

public final void acquireInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); if (!tryAcquire(arg)) doAcquireInterruptibly(arg); }

doAcquireInterruptibly 和 acquireQueued 的循环结构几乎一样,区别在于 parkAndCheckInterrupt 返回 true 时直接抛 InterruptedException,而不是记录标记。抛出异常前会走 finally 里的 cancelAcquire,把自己从队列中摘除。这个差异在线程池场景很重要:如果你希望线程被 shutdownNow 时能快速从等锁状态释放,就得用 lockInterruptibly 或带超时的 tryLock,而不是裸 lock()。踩过一次线程池老是关不掉、任务堆在那儿的坑之后,你会明白这个选择不是吹毛求疵。

4.2 tryLock 超时:抢不到就果断放弃

tryLock() 无参版本是立即尝试一次:成功返回 true,失败返回 false,不排队。带超时的 tryLock(timeout, unit) 会在队列里等一段时间,超时还没拿到就返回 false,核心逻辑在 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); } }

两个工程细节值得学。一是用 System.nanoTime() 而不是 System.currentTimeMillis() 做超时计算:nanoTime 基于单调时钟,不受系统时间调整影响,适合测量时间间隔;currentTimeMillis 受 NTP 校时和手动修改影响,做超时计算容易出幺蛾子。二是 spinForTimeoutThreshold 的值是 1000 纳秒:剩余时间比这个阈值还短,直接自旋不再 park,因为 park/unpark 本身有上下文切换开销,可能比自旋 1000ns 更贵。这种“太短就不睡”的取舍,在写高频并发代码时非常实用。线上遇到“抢锁但不想无限等”的场景,tryLock 基本是最优解,但务必在拿到锁之后才在 finally 里 unlock,没拿到锁不能 unlock。

4.3 Condition:多路等待队列和 await/signal 全流程

ReentrantLock 比 synchronized 高阶的另一处是 Condition。一个锁可以 new 多个 Condition,每个对应一条独立的等待队列,相当于把 Object.wait/notify 的单等待队列拆成多路。典型场景是生产者消费者:一个 Condition 表示“缓冲区没满”,另一个表示“缓冲区没空”,不同队列互不干扰,避免了 synchronized 下 notifyAll 的惊群效应。

Condition.await 的简化流程是:

public final void await() throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); Node node = addConditionWaiter(); int savedState = fullyRelease(node); int interruptMode = 0; while (!isOnSyncQueue(node)) { LockSupport.park(this); if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) break; } if (acquireQueued(node, savedState) && interruptMode != 0) // 处理中断 }

await 绝不是简单挂起线程。它会先释放锁,这是关键:如果一个持锁线程在 await 里不释放锁,其他线程永远进不来,Condition 就废了。它还记录了重入次数 savedState,等被唤醒回到同步队列后,会重新 acquire 这个数量的 state,保证可重入语义不丢。

signal 的流程同样微妙:

public final void signal() { if (!isHeldExclusively()) throw new IllegalMonitorStateException(); Node first = firstWaiter; if (first != null) doSignal(first); }

doSignal 会把条件队列的第一个节点 transferForSignal 到同步队列。注意这里并不直接 unpark 线程,只是把节点从条件队列搬到同步队列尾部,waitStatus 从 CONDITION 改成 0,再尝试把前驱设置成 SIGNAL。真正的唤醒仍然发生在持锁线程最终 unlock 时,由 unparkSuccessor 完成。很多人以为 signal 一调等待线程立刻执行,其实它只是“搬家”,要等持有的锁释放才会被叫号,这是面试容易出错的隐藏细节。

4.4 从 AQS 看 JUC 全家桶:一个骨架撑起半个并发包

读到这里你会发现,AQS 的核心就是“一个原子状态 + 一条等待队列 + 一套模板方法”,这套骨架不止给 ReentrantLock 用。独占模式由 tryAcquire/tryRelease 定义,共享模式由 tryAcquireShared/tryReleaseShared 定义,共享模式的返回值语义是:负数表示失败,0 表示成功但没剩余资源,正数表示成功且还有剩余资源。CountDownLatch 就是共享模式实现:tryAcquireShared 判断 state(倒计数)是否已经是 0,不为 0 返回负数让线程排队;countDown 把 state 减一,减到 0 后 releaseShared 唤醒所有等待线程,这是一个一次性的门闩。Semaphore 也是共享模式:tryAcquireShared 尝试把许可数减一,减不了就排队,releaseShared 加一并唤醒等待者。ReentrantReadWriteLock 更复杂一点:state 的低 16 位存写锁重入数,高 16 位存读锁持有数,读锁走共享模式,写锁走独占模式,同一个 AQS 队列要同时容纳两类等待者,通过 waitStatus 和节点模式区分。理解了这套骨架,再去读 JUC 其他源码会快非常多,因为排队、阻塞、唤醒这些最难的机制全都通了,这就是 AQS 设计真正值钱的地方。

5. 实操心得:锁的选型、线上排查与踩坑记录

5.1 公平锁和非公平锁到底怎么选

这是面试高频题,也是工程里容易纠结的地方。先给结论:默认用非公平锁,ReentrantLock 无参构造就是非公平,特殊场景才考虑公平。

两者本质差异在于“新来的线程能不能插队”。非公平锁允许在队列非空时通过 CAS 直接抢一次,以及在 acquireQueued 循环里 tryAcquire 时再次尝试;公平锁则强制“队列里有排队者就不能抢”。性能上,非公平锁在临界区短、竞争适中的场景下吞吐更高,因为减少了 park/unpark 的线程切换;但在高竞争且任务执行时间不定时,可能造成队列尾部线程长时间饥饿。公平锁适合对响应延迟敏感、任务时间短、需要稳定顺序的场景,代价是每次 acquire 都要读 volatile tail/head 判断排队情况,吞吐通常明显下降。

我做一个对比表方便参考:

维度非公平锁公平锁
加锁规则先 CAS 插队,抢不到再排队队列非空就必须排队,禁止插队
吞吐高,减少线程切换低,多一次队列检查
饥饿风险有,尾部线程可能长时间等待无,先来后到
典型场景大部分业务系统默认选择避免任务饥饿、顺序敏感的调度
实现差异tryAcquire 不查队列tryAcquire 里调 hasQueuedPredecessors

还有一个容易误解的点:非公平锁的“插队”只发生在入口和 tryAcquire 阶段,线程一旦入队,在 acquireQueued 内部仍然是按队列顺序执行的,前驱必须是 head 才能拿锁。所以非公平锁并不是让队列里的人乱队,只是给新来的线程一些插队机会。

5.2 线上排查:jstack 里一眼认出等锁线程

实战中遇到线程池任务卡住、接口超时,最常见的原因之一就是大量线程阻塞在锁上。jstack 抓出来的线程栈里,如果看到类似这样的片段:

"pool-1-thread-5" WAITING (parking) at sun.misc.Unsafe.park (Native Method) at java.util.concurrent.locks.LockSupport.park at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued

这就是线程在 AQS 队列里等锁。注意后面通常会带一个 “parking to wait for <0x...>” 的地址,这个地址就是锁对象。接下来再找谁持有了这把锁:看同一份 dump 里有没有 RUNNABLE 状态的线程正在执行临界区逻辑;如果是 ReentrantLock,还可以看持锁线程的栈里有没有对应的 unlock 帧,如果卡在业务代码里,说明临界区里做了耗时操作,比如远程 HTTP 调用、数据库慢查询之类。

讲一个真实的踩坑案例。某次任务调度系统批量任务全部卡住,jstack 发现 30 个 worker 全部 WAITING(parking) 在同一个锁地址,而持锁线程 RUNNABLE 但卡在一个外部 API 调用上。根因是临界区里做了远程调用,单个请求耗时 30 秒,其他线程全部被堵住。后来把远程调用移出临界区,性能立刻恢复。排查思路总结下来就是:先认锁地址,再认持锁线程,最后查临界区里做了什么。看到 WAITING(parking) 先别慌着重启,它通常指明了一个明确的方向。

5.3 几个容易翻车的 Lock 编码习惯

第一,lock 和 unlock 必须配对。最简单可靠的习惯是统一写成:

lock.lock(); try { // 业务 } finally { lock.unlock(); }

lock() 放 try 外面,避免“获取锁失败还走进 finally”。无论中间是 return、异常还是 break,unlock 都会执行。手动在多个分支条件里写 unlock,十有八九会漏,这类 bug 很难复现,线上往往表现为线程池逐渐被占满。第二,不要在持锁期间做阻塞式 IO。锁本质上把并发降成了串行,临界区里有网络请求、磁盘 IO、跨服务调用,等于把吞吐按锁的粒度重新串行化。实在躲不开,至少用 tryLock 加超时,别让等待线程无限阻塞。第三,Condition.await 必须在持有锁的情况下调用,signal 也必须在持有锁的情况下调用,否则直接抛 IllegalMonitorStateException。如果你把 await 从 try 里拿出来,或者忘了在 finally 里 signal,等待线程可能永远睡下去。第四,ReentrantLock 的锁是线程级别的,A 线程加锁必须 A 线程解锁,不能跨线程转移,这和某些分布式锁模型不一样,别试图用两个线程协作完成 lock 和 unlock。第五,可重入锁的 lock 次数和 unlock 次数要一致。为了防手滑,可以写一个 AutoCloseable 包装类,用 try-with-resources 语法保证配对,JDK 8 之后这个小工具成本极低,收益很高。

5.4 读 AQS 源码的几个实用技巧

最后分享一点读源码的方法,都是我自己实践过、觉得有效的。

第一,动态调试比静态读代码有效。起两个线程竞争一把锁,在 acquireQueued 的循环、shouldParkAfterFailedAcquire、unparkSuccessor 打上断点,用 IDE 的线程视图观察节点状态变化。你会发现“锁的持有者永远是 head,线程就是队列里不断移动的节点”这句话变得非常直观。第二,先画状态图再读实现。把 Node 的几种 waitStatus、线程在同步队列和条件队列之间的转移画在纸上,对照代码读。源码逻辑本来就绕,不先把状态机的转移关系理清,很容易读两遍就忘。第三,不要试图一次读懂所有方法。第一次只看 lock/unlock 主链路,第二次看 Condition,第三次再看共享模式和 cancelAcquire 的取消逻辑。每一轮的收获完全不同。第四,读 AQS 时要留意它大量使用“先 CAS 建立状态,再补全指针”的并发手法,这不是代码写得随意,而是为了保证竞争情况下队列结构不被破坏的特殊顺序,很多地方一旦颠倒就产生并发 bug,不能按普通单线程代码的顺序感去理解。

最后说点源码之外的体会。AQS 第一遍读不懂很正常,我前后啃了四五轮才敢说自己理解。别贪快,第一遍只盯着 lock/unlock 主链路,第二遍再看 Condition,第三遍去对比共享模式。建个自己的调试工程,两个线程抢一把锁,在关键方法打上断点,亲眼看看节点怎么进队、head 怎么交接,比背十遍源码都有效。这之后你会发现,Java 并发包里其他工具,什么 CountDownLatch、Semaphore,读起来全都似曾相识,因为它们的骨架都是 AQS。到那个时候,你再回头想那个“抢座位”的比喻:一个状态、一条队列、一个叫号员,整个并发世界就这么转起来了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 13:01:11

Zebra打印机ZPL指令入门与Windows直打实战指南

简介&#xff1a;这是一份面向.NET开发者与工业打印集成工程师的Zebra打印机C#开发演示资源&#xff0c;聚焦于零积分、免付费调用Zebra标签打印功能的实践方案&#xff0c;适用于物流分拣、仓储贴标、零售POS等需快速对接Zebra硬件的业务场景。压缩包共89个文件&#xff0c;涵…

作者头像 李华
网站建设 2026/10/1 13:01:11

VC中Win32原生OpenGL三维绘图实战:从窗口创建到渲染上下文配置

简介&#xff1a;本资源是一套在Visual C环境下实现OpenGL三维图形渲染的完整工程实践代码包&#xff0c;面向C图形编程初学者与计算机图形学入门开发者&#xff0c;解决Windows平台下OpenGL环境搭建、上下文管理、三维建模与实时渲染等核心问题。压缩包共108个文件&#xff0c…

作者头像 李华
网站建设 2026/10/1 13:00:16

地产增发方案解读:30.9%持股比例背后的控制权与摊薄

最近我把德祥地产的增发方案翻出来&#xff0c;对照此前两轮公告重新读了一遍。表面上看&#xff0c;这不过是一次常规股本增发&#xff1a;上市公司引入新的战略认购人&#xff0c;现有大股东瑞凯集团顺势扩大持股&#xff0c;一路逼近 30.9% 的关键比例。但这类方案从来不只是…

作者头像 李华
网站建设 2026/10/1 12:59:58

换根DP实战:两趟DFS高效求解树上最大连通子图得分

这道题乍一看像是“又一道树形DP”&#xff0c;但等你看清数据范围和询问方式&#xff0c;才会发现真正的考点是换根法。题目来自图论里非常典型的场景&#xff1a;给一棵带点权的树&#xff0c;需要以每个节点作为“根”或“连接点”计算某个子图的最大得分。如果对每个点都单…

作者头像 李华
网站建设 2026/10/1 12:59:52

Paperclip:AI Agent轻量级协同中间件设计与实战

1. “Paperclip”不是回形针&#xff1a;它正在悄悄改写AI Agent的底层协作逻辑你搜“paperclip”&#xff0c;第一反应是办公桌抽屉里那枚银色小金属&#xff1f;别急——在2024年中后期的AI工程圈&#xff0c;这个词正以极快的速度脱离物理世界&#xff0c;变成一个高频、隐晦…

作者头像 李华
网站建设 2026/10/1 12:59:11

JavaWeb Servlet实战:可部署的MVC分层教学工程

简介&#xff1a;这是一份面向Java Web初学者与进阶学习者的实战型源码资源&#xff0c;聚焦MVC分层开发实践&#xff0c;帮助学习者系统掌握从Servlet/JSP基础到Spring、Hibernate等主流框架集成的完整Web应用开发流程。资源共450个文件&#xff0c;6.75MB ZIP包&#xff0c;包…

作者头像 李华