拓冰建站拓冰建站
首页 / 资讯中心 / 正文

深入解析AQS:Java并发编程核心框架原理与应用实践

1. 项目概述为什么AQS是Java并发编程的“定海神针”如果你写过Java并发程序用过ReentrantLock、Semaphore或者CountDownLatch那你其实已经在和AQS打交道了。AQS全称AbstractQueuedSynchronizer是Java并发包java.util.concurrent.locks里的一个核心基础框架。我第一次深入接触它是因为一个线上死锁问题排查了整整两天最后发现是对锁的获取和释放状态理解有偏差而问题的根源就藏在AQS的实现细节里。从那以后我意识到不理解AQS所谓的“精通Java并发”就像是在沙地上盖楼基础不牢。简单来说AQS是一个用于构建锁和同步器的“脚手架”。它封装了同步状态管理、线程排队、等待与唤醒这些复杂且容易出错的底层机制。ReentrantLock、Semaphore、CountDownLatch甚至ReentrantReadWriteLock这些我们耳熟能详的同步工具其内部的核心同步逻辑都是基于AQS这个“骨架”搭建起来的。你可以把它想象成一个高度抽象、可定制的“同步状态机”模板。我们只需要关注“资源是否可用”这个核心状态并定义“如何获取”和“如何释放”资源的规则AQS就会帮我们自动处理好线程的排队、阻塞和唤醒。为什么需要彻底搞懂它因为它是理解Java并发库高级用法的钥匙。当你明白了ReentrantLock的公平与非公平模式在AQS队列里是如何体现的你就能在写代码时做出更合适的选择当你清楚了Semaphore的“许可证”在AQS里只是一个int类型的状态值你就能更好地理解其资源控制的本质。更重要的是在排查复杂的并发问题时比如线程卡死、性能瓶颈能够深入到AQS的层面去分析队列状态、线程状态往往是定位问题的关键。这不仅仅是面试八股文更是解决实际生产问题的硬核技能。2. AQS核心设计思想与原理拆解要理解AQS不能一上来就钻到代码里而是要先把握住它的顶层设计。它的核心思想非常巧妙可以用一个“银行柜台办理业务”的模型来类比。2.1 状态State与资源模型AQS内部维护了一个核心的volatile int类型的变量叫做state。这个state就是整个同步器的“灵魂”它代表了共享资源的状态。至于这个状态具体表示什么完全由子类来决定这就是“抽象”的含义。在ReentrantLock中state表示锁的重入次数。state 0表示锁空闲state 1表示锁被一个线程持有state 1表示被同一个线程重入了多次。在Semaphore中state表示当前可用的许可证Permit数量。state 0表示没有空闲许可证新来的线程需要等待state 0表示还有许可证可以获取。在CountDownLatch中state表示倒计时的初始计数。线程调用countDown()会使state减1调用await()的线程会等待state变为0。这个设计非常精妙AQS不关心state的具体语义它只提供对state进行原子性读写的getState()、setState()和compareAndSetState()CAS操作方法。具体的资源获取和释放逻辑则交给子类去实现。这完美符合了“模板方法”设计模式。2.2 核心方法模板tryAcquire与tryReleaseAQS定义了获取和释放资源的顶级入口如acquire(int arg)和release(int arg)。但这些方法内部并不会直接操作state而是会调用子类必须实现或可以选择实现的“钩子”方法。protected boolean tryAcquire(int arg)尝试以独占模式获取资源。子类需要实现这个方法定义“在什么条件下算获取成功”。通常这个方法里会检查当前state如果满足条件比如state 0就通过CAS操作将state设置为目标值比如1成功返回true否则返回false。protected boolean tryRelease(int arg)尝试释放独占模式下的资源。子类实现此方法通常是将state进行减少或归零并返回true表示释放成功。对于共享模式如Semaphore、CountDownLatch也有对应的tryAcquireShared和tryReleaseShared方法。这里的关键点在于AQS的acquire()方法会先调用子类的tryAcquire()。如果tryAcquire()成功线程就直接获取资源继续执行非常高效。如果失败AQS才会介入将当前线程包装成一个节点Node加入到等待队列中然后进行复杂的排队、阻塞和唤醒管理。这个“先尝试失败再排队”的流程是理解AQS性能的关键。2.3 等待队列CLH队列变体当线程通过tryAcquire尝试获取资源失败后AQS不会让它“空转”或立即阻塞而是将其放入一个先进先出FIFO的等待队列中。这个队列是AQS的另一个核心组件它是一个双向链表每个节点Node代表一个等待线程。这个队列的设计借鉴了CLH锁队列的思想但做了很多优化。每个Node节点里保存了线程引用、等待状态waitStatus以及前驱prev、后继next指针。waitStatus非常重要它标识了节点的状态比如SIGNAL (-1)表示该节点的后继节点需要被唤醒。当前驱节点释放锁时需要唤醒其后继节点。CANCELLED (1)表示该节点对应的线程已超时或被中断需要从队列中移除。CONDITION (-2)表示该节点在条件队列中与Condition对象相关这是另一个话题。PROPAGATE (-3)用于共享模式下的传播唤醒。一个常见的误解是队列是严格公平的。实际上在非公平锁的实现中如ReentrantLock的非公平模式新来的线程在进入队列前会再次尝试“插队”获取锁调用tryAcquire。如果此时锁恰好被释放它就能抢在队列头部的线程之前获得锁。这种设计虽然牺牲了绝对的公平性但减少了线程挂起和唤醒的开销在大多数场景下能显著提升吞吐量。理解这一点对性能调优至关重要。3. 从零解析AQS工作流程以ReentrantLock为例理论说再多不如看一个完整的流程。我们以最常用的ReentrantLock的非公平锁模式为例拆解一个线程从加锁到解锁AQS内部到底发生了什么。3.1 加锁lock流程深度剖析假设我们有一个ReentrantLock lock new ReentrantLock();线程A调用lock.lock()。首次尝试插队lock()方法会直接调用AQS的acquire(1)。在acquire方法内部首先会调用子类ReentrantLock中的Sync内部类实现的tryAcquire方法。在非公平模式下tryAcquire的逻辑是先读取当前的state。如果state 0锁空闲它会立即尝试用CAS操作将state从0设置为1。如果成功则将当前线程设置为独占锁的拥有者setExclusiveOwnerThread然后lock()方法直接返回线程A成功获取锁继续执行。这个过程完全没有涉及队列操作是最快的路径。如果state ! 0但当前持有锁的线程就是线程A自己重入那么就将state加1然后返回成功。如果以上都不满足tryAcquire返回false。入队与等待由于tryAcquire失败AQS开始执行acquire方法的后续逻辑。首先调用addWaiter(Node.EXCLUSIVE)方法将线程A包装成一个独占模式的Node节点然后通过一个“自旋CAS”的方式将这个新节点安全地添加到等待队列的尾部。如果队列尚未初始化头节点head为空会先创建一个虚拟的哑元节点dummy node作为头节点。自旋、检查与阻塞节点入队后会进入acquireQueued方法。这是一个核心的自旋循环。在循环中它会检查自己的前驱节点是不是头节点head。因为只有头节点的线程有资格尝试获取锁。如果前驱是头节点它会再次调用tryAcquire尝试获取锁。如果成功则将当前节点设置为新的头节点原头节点出队并清空其中的线程引用因为该线程已获得锁然后返回。如果前驱不是头节点或者尝试获取锁再次失败则会调用shouldParkAfterFailedAcquire方法。这个方法会检查前驱节点的waitStatus。如果前驱节点状态是SIGNAL则返回true表示当前线程“可以安心去睡了”因为前驱节点释放锁时会负责唤醒它。如果前驱节点状态不是SIGNAL则通过CAS将其设置为SIGNAL然后返回false进行下一轮循环检查。当shouldParkAfterFailedAcquire返回true后会调用parkAndCheckInterrupt方法底层使用LockSupport.park(this)将当前线程挂起进入WAITING状态。线程A就在这里安静地等待被唤醒。3.2 解锁unlock流程与唤醒链现在持有锁的线程可能是线程A也可能是其他线程执行完了临界区代码调用lock.unlock()。尝试释放unlock()调用AQS的release(1)。release方法首先调用子类实现的tryRelease方法。在ReentrantLock中tryRelease会将state减1。如果减1后state 0完全释放则清空独占线程持有者并返回true否则重入情况返回false。只有完全释放时release方法才会继续执行唤醒逻辑。唤醒后继如果tryRelease返回truerelease方法会检查头节点head。如果头节点不为空且其waitStatus ! 0通常为SIGNAL则调用unparkSuccessor方法。unparkSuccessor会从尾节点向前遍历为了处理可能存在的取消节点找到离头节点最近的、状态正常的非CANCELLED后继节点。找到之后调用LockSupport.unpark(s.thread)唤醒该节点对应的线程。被唤醒线程的后续之前被park挂起的线程比如队列中的第二个节点线程B被唤醒后会从parkAndCheckInterrupt方法中返回然后继续它所在的acquireQueued自旋循环。被唤醒后它再次检查自己的前驱节点是否变成了头节点因为原头节点在释放锁后已出队。如果是它再次调用tryAcquire尝试获取锁。此时锁是空闲的所以这次tryAcquire通常会成功。成功后线程B将自己所在的节点设置为新的头节点然后从lock()方法返回正式获得锁开始执行自己的临界区代码。这个流程的精髓在于线程的阻塞和唤醒是由AQS队列精确管理的避免了忙等待busy-waiting的巨大CPU开销。同时“前驱节点负责唤醒后继节点”的责任链机制以及“只有头节点能尝试获取锁”的规则保证了等待队列的有序性和正确性。4. AQS在JUC同步器中的典型应用理解了AQS的骨架我们再看看几个“血肉”是如何长上去的。这能让你更深刻地体会到AQS的灵活与强大。4.1 ReentrantLock独占锁的典范ReentrantLock是AQS最经典的应用。它内部有两个主要的同步器实现NonfairSync非公平锁和FairSync公平锁它们都继承自Sync而Sync继承自AQS。非公平锁tryAcquire逻辑如前面所述新线程有机会直接“插队”获取锁。其tryAcquire方法的核心代码如下简化final boolean nonfairTryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { // 锁空闲 if (compareAndSetState(0, acquires)) { // 直接CAS抢锁 setExclusiveOwnerThread(current); return true; } } else if (current getExclusiveOwnerThread()) { // 重入 int nextc c acquires; setState(nextc); // 注意这里直接setState因为线程已持有锁不存在竞争 return true; } return false; }注意在重入时它直接使用setState而不是compareAndSetState因为当前线程已经持有锁不存在竞争这是性能上的一个优化点。公平锁tryAcquire公平锁的tryAcquire多了一个检查hasQueuedPredecessors()。这个方法会检查等待队列中是否有比当前线程等待时间更长的线程。如果有即使state 0公平锁也会“礼貌地”返回false让当前线程乖乖去排队。这就是公平性的体现。选择建议在锁竞争不激烈或持有时间非常短的场景非公平锁的吞吐量更高。在需要严格保证线程获取锁的顺序避免饥饿的场景使用公平锁。默认情况下ReentrantLock是非公平的因为性能更好。4.2 Semaphore共享资源的流量控制器Semaphore信号量是共享模式的典型代表。它允许多个线程同时访问资源池。其state表示可用许可证数量。tryAcquireShared尝试获取一个许可证。其逻辑是当前state减去需要的数量acquires通常为1如果结果不小于0则用CAS更新state并返回剩余数量正值表示获取成功且可能还有剩余0表示获取成功但无剩余如果结果为负则返回负值表示失败。protected int tryAcquireShared(int acquires) { for (;;) { // 自旋 int available getState(); int remaining available - acquires; if (remaining 0 || compareAndSetState(available, remaining)) return remaining; // 关键返回剩余量 } }tryReleaseShared释放一个许可证。逻辑是将state增加释放的数量由于可能有多个线程同时释放所以必须用CAS在循环中操作。protected final boolean tryReleaseShared(int releases) { for (;;) { int current getState(); int next current releases; if (next current) // 溢出检查 throw new Error(Maximum permit count exceeded); if (compareAndSetState(current, next)) return true; } }这里有个关键点tryAcquireShared的返回值。在AQS的acquireShared方法中如果tryAcquireShared返回负值表示获取失败线程入队如果返回0或正值表示获取成功。对于Semaphore返回remaining剩余许可证数。当remaining 0时不仅当前线程获取成功AQS还会根据返回值决定是否要“传播”唤醒后续的共享节点这正是共享模式“一呼百应”特性的基础。4.3 CountDownLatch多线程协调的“发令枪”CountDownLatch同样使用共享模式。它的state在构造时被设置为一个正数计数。countDown()操作对应releaseShared(1)每次将state减1。await()操作对应acquireSharedInterruptibly(1)它会尝试获取共享锁而tryAcquireShared的逻辑非常简单只要state 0就返回成功1否则返回失败-1。这意味着在计数未减到0之前所有调用await()的线程都会在tryAcquireShared中失败然后被AQS放入队列并挂起。当最后一个线程调用countDown()将state减为0时tryReleaseShared会返回true触发AQS唤醒队列中所有等待的线程共享模式的传播唤醒于是所有等待的线程同时被释放继续执行。它完美地实现了一个或多个线程等待一组操作完成再继续执行的场景。5. 实战避坑与高级特性探讨理解了原理在实际使用和面试中还有一些深坑和高级话题需要特别注意。5.1 常见问题排查与性能调优死锁与AQS队列死锁通常不是AQS本身造成的而是业务逻辑中锁的顺序问题。但AQS的等待队列状态可以帮助我们诊断。通过jstack或jconsole等工具获取线程dump如果看到大量线程处于WAITING (parking)状态且锁持有者是另一个线程同时等待队列很长这很可能就是锁竞争激烈或发生了死锁。结合ReentrantLock等工具提供的getOwner()和getQueuedThreads()注意是protected方法需要继承或反射调用可以更精确地分析。非公平锁的“线程饥饿”在锁竞争极度激烈且持有时间较长的场景下非公平锁可能导致某些线程长时间无法获取锁饥饿。虽然概率低但在需要绝对公平性的场景如计费、任务调度要警惕。监控队列长度和线程等待时间可以帮助发现这个问题。tryLock()与超时的使用ReentrantLock提供了tryLock()和带超时的tryLock(long time, TimeUnit unit)方法。它们底层调用的是AQS的tryAcquireNanos等方法。强烈建议在可能发生竞争或死锁的地方使用带超时的tryLock而不是无条件的lock()。这能为系统提供一定的自我恢复能力。Lock lock ...; if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 操作共享资源 } finally { lock.unlock(); } } else { // 获取锁失败执行降级或告警逻辑 log.warn(获取锁超时执行备用方案); }Condition对象的使用AQS还提供了一个内部类ConditionObject用于实现更精细的线程等待/通知机制类似于Object.wait()和Object.notify()但更强大、更安全。每个ConditionObject都维护一个独立的条件队列。await()会将当前线程从AQS主队列移到条件队列并释放锁signal()会将条件队列中的一个线程转移到AQS主队列去竞争锁。这在实现“生产者-消费者”等模型时非常有用但要小心使用确保在调用await()和signal()时持有正确的锁。5.2 AQS的扩展与自定义同步器虽然我们很少需要自己从头实现一个AQS同步器但理解如何扩展它能让你对并发控制有更深的认识。假设我们要实现一个“同时最多只有两个线程进入”的闸门TwinsLock。定义资源状态我们设定state的初始值为2表示两个许可。实现tryAcquireShared线程尝试获取时将state减1。如果结果0则获取成功。protected int tryAcquireShared(int reduceCount) { for (;;) { int current getState(); int newCount current - reduceCount; if (newCount 0 || compareAndSetState(current, newCount)) { return newCount; // 成功则返回剩余许可数 } } }实现tryReleaseShared线程释放时将state加1。protected boolean tryReleaseShared(int returnCount) { for (;;) { int current getState(); int newCount current returnCount; if (compareAndSetState(current, newCount)) { return true; } } }封装使用将上述同步器封装到一个TwinsLock类中提供lock()和unlock()方法内部调用acquireShared(1)和releaseShared(1)。通过这个简单的例子你可以看到基于AQS我们只需要关注“资源”的定义和“获取/释放”的规则复杂的线程排队管理全部由AQS这个强大的框架代劳了。6. 源码阅读技巧与学习路径建议最后如果你想真正“搞懂”AQS阅读源码是必经之路。但AQS的源码尤其是acquireQueued、cancelAcquire等方法以晦涩难懂著称。这里分享我个人的阅读心得画图辅助准备纸笔或绘图工具。在阅读acquire、release、enq入队、addWaiter等方法时随时画出队列、节点、指针prevnext和状态waitStatus的变化。图形化能极大帮助理解链表操作和状态流转。使用调试器写一个简单的多线程测试程序使用ReentrantLock。在IDEA或Eclipse中在AQS的关键方法如tryAcquireaddWaiteracquireQueuedunparkSuccessor上设置断点以Debug模式运行。观察每一步执行后state、队列结构、线程状态的变化。这是最直观的学习方式。先抓主干再抠细节不要一开始就陷入shouldParkAfterFailedAcquire中各种waitStatus判断的细节。先理解主流程尝试获取 - 失败入队 - 检查前驱 - 挂起等待 - 被唤醒 - 再次尝试。把这个主干流程刻在脑子里再去研究每个环节的精细处理如取消节点清理、状态位设置。结合Javadoc和经典资料AQS的作者Doug Lea在类注释里写了大量的设计说明这是第一手资料。同时《Java并发编程实战》和《Java并发编程的艺术》等书籍中对AQS都有深入讲解可以对照着看。从使用到实现学习路径应该是先熟练使用ReentrantLock、Semaphore等工具 - 思考它们的行为和特性公平/非公平、重入、共享/独占- 带着问题去阅读AQS源码看这些特性是如何实现的。这样学习更有目的性也更容易记住。理解AQS就像是拿到了Java并发世界的地图。它不会让你立刻成为并发大师但能让你在面对任何基于锁或同步器的并发问题时心里有底知道该从哪里入手分析。它揭示的“状态管理队列化等待”的思想在分布式系统、操作系统调度等更广阔的领域也同样闪耀着智慧的光芒。下次当你使用lock.lock()时不妨在脑海里过一遍那个精巧的CLH队列和状态流转图这或许就是程序员的一种浪漫吧。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门