首页 / 资讯中心 / 文章详情

深入理解AbstractQueuedSynchronizer:Java并发锁的底层骨架

深入理解AbstractQueuedSynchronizer:Java并发锁的底层骨架 ★ FEATURED ARTICLE
如果你跟我一样最初接触AbstractQueuedSynchronizer时只记住了它是一个队列同步器那这篇文章就是写给你的。我在某个高并发项目的压测阶段被ReentrantLock的源码逼着去啃 AQS啃完之后最大的感触是Java 并发包里几乎所有同步工具——ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock——都只是 AQS 这同一个骨架上的不同器官。理解了 AQS你就等于拿到了并发工具类的源码级地图。这篇文章我会从设计者的视角把 AQS 拆开为什么用一个int表示同步状态、为什么等待队列选双向链表、模板方法怎么被复用。中间会穿插源码走读和一个自定义同步器的完整落地最后分享我接入 AQS 时踩过的几个坑。内容偏原理和实操结合适合有一定 Java 并发基础、想彻底搞懂锁机制的开发者也适合准备面试想系统梳理 AQS 的人。1. 为什么每个并发工具背后都藏着同一个AQS1.1 从ReentrantLock的一个反直觉现象说起先抛个现象ReentrantLock里的lock()和unlock()底层其实调的都不是锁对象自己的方法而是一个叫Sync的内部类方法这个Sync又继承了 AQS。也就是说你每天在业务代码里写的lock.lock()真正的执行者是一个 AQS 实例。我最初不理解这种套娃设计的意义直到我自己用synchronized和ReentrantLock做对比测试。synchronized加锁解锁是 JVM 直接管的你没法干预获取锁失败时干什么而ReentrantLock却能实现获取锁最多等 3 秒获取锁期间响应中断获取不到锁就去做别的事这些灵活行为。这些行为的底层支撑恰恰就是 AQS 提供的模板流程加上子类自定义的策略钩子。所以 AQS 解决的核心问题用大白话说就是把线程抢锁、抢不到排队、排队中阻塞唤醒、释放锁后通知下一个这套通用流程固化下来把什么条件下算抢锁成功、什么条件下算释放成功这类具体判断留给子类。通用骨架做好同步器作者只需要关心业务规则不需要重复造轮子。1.2 一个int如何撑起加锁的底层设计思路AQS 里最核心的成员变量之一是private volatile int state。很多人以为锁的状态应该是一个复杂对象比如记录持有者线程、重入次数、等待队列但设计者只用了一个int。原因在于所有同步器本质上都在回答一个问题——当前可用的资源数量是多少。这个state在不同的同步器里有完全不同的语义同步器state 的语义获取资源操作释放资源操作ReentrantLock重入次数0 表示无锁CAS 从 0 改成 1每次减 1Semaphore剩余许可数减 1不能为负加 1CountDownLatch剩余待计数不获取只等待减 1ReentrantReadWriteLock高 16 位读锁计数低 16 位写锁计数按位运算按位运算我一开始犯过一个错误认知以为state改变必须靠synchronized保护。实际上 AQS 用的是 CAS因为state是volatile的CAS 操作能保证检查-修改的原子性。这在高并发抢锁场景下非常关键——上千个线程同时执行lock()大家都在对同一个state做 CAS谁成功了谁就拿到锁失败的线程才进入后面的排队流程。这种先尝试再排队的设计其实是乐观锁思想。大部分情况下锁竞争并不激烈CAS 一次就能成功不需要把线程挂起性能开销极小。只有当 CAS 失败资源确实被占用时才会走阻塞流程。2. 同步状态state与CLH等待队列的设计哲学2.1 state的可见性保证与三组方法既然state被多个线程同时读写光声明volatile还不够AQS 还提供了三组访问方法getState()读取同步状态setState(int)直接写入状态通常用于非竞争场景compareAndSetState(int expect, int update)CAS 更新状态这三组方法中compareAndSetState是并发修改的核心。举个例子ReentrantLock的非公平锁获取逻辑大概是先 CAS 试探state从 0 改 1成功就设置当前线程为锁持有者失败才走完整流程。这里有个设计细节很容易被忽略setState是直接写为什么要暴露一个普通方法因为它服务于已经持有锁的线程在释放锁时减少重入次数。比如可重入锁中线程多次lock()state变成 3每次unlock()就执行setState(state - 1)此时线程已经持有锁不存在竞争用 CAS 反而多余。而getState()之所以需要 volatile 保证是因为等待队列里的线程可能被唤醒后要读取最新状态。比如唤醒线程已经释放锁并执行setState(0)被唤醒的线程必须以最新值做 CAS 抢锁如果读到旧值整个同步器就乱套了。2.2 CLH队列的变体为什么是双向链表而不是别的结构AQS 等待队列是 CLHCraig, Landin, Hagersten锁队列的一个变体。原始 CLH 队列是单向链表每个节点自旋检查前驱节点的状态AQS 改成了双向链表节点状态也更复杂。结构上就是一个Node双向链表head指向头节点头节点对应的线程是当前持有资源的线程或已释放资源的哨兵节点tail指向尾节点新节点通过 CAS 追加到尾部Node 中维护prev和next指针以及thread该节点对应的线程和waitStatus节点状态为什么要用双向链表因为在线程被唤醒后需要从后往前找可唤醒的节点而且取消排队节点状态变为 CANCELLED时要把自己从链表中摘除这都需要prev指针。单向链表做不到高效的前驱回溯。实际上 AQS 还有一个隐藏优化新线程入队不一定立刻阻塞。acquireQueued方法里有个自旋阶段当前线程入队后会先尝试获取资源只有获取失败且前驱节点的状态是SIGNAL表示前驱有义务唤醒自己时才真正调用LockSupport.park挂起。这减少了一次线程挂起再唤醒的昂贵开销。2.3 Node节点上的四种状态到底在表达什么Node 的waitStatus是另一个容易被忽略的细节。它告诉周围节点我这个节点接下来想干什么。常见值有状态值含义触发场景CANCELLED (1)线程已放弃竞争超时或中断SIGNAL (-1)当前节点的后继需要被唤醒释放资源时要 unpark 后继CONDITION (-2)节点在 Condition 队列中等待调用await()PROPAGATE (-3)共享锁场景下唤醒要向后传播Semaphore释放许可时SIGNAL 状态是理解整个阻塞唤醒机制的关键。新入队的节点在阻塞之前会把前驱节点的waitStatus改成 SIGNAL意思是前驱释放资源时记得唤醒我。这是一个约定的信号传递机制每个节点都通过检查前驱的状态来决定自己要不要阻塞。我在看这段源码时最大的困惑是为什么不是检查后继节点状态后来想通了——唤醒动作发生在释放资源的线程上它只关心自己的后继而是否需要阻塞的决策发生在获取资源的线程上它只关心自己的前驱。职责分离让每一环都简单清晰。3. 模板方法模式排他锁与共享锁共用一套骨架的巧妙之处3.1 模板加钩子AQS预留的五个口子整个 AQS 的骨架是acquire(int arg)和release(int arg)还有共享版的acquireShared和releaseShared。骨架流程如下获取尝试获取 - 失败则入队 - 入队后循环尝试或阻塞 - 获取成功后返回 释放尝试释放 - 成功后唤醒后继节点骨架本身是固定演出流程但尝试获取和尝试释放的具体判断条件完全开放给子类。AQS 暴露了五个可覆写的钩子方法tryAcquire(int arg)排他方式尝试获取返回 true 表示成功tryRelease(int arg)排他方式尝试释放tryAcquireShared(int arg)共享方式尝试获取返回负数表示失败0 表示成功但无剩余正数表示成功且有剩余tryReleaseShared(int arg)共享方式尝试释放isHeldExclusively()当前线程是否独占资源主要用于 Condition这套设计的精妙之处在于子类只需要实现这几个钩子完全不用碰队列操作、线程阻塞唤醒这些复杂逻辑。翻一下ReentrantLock的Sync子类你会发现它只实现了tryAcquire和tryRelease其他全是 AQS 自带。我还记得第一次在自定义同步器里只实现两个钩子就能工作时的惊讶感。后来我用一个比喻理解它AQS 是流水线钩子是流水线上的质检员不同工厂只需要换质检员流水线本身不用动。3.2 公平锁与非公平锁的差异源自一个钩子ReentrantLock有公平锁和非公平锁两个版本两者的代码差异比很多人想象的小得多。它们甚至共用同一个tryAcquire核心逻辑唯一的区别在于非公平锁lock()进来先直接 CAS 抢一次抢不到再走排队公平锁任何时候都先检查队列里有没有排队的节点有就老老实实排队用源码语言来说公平锁的tryAcquire里多了一个!hasQueuedPredecessors()判断。这个判断会查看队列中当前节点之前是否有其他等待者——如果head不等于tail且当前线程不是head的直后继说明有人比自己先来当前线程必须放弃这次尝试。这个设计带来的结果是非公平锁吞吐量更高减少线程挂起唤醒带来的上下文切换但可能出现插队现象公平锁按顺序获取但代价是更多线程进入阻塞再唤醒的流程。具体项目里怎么选我后面会详细说。3.3 共享模式x个许可怎么同时放行排他锁和共享锁在 AQS 里走的是两套不同骨架但设计思路一脉相承。共享模式的关键方法是doAcquireShared它的返回值有三类负数表示失败0 表示成功但没有剩余资源正数表示成功后仍有剩余资源。拿Semaphore举例初始化 5 个许可4 个线程同时acquire()都成功第 5 个线程的第 6 次尝试返回负数进入等待队列。每次release()会把许可数加 1并触发共享释放的传播唤醒——不仅唤醒后继一个节点如果资源还有富余后继被唤醒后再尝试获取成功还会继续唤醒它的后继。这种链式唤醒是共享模式和排他模式最大的区别。排他锁释放时unparkSuccessor只唤醒一个后继共享锁释放时只要资源没有被耗尽唤醒会像多米诺骨牌一样继续传递。我用CountDownLatch验证过一次一个闸门计数归零的瞬间所有等待线程几乎同时被唤醒而不是一个个排队苏醒。4. 从acquire到release的完整生命周期源码走读4.1 获取tryAcquire失败后addWaiter如何入队这一节我带你走一遍ReentrantLock加锁的完整链路。调用lock()实际进入的是acquire(1)public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) { selfInterrupt(); } }分解一下这行代码第一步tryAcquire(arg)子类实现的钩子尝试获取资源。成功就直接返回性能最优路径结束。第二步addWaiter(Node.EXCLUSIVE)获取失败创建一个排他模式的 Node通过 CAS 追加到队列尾部。尾部竞争由 CAS 保证原子性多个线程同时入队不会出错。第三步acquireQueued入队后不断尝试获取资源或判断是否需要阻塞。第四步selfInterrupt()acquireQueued返回 true 表示等待过程中被中断过这里补上中断标记。注意addWaiter有一个细节如果 tail 不为空先尝试一次快速入队CAS失败才走enq的自旋入队流程。这又是一种乐观优化——大部分情况 CAS 一次就成功不需要进入自旋循环。4.2 等待shouldParkAfterFailedAcquire的信号机制acquireQueued中的核心循环逻辑是for (;;)自旋里做三件事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 的下一代才尝试的设计保证了队列整体有序。shouldParkAfterFailedAcquire的逻辑是前驱节点状态是SIGNAL说明前驱承诺释放时会唤醒自己可以安心阻塞前驱状态是CANCELLED跳过它继续向前找有效节点把链表中的取消节点摘除前驱状态是 0用 CAS 把前驱状态改成SIGNAL下次循环再阻塞这个设计我理解了很久才明白它的精妙之处线程不是一入队就立刻挂起而是至少自旋检查两轮——第一轮把前驱状态改成 SIGNAL第二轮才真正阻塞。给刚释放资源的瞬间预留了一个时间窗口很多竞争在这个窗口期内就直接结束了避免了线程的挂起和唤醒开销。4.3 释放unparkSuccessor怎么精确唤醒后继释放路径比获取路径简单。release(1)先调用tryRelease成功后获取当前 head调用unparkSuccessorprivate 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因为waitStatus为 SIGNAL 表示需要唤醒后继一旦唤醒动作确实发生了这个标记就没用了。如果不重置后面的排队节点会误以为还需要再次唤醒。为什么后继节点为空或已取消时要从 tail 往前遍历这是 AQS 源码里最容易被忽略的边界处理。next指针可能因为并发入队而暂时为空CAS 入队是先设置tail再设置pre/next关联中间有个空隙窗口所以不能依赖next找后继必须从 tail 向前找到第一个非取消节点。这个从后往前的回溯逻辑是并发场景下保证不丢唤醒的关键。5. 手写一个自定义同步器把AQS模板用起来5.1 需求做一个限流闸门理论说了这么多不落一次地不算真懂。我来做一个自定义同步器一个只允许 N 个线程同时进入的闸门类似最多 N 个并发的限流器。这里用共享模式更合适。初始 state N每个线程进入时tryAcquireShared将 state 减 1成功表示获得通行证离开时tryReleaseShared将 state 加 1。为什么不直接用Semaphore一是练习 AQS 的钩子实现二是明确这样一个事实Semaphore本身也不过是一个 AQS 钩子实现而已。5.2 源码实现仅用tryAcquireShared和tryReleaseShared继承 AQS实现共享模式的两个钩子public class SimpleGate extends AbstractQueuedSynchronizer { public SimpleGate(int permits) { setState(permits); } Override protected int tryAcquireShared(int arg) { for (;;) { int current getState(); int remain current - arg; if (remain 0 || compareAndSetState(current, remain)) { return remain; } } } Override protected boolean tryReleaseShared(int arg) { for (;;) { int current getState(); int next current arg; if (compareAndSetState(current, next)) { return true; } } } public void enter() { acquireShared(1); } public void exit() { releaseShared(1); } }注意tryAcquireShared的循环写法。先读current计算remain如果remain 0直接返回负数表示失败否则 CAS 尝试更新。CAS 失败说明有其他线程同时修改了 state自旋重试。这比 synchronized 保护整个方法的写法吞吐量高得多。enter()和exit()是同步器对外暴露的业务方法其实内部就是调用 AQS 的acquireShared/releaseShared。我没有覆写isHeldExclusively因为共享模式不需要独占判断。5.3 测试60个线程抢10个许可证写个简单的并发测试验证public static void main(String[] args) throws Exception { SimpleGate gate new SimpleGate(10); AtomicInteger inside new AtomicInteger(0); AtomicInteger maxInside new AtomicInteger(0); for (int i 0; i 60; i) { new Thread(() - { gate.enter(); try { int current inside.incrementAndGet(); // 统计最大同时在线人数 while (true) { int observed maxInside.get(); if (current observed || maxInside.compareAndSet(observed, current)) { break; } } Thread.sleep(50); // 模拟业务处理 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { inside.decrementAndGet(); gate.exit(); } }).start(); } Thread.sleep(3000); System.out.println(最大同时在线数: maxInside.get()); }实测最大在线数严格等于 10。这说明 state 的 CAS 计数逻辑完全正确AQS 的共享队列也确实做到了资源不够就排队的约束。你可以把它换成Semaphore试试行为几乎一致——因为Semaphore内部就是这么实现的。6. 接入AQS后我遇到过的坑和排查方法6.1 中断状态被吞掉导致的线程永不停止我第一次写自定义同步器时犯了经典错误acquireShared返回后没有检查中断状态。业务代码调Thread.interrupt()想终止一个正在抢闸门的线程结果线程完全无视中断继续阻塞。这在异步场景下非常危险。排查过程我先加日志打印线程状态发现线程始终是 WAITING 状态。随后翻了acquireSharedInterruptibly的源码对比才发现——普通acquireShared版本确实会忽略中断只有Interruptibly版本的才会抛出InterruptedException。想要中断响应必须显式调acquireSharedInterruptibly。修法很简单enter()里改用带Interruptibly的方法。但这段排查经历让我明白一个道理AQS 把中断是否被处理这个决策交给了同步器作者不同的 API 方法语义差别很大选中之前要看清楚。6.2 公平模式比非公平模式慢一倍的真实数据某次我用自定义锁做过一个基准测试8 个线程对同一个计数器各累加 100 万次。非公平模式耗时约 120ms公平模式耗时约 430ms。差距非常大而且线程数越多差距越明显。原因很好理解公平模式下每个线程释放锁后必须立即挂起下一个线程被唤醒后才能执行。每次锁交接都要经历挂起→唤醒的完整上下文切换。非公平模式下释放锁的线程可能立刻又重新抢到锁省去了挂起唤醒的过程。实际项目里的取舍如果你有严格的先来后到需求选公平锁如果只是保护临界区非公平锁的性能优势明显。绝大多数业务场景其实不需要公平性默认用非公平锁就够了。6.3 只想看有没有人排队时的hasQueuedPredecessors陷阱hasQueuedPredecessors是一个高频面试点也是一个隐蔽的坑。它的返回含义是是否有线程排队早于当前线程。如果队列是空的返回 false如果队列非空且队首不是当前线程返回 true。但要注意它判断的是在当前时刻的队列状态存在竞态窗口。比如线程 A 刚释放锁线程 B 正在入队的过程中hasQueuedPredecessors可能返回 false导致公平锁出现短暂的伪插队。这不是 bug而是并发系统的固有窗口了解这一点能在排查为什么公平锁还会插队的问题时少走弯路。我用这个现象解释过一个问题某同学起了公平锁的ReentrantLock却发现自己一个线程连续两次拿到锁其他线程却长期得不到执行。我说这本质上是公平锁只保证排队顺序不保证不插队窗口加上线程调度顺序不可控看起来不公正是正常的。6.4 死锁排查时jstack里的AQS队列意味着什么线上遇到锁等待超时最常用的排查手段是jstack dump线程快照。AQS 相关的线程状态会显示在java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject或直接显示 parked等待。有一次排查一个服务卡死问题线程快照里几个线程全部停在LockSupport.park而且等待的锁对象是同一个 AQS 子类。我第一反应是死锁。后来看jstack里的持有关系发现根本不是死锁而是某个线程持有锁后去调一个外部接口外部接口超时长达 120 秒导致所有排队线程全部阻塞。这个案例给我的教训是看到 AQS 队列在park不等于死锁更常见的是持锁线程自己有问题。要判断是不是真死锁核心看锁的持有者线程是否在等待另一个锁。6.5 Condition里的signal和signalAll选择用Condition时有个经典问题signal()只唤醒一个等待线程signalAll()唤醒所有。如果业务语义是释放一个资源用signal()即可但如果是状态翻转类事件比如缓冲区由空变非空必须用signalAll()。我踩过的坑是这样的一个生产者消费者模型里两个消费者都在await()生产者放入一个任务后调signal()。结果唤醒的恰好是那个不该消费的类型任务白白等了一个超时周期。换成signalAll()后所有等待线程都醒来检查条件不满足条件的重新挂起虽然多了一些空转但不会漏事件。AQS 在ConditionObject的实现里被signal的节点会从 Condition 等待队列转移到 AQS 同步队列。这个转移过程同样是 CAS 操作可以看成一个队列之间的平移。理解这一点你对 I/O 多路复用里从等待队列到就绪队列的模型也会更有画面感。收尾一页纸记住AQS最后分享一个我整理给自己团队的口诀式总结也当作这篇文章的收尾一个状态volatile int state所有同步语义都压在这个 int 上一个队列CLH 变体双向链表排队、取消、唤醒都靠它三个模板方法acquire/release/acquireShared/releaseShared骨架由 AQS 写死五个钩子tryAcquire/tryRelease/tryAcquireShared/tryReleaseShared/isHeldExclusively规则由子类实现两个队列AQS 同步队列和 Condition 等待队列signal做节点平移我在实际项目里最深的体会有两条。一是读 AQS 源码不要从头读到尾先跑通正常获取正常释放的最短路再加并发、加中断、加超时这些分支条件去理解边界。二是 AQS 的设计思路可以反哺业务系统——很多资源配额排队限流的中间件模型本质上就是一套状态变量加等待队列的管理只不过没有 AQS 帮你处理并发细节而已。把 AQS 吃透之后再去读ReentrantReadWriteLock、StampedLock甚至ForkJoinPool里的同步逻辑都会顺畅很多。
阅读完成 · 觉得有帮助?
咨询建站