FEATURED · 精选文章

深入理解Java并发基石AQS:从ReentrantLock源码到Condition实现

发布时间 / 2026/9/9 16:59:27
来源 / 创域科博编辑部
栏目 / 资讯中心
深入理解Java并发基石AQS:从ReentrantLock源码到Condition实现 1. 从一次线上锁等待说起如果你写过一段时间的Java并发代码一定见过这样的用法ReentrantLock lock new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }这行代码看起来平平无奇但真正深入进去你会发现ReentrantLock背后站着一个极其精巧的并发框架——AbstractQueuedSynchronizer也就是大家常说的AQS。可以这么说理解了AQS你就理解了Java并发编程的半壁江山。AQS是整个java.util.concurrent包的基石ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock、ThreadPoolExecutor里的Worker底层全部依赖AQS来管理同步状态和线程阻塞唤醒。它的核心价值就一句话用一套通用的模板实现了多个线程争抢一个资源的完整管理流程包括谁来抢、抢不到怎么办、排好队之后怎么叫醒、怎么处理中断和超时。这篇文章我就从ReentrantLock这个最经典的实现切入把AQS的加锁、排队、唤醒、条件变量这几个核心机制彻底掰开揉碎配合源码逐行分析最后再聊几个实际开发中经常踩的坑。不管你是面试前突击还是想真正把并发编程吃透这篇都值得花二十分钟细读。2. AQS的核心设计思路2.1 为什么需要AQS它到底解决了什么问题要理解AQS得先想明白一件事实现一把线程安全的锁难点在哪里最朴素的锁实现就是CAS自旋。一个共享变量谁CAS成功谁持有锁失败就继续自旋重试。这在低并发下没问题但一旦竞争激烈所有线程都在循环空转CPU被白白烧掉锁的公平性也无法保证。更关键的是它缺少一个组织机制——失败线程是立即重试还是阻塞等待等待的线程之间按什么顺序醒来如果锁被持有着释放了应该唤醒谁AQS就是这套组织机制的标准答案。它用一个volatile的int变量表示同步状态用一个CLH变体队列管理所有等待线程把获取锁失败后的排队、阻塞、唤醒这套复杂逻辑全部沉淀为模板方法。具体到每个锁只需要实现几个钩子方法决定什么条件下允许抢锁和持有锁之后支持哪些扩展行为。提示这里说的CLH队列全称是Craig、Landin和Hagersten三位大佬提出的自旋锁队列。AQS借鉴了它的思想但做了两个关键改造——把自旋改成了阻塞LockSupport.park把单向链表改成了双向链表。原因后面展开讲。2.2 AQS的两大核心组成状态位与等待队列AQS内部其实就两大块一块是“数据”一块是“结构”。数据是那个volatile int state它代表共享资源的同步状态。对于ReentrantLock来说state的值就是当前线程重入锁的次数——0表示无锁1表示锁被某个线程第一次持有2表示持有者又重入了一次以此类推。对于Semaphorestate代表剩余的许可数对于CountDownLatchstate代表还没countDown的计数。结构是一个双向链表队列每个节点Node封装了一个等待中的线程以及它的等待状态waitStatus。所有没抢到锁的线程都会以节点的形式进入这个队列尾部挂起等待。这两个组件之间的关系可以用一个生活场景类比想象银行只有一个柜台state0表示柜台空闲来了一个客户A成功坐上柜台开始办业务state变成1表示锁定。这时客户B、C、D陆续来了发现柜台被占于是到大厅的等候区排队进入CLH队列。A办完业务起身state变回0柜员叫下一个号——如果等待队列非空会唤醒队列头部的线程。![AQS核心结构示意](这里用文字描述state状态位 双向链表队列 队头队尾指针head/tail每个节点包含线程引用和waitStatus)对应到源码里AQS的字段极其精简private volatile int state; // 同步状态 private transient volatile Node head; // 队头 private transient volatile Node tail; // 队尾状态位用volatile修饰队列的入队和出队通过CAS操作保证线程安全。整个设计非常干净。2.3 模板方法模式AQS是一副半成品棋盘AQS用的设计模式是模板方法模式这是理解它整个代码结构的最关键一环。AQS把抢锁失败之后怎么办的完整流程写死在acquire、acquireShared、release、releaseShared这几个方法里这套流程对所有锁都是一样的尝试获取同步状态失败则构造Node节点CAS入队入队后检查前驱节点如果前驱是头节点则再试一次抢锁抢不到就park阻塞自己被唤醒后重复检查直到成功获取锁而个性化的部分AQS只留了几个钩子方法让子类去实现protected boolean tryAcquire(int arg) // 独占方式尝试获取锁 protected boolean tryRelease(int arg) // 独占方式尝试释放锁 protected int tryAcquireShared(int arg) // 共享方式尝试获取锁 protected boolean tryReleaseShared(int arg) // 共享方式尝试释放锁 protected boolean isHeldExclusively() // 当前线程是否独占锁看到没AQS本身不关心你这是重入锁还是信号量它只负责抢不到就排队释放了就通知。至于怎么算抢到、能不能重入完全由子类在tryAcquire里自定义。这就是AQS最有价值的设计——把并发控制中千篇一律的排队调度抽离出来留出几个口子供不同的同步器自由发挥。ReentrantLock就是在tryAcquire里实现了重入逻辑和公平性判断这个后面的章节会详细拆。3. ReentrantLock加锁流程深度拆解3.1 非公平锁的lock方法先抢一把再说我们平时用的new ReentrantLock()默认构造的是非公平锁。调用lock()时实际走的是NonfairSync.lock()final void lock() { // 第一步直接CAS尝试把state从0改成1 if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); // 成功则设置独占线程 else acquire(1); // 失败则进入AQS的完整获取流程 }这里就体现出非公平锁的精髓了新来的线程不管队列里有没有人在排队先直接CAS抢一次抢到了就插队成功完全无视那些已经在排队等了好久的线程。如果没抢到才会老老实实走AQS的acquire流程去排队。你可能会问为什么要这样设计直接一个个排队不好吗答案是性能。非公平锁的优点是如果线程恰好是在锁释放的那个时间点到达它可以立刻拿到锁省掉了线程阻塞和唤醒的开销这在短临界区高并发场景下吞吐量明显更高。代价是公平性受损——极端情况下某些线程可能饥饿不过ReentrantLock的非公平策略实际触发饥饿的概率非常低。acquire(1)是AQS的模板方法我们再看它public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }短短三行却包含了整个获取锁的核心流程先调子类的tryAcquire再试一次失败就addWaiter入队然后在acquireQueued里循环阻塞唤醒。注意这里tryAcquire被调用了两次一次在lock()里通过CAS尝试一次在acquire里通过子类实现尝试。这样设计的目的是在不同层面提供机会增加获取成功的概率。3.2 tryAcquire重入逻辑在源码层面长什么样NonfairSync的tryAcquire直接调用了父类Sync里的nonfairTryAcquire这是重入锁最核心的一块final boolean nonfairTryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { if (compareAndSetState(0, acquires)) { // state为0说明无锁CAS抢锁 setExclusiveOwnerThread(current); return true; } } else if (current getExclusiveOwnerThread()) { // state不为0且当前线程就是持锁线程说明是重入 int nextc c acquires; if (nextc 0) // overflow判断防止重入次数溢出 throw new Error(Maximum lock count exceeded); setState(nextc); return true; } return false; }分两种情况看第一种state 0说明锁没有被任何人持有。这时候走CAS竞争把state从0改成1成功则设置独占线程为当前线程返回true。CAS保证了这一步的原子性。第二种state ! 0但持有锁的正好是当前线程。这就是重入的场景——同一个线程再次调用lock()直接把state加1不需要CAS因为持锁线程不可能被其他线程修改state同时要考虑重入次数上限防止int溢出。如果两种情况都不满足说明锁被别人持有着返回false进入排队逻辑。这里有个非常容易忽略的细节重入次数记录在state里。所以unlock()必须调用对应的次数才能完全释放锁这也是为什么lock()和unlock()必须成对出现而且unlock()要放在finally块里。一旦少unlock一次state就永远大于0锁就永远释放不掉了队列里的所有线程全部永久阻塞。3.3 addWaiter和acquireQueued排队与阻塞的核心机制tryAcquire失败后线程需要进入队列等待。addWaiter(Node.EXCLUSIVE, arg)负责把当前线程封装成Node节点并加入队列尾部private Node addWaiter(Node mode) { Node node new Node(Thread.currentThread(), mode); // 当前线程包装成Node Node pred tail; if (pred ! null) { node.prev pred; // CAS将node设置为新的tail失败则进入enq自旋 if (compareAndSetTail(pred, node)) { pred.next node; return node; } } enq(node); // 自旋入队处理队列为空或CAS失败的情况 return node; }注意入队时用的CAS操作compareAndSetTail(pred, node)它的作用是只有当tail还是我们刚才读到的pred时才把tail更新为node。并发入队时多个线程同时执行这段代码只有一个能CAS成功失败的进入enq自旋重试。enq的逻辑是经典的CAS自旋模式private Node enq(final Node node) { for (;;) { Node t tail; if (t null) { // 队列为空需要初始化head if (compareAndSetHead(new Node())) // 设置一个空的哨兵头节点 tail head; } else { node.prev t; if (compareAndSetTail(t, node)) { t.next node; return t; } } } }这里有个细节队列初始化时AQS会先创建一个不包含线程的“哨兵节点”作为head再让第一个等待线程排在它的后面。为什么非要有一个空头节点因为CLH队列的设计中head代表“当前持有锁的线程”它在释放锁时需要判断后继节点是否可以被唤醒。如果head直接指向获取锁的线程那么head永远在被持有锁的过程中变化不利于队列的稳定性。用一个空的哨兵节点让head始终指向“上一个已经获取锁的线程”出队逻辑会更简单。入队之后进入最核心的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)) { // 前驱是head说明当前节点排在队首再试一次抢锁 setHead(node); // 抢锁成功把自己设为新的head p.next null; // 清除旧head的引用帮助GC failed false; return interrupted; } // 前驱不是head或者抢锁失败则检查和阻塞 if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) interrupted true; } } finally { if (failed) cancelAcquire(node); // 异常情况如抛出异常取消排队 } }这段代码是整个AQS的灵魂。理解它需要抓住三个要点。第一为什么只检查前驱是不是head这是CLH队列的设计核心。只有head节点的后继节点才有资格竞争锁。如果前驱不是head说明自己前面还有人在等那就不去抢了直接准备阻塞。这保证了锁分配的FIFO顺序在队列内的公平性。第二什么时候会再次抢锁两种情况一是刚入队时检查如果自己刚好排到队首二是被唤醒之后重新走循环再次检查自己的前驱是不是head。注意p head tryAcquire(arg)是一个“先确认资格再抢”的组合判断。第三阻塞时机。shouldParkAfterFailedAcquire会检查前驱节点的waitStatus。如果是SIGNAL表示前驱节点释放锁时要唤醒后继则返回true当前线程可以放心阻塞如果前驱状态是0初始状态则通过CAS把它改成SIGNAL然后外层循环再走一轮目的是给前驱节点“交代好后事”确保阻塞后一定有人会唤醒自己。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 { // waitStatus为0或PROPAGATECAS设置为SIGNAL compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }最后真正阻塞线程的是parkAndCheckInterrupt就这么简短private final boolean parkAndCheckInterrupt() { LockSupport.park(this); // 阻塞当前线程 return Thread.interrupted(); // 被唤醒后返回中断状态 }是的底层就是LockSupport.park()。这个来自sun.misc.Unsafe的本地方法最终调用操作系统层面的线程挂起原语在Linux上是pthread_cond_wait在Windows上是WaitForSingleObject等。我画一个简化版的加锁流程图帮助你理清整体逻辑lock() 调用 ├─ CAS尝试直接获取锁非公平 │ ├─ 成功 → 持有锁流程结束 │ └─ 失败 ↓ └─ acquire(1) ├─ tryAcquire再次尝试检查状态、重入判断 │ ├─ 成功 → 持有锁流程结束 │ └─ 失败 ↓ ├─ addWaiter将线程包装成Node加入队尾 ├─ acquireQueued循环检查 │ ├─ 前驱是head且tryAcquire成功 → 获取锁设为新head │ └─ 否则 → shouldParkAfterFailedAcquire │ ├─ 前驱状态为SIGNAL → park阻塞等待唤醒 │ └─ 设置前驱为SIGNAL后重试 └─ 被唤醒后继续循环直到成功获取锁整体逻辑非常清晰先试着抢抢不到排队排到队首再抢抢不到就睡被叫醒继续抢。一个线程从“尝试”到“阻塞”最坏情况下只经历两次tryAcquire和一次park效率相当高。4. 释放锁与队列唤醒机制4.1 unlock的完整链路从state到后继线程释放锁的入口是lock.unlock()它调用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; }release做了两件事先调tryRelease把state减到0然后如果队头和队头状态满足条件就唤醒队列里最前面的等待线程。注意这里的一个细节release并不关心队列是否为空它只关心head的状态。如果head为null说明从未有过竞争不需要唤醒如果head不为null但waitStatus为0说明没有后继需要唤醒waitStatus什么时候会是0节点刚入队时初始状态是0而且唤醒了后继线程后会把waitStatus恢复为0。tryRelease在ReentrantLock里的实现如下protected final boolean tryRelease(int releases) { int c getState() - releases; // state减去释放的次数 if (Thread.currentThread() ! getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free false; if (c 0) { // state归零表示完全释放 free true; setExclusiveOwnerThread(null); } setState(c); return free; }这里有一个非常严肃的检查Thread.currentThread() ! getExclusiveOwnerThread()。释放锁时如果当前线程不是持锁线程会直接抛出IllegalMonitorStateException。这个异常很多人遇到过典型的触发场景就是A线程加锁B线程调用unlock。这在代码review时是必须揪出来的bug。重入锁的释放和获取逻辑一一对应。每调一次unlock()state减1只有减到0才真正释放锁唤醒等待队列里的线程。这同时也意味着如果写代码时lock和unlock没有成对出现锁永远不会真正释放等待队列里的其他线程会永久阻塞。4.2 unparkSuccessor如何选择下一个唤醒的线程唤醒后继线程的代码是unparkSuccessorprivate void unparkSuccessor(Node node) { int ws node.waitStatus; if (ws 0) compareAndSetWaitStatus(node, ws, 0); // 将状态重置为0 Node s node.next; // 后继节点 if (s null || s.waitStatus 0) { // 后继为null或已被取消 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); // 唤醒线程 }这段代码包含一个非常有趣的细节唤醒后继节点时如果后继节点为空或已被取消waitStatus 0AQS不会直接从node.next往后找而是从tail往前遍历找到最前面的有效节点。为什么不直接往后找因为CLH队列的入队操作不是原子的。addWaiter里线程是先设置node.prev pred然后CAS设置tail最后才设置pred.next node。也就是说在CAS成功到pred.next node赋值之间的很短时间里这个节点已经入队了但从旧尾部往后看却是后续为空。如果此时从head往后遍历可能漏掉刚入队的节点。而从前向后走由于prev的赋值发生在入队最前端只要从tail往前一定能找到所有有效的等待节点。这个细节很多讲解AQS的文章不会提但在线上排查线程唤醒问题时理解它非常关键。4.3 持锁线程释放后新锁竞争过程全解析这里有一个容易混乱的地方lock()时是先CAS再排队unlock()是唤醒队首线程。那么问题来了非公平锁下如果持锁线程刚释放锁同时一个新线程在入口CAS而队首线程刚被唤醒谁先拿到锁答案是不确定这正是非公平锁非公平的体现。新线程的CAS和队首线程的tryAcquire是并发的谁先成功谁拿锁。可能出现的情况是队首线程被唤醒后还没来得及抢新线程就已经CAS成功队首线程只能再回到park状态继续等待。这就是“插队”发生的本质。如果用的是公平锁情况完全相反。公平锁的tryAcquire里有明确判断如果同步队列中有等待的线程就直接返回false绝不竞争。这个差异在下一节详细对比。5. ReentrantLock中公平锁与非公平锁的源码级对比5.1 公平锁的实现差异只有一个hasQueuedPredecessorsFairSync的tryAcquire和NonfairSync的版本非常相似唯一的差异在于c 0时抢锁之前多了一个判断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; }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说明队列为空或者队列只有一个节点此时head和tail指向同一个哨兵节点没有人在排队可以抢锁h ! t且h.next null说明队列正处于入队的中间状态——有新节点CAS成功但还没设置next引用必须保守处理返回true有其他线程正在入队当前线程不能抢h ! t且h.next不为空但h.next.thread ! current说明有等待线程排在当前线程前面不能抢。所以这个方法的完整语义是“同步队列中是否存在等待时间比当前线程更长的其他线程”。有就乖乖排队没有才能竞争。这里有一个边界情况值得注意公平锁并不是100%绝对公平。比如队列是空的或者当前线程自己已经排在队首那就可以立即获取锁。这种“有限公平”是合理的因为如果锁是空闲的你还要唤醒一个阻塞线程来拿锁反而增加了不必要的开销。侯捷老师在STL源码剖析里说过“不要为了学术上的优雅牺牲工程上的效率”AQS的公平锁设计深谙此道。5.2 两种锁的取舍与实操选型建议锁类型优点缺点适用场景非公平锁吞吐量高减少了线程唤醒挂起的开销可能出现线程饥饿概率低锁获取顺序不可预期默认选择大多数业务场景公平锁线程获取锁的顺序与调用顺序一致避免饥饿吞吐量低频繁的线程唤醒切换对顺序敏感、追求公平性的场景我个人的实操建议是除非你有非常明确的“必须保证线程按申请顺序获取锁”的业务诉求否则一律使用默认的非公平锁。原因很简单非公平锁在低竞争下几乎无开销在高竞争下吞吐量通常比公平锁高出1-2个数量级取决于临界区长度和数据量。公平锁的代价不只是“多一次判断”而是频繁的线程阻塞唤醒这在锁竞争激烈时是一场灾难。就算在“看起来需要公平”的场景里比如任务调度也建议先想想能不能用信号量、队列等更高层的方式组织任务而不是直接上公平锁。公平锁经常是一种“看起来更稳妥实际更难调优”的方案。6. AQS的等待队列变体Condition的实现原理6.1 Condition与Object.wait/notify的差别理解了AQS的同步队列之后再看ReentrantLock的Condition就轻松多了。Condition是Object.wait/notify的替代品解决的是“锁条件等待”组合使用时的痛点Object.wait必须配合synchronized使用一个锁只能关联一个条件队列而Condition允许一个ReentrantLock上创建多个条件队列提供更精细的等待唤醒控制。用法上非常直观ReentrantLock lock new ReentrantLock(); Condition notEmpty lock.newCondition(); // 生产者 lock.lock(); try { // 更新数据 notEmpty.signal(); // 唤醒一个等待线程 } finally { lock.unlock(); } // 消费者 lock.lock(); try { while (数据为空) { notEmpty.await(); // 等待条件满足 } // 消费数据 } finally { lock.unlock(); }6.2 Condition内部维护的条件队列与信号传递AQS里的ConditionObject内部维护着一个单链表叫做“条件队列”。线程调用await()时会从同步队列中移出加入条件队列并释放锁线程调用signal()时会把条件队列头部的节点转移回同步队列等待重新竞争锁。await()的简化流程public final void await() throws InterruptedException { Node node addConditionWaiter(); // 创建条件队列节点 int savedState fullyRelease(node); // 完全释放锁注意这里保存了重入次数 int interruptMode 0; while (!isOnSyncQueue(node)) { LockSupport.park(this); // 在条件队列中阻塞 // 唤醒后检查中断... } // 重新进入同步队列后继续竞争锁恢复重入次数 if (acquireQueued(node, savedState) interruptMode ! 0) interruptMode ... }signal()的简化流程public final void signal() { if (!isHeldExclusively()) throw new IllegalMonitorStateException(); Node first firstWaiter; // 条件队列头部 if (first ! null) doSignal(first); // 移出条件队列加入同步队列 }这个过程可以用一个简单的状态机理解持锁线程 → await() 1. 释放锁保存重入计数 2. 进入条件队列 3. park挂起 其他线程 → signal() 1. 把条件队列头节点移到同步队列 2. 该节点等待重新竞争锁 竞争到锁 → 继续执行恢复重入计数await()方法里最容易被忽视的是条件的循环判断。也就是我上面写代码时用的while (数据为空)而不是if (数据为空)。为什么必须用while因为signal()只保证“条件可能满足”不代表“条件一定满足”。被唤醒后可能另一个线程抢先消费了数据条件又不满足了。如果用if判断唤醒后直接继续就会产生虚假唤醒或数据竞争。这是多线程条件变量的经典陷阱Go语言、C的condition_variable、Java的Condition全都遵循同一原则必须在循环中检查条件。6.3 使用Condition的四个必知细节用Condition有一堆细节坑我先帮你踩好。第一await()和signal()都必须在持有锁的前提下调用。否则await的fullyRelease会因为没有持锁而抛出IllegalMonitorStateExceptionsignal()的isHeldExclusively()检查也会抛异常。这不是运行时报错那么简单——如果你在设计一个API把这个约束暴露给外部调用者务必在文档中写清楚否则对方踩坑会找你问罪。第二await()会释放锁signal()不会。这个和Object.wait/notify一样。signal只是把线程从条件队列挪到同步队列这个线程还要和其他线程一起竞争锁并不是立即恢复执行。第三await()支持超时和中断。await(long time, TimeUnit unit)会在指定时间后自动返回即使条件没有满足awaitUninterruptibly()则完全忽略中断。选型时要根据场景决定。如果不想让线程无限期等待一定要用带超时参数的版本配合while循环里检查剩余时间是处理复杂生产消费场景的常用做法。第四一个ReentrantLock可以创建多个Condition。比如一个Bounded Buffer有界队列可以用“notFull”和“notEmpty”两个条件分别管理生产者阻塞和消费者阻塞唤醒时精准定向而不需要像synchronized那样用单一锁单一条件广播唤醒所有线程。这也是Condition相比wait/notify最大的优势。7. AQS在ReentrantLock之外的广阔应用7.1 Semaphore、CountDownLatch、ReentrantReadWriteLock的底层逻辑AQS设计为“模板钩子”所以它的能力边界远不止一把可重入锁。理解了state的抽象含义其他同步器就是一通百通。Semaphore信号量state表示剩余许可数量。acquire时通过tryAcquireShared尝试把state减一如果减到负数说明许可不足进入等待队列release时通过tryReleaseShared把state加一然后唤醒等待线程。它用的是AQS的共享模式也就是说多个线程可以同时通过acquire检查只要许可足够。CountDownLatch倒计时门闩state初始值设为N需要等待的线程数。每次countDown()调用tryReleaseShared把state减一直到state变成0所有调用await()的线程被一次性全部唤醒。共享模式下AQS支持“一对多”的唤醒这是独占锁ReentrantLock做不到的。ReentrantReadWriteLock读写锁它在一把锁上同时实现了读锁和写锁两种模式用到AQS的两个不同状态位——高16位表示读锁持有数量低16位表示写锁重入次数。读锁是共享模式多个读线程可以同时持有写锁是独占模式同一时刻只能有一个写线程。源码里有大量位运算来拆分这两个16位数字非常精妙。用一个表格总结这些同步器和AQS核心参数的关系同步器state语义获取模式释放行为ReentrantLock独占锁的重入次数独占tryAcquire每次减1归零才唤醒Semaphore剩余许可数量共享tryAcquireShared每次加1唤醒等待者CountDownLatch剩余未countDown计数共享await减到0时唤醒所有等待ReentrantReadWriteLock高16位读锁数低16位写锁数共享读 独占写分别递减7.2 锁升级、锁粗化与AQS的适用边界弄明白了AQS的原理你自然会想到一个问题既然ReentrantLock这么强大是不是所有并发场景都应该用它也不是。synchronized在JDK 6之后引入了锁升级机制——偏向锁、轻量级锁、重量级锁——在低竞争下synchronized的CAS开销可以和ReentrantLock持平甚至更低而且还支持锁粗化和锁消除两大编译优化。所以能用synchronized的场景比如简单的互斥、单条件等待优先用synchronized代码更简洁也减少误用风险。需要ReentrantLock的典型场景需要尝试非阻塞获取锁tryLock()需要超时获取锁tryLock(timeout, unit)支持中断响应lockInterruptibly()需要多个Condition条件队列需要公平锁这里面最有价值的是tryLock()配合超时时间能在锁竞争激烈时避免无限阻塞。比如分布式任务调度里获取本地锁加上超时时间拿不到就放弃或走降级逻辑这种弹性的并发控制才是生产级代码该有的样子。8. 实战复盘基于AQS的七个高频问题与排查技巧8.1 为什么我的程序卡死了这是一个没有代码细节的笼统问题但多数情况下指向同一类原因死锁或锁未释放。AQS相关的死锁最容易出现在两个地方。第一个是重入锁没成对释放。比如你在方法A里调了lock()方法B又调了一次lock()同一个线程重入但B对应的unlock()漏写了导致state一直是2最终无法归零。排查方法在关键地方打印state值和持锁线程或者用getHoldCount()方法查看当前线程重入次数。第二个是Condition使用不当导致的永久等待。比如生产者调用了signal()却发现消费者没有醒原因是调用signal()的线程没有持有锁或signal的条件写错唤醒的是错误的条件队列。排查方法用jstack查看所有线程的栈信息重点看WATING和TIMED_WAITING状态的线程卡在哪个对象的park方法上。8.2 jstack排查线程状态一眼定位锁问题用jstack抓取线程栈你会看到类似这样的输出pool-1-thread-1 #11 prio5 os_prio0 tid0x... nid0x... waiting on condition [0x...] java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AQS.java:836) at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(AQS.java:870)看到parkAndCheckInterrupt就说明线程卡在AQS的同步队列里等锁看到Condition队列的await相关栈则说明线程在条件等待。结合jstack -l输出的锁信息一般能直接判断出是哪一把锁导致的互相等待。如果有多个线程互相持有对方需要的锁jstack会明确显示Found one Java-level deadlock并列出死锁环。这种定位特别快比盲改代码高效得多。8.3 如何提升锁竞争条件下的整体吞吐如果jstack显示大量线程在acquireQueued里阻塞说明锁竞争过热这时优化的方向不是调AQS参数而是从业务层面减少临界区的持有时间。常用的几个手段缩小锁范围只锁必须保护的代码段用读写锁替换独占锁读多写少时收益巨大用ConcurrentHashMap、LongAdder、AtomicReference等无锁或分段结构代替“大锁保护大Map”用tryLock 快速失败而不是无限阻塞必要时用Semaphore做流量控制避免过多线程同时堆积在锁上排队很多人在锁竞争问题上一上来就想“换并发工具”,结果越换越复杂。其实先做锁粒度分析看看有没有不必要的串行化往往能解决80%的问题。9. AQS源码背后的设计哲学与踩坑清单9.1 为什么AQS里的waitStatus要分这么多种状态AQS节点定义的waitStatus一共有五个取值整理在一起看更清晰状态值数值含义触发时机CANCELLED1节点线程已取消等待tryLock超时、acquire异常中断SIGNAL-1后继节点线程需要被唤醒节点获取锁后释放时CONDITION-2节点在条件队列中await()调用时PROPAGATE-3共享模式下状态需要向后传播releaseShared时00初始状态节点刚创建时这些状态的核心作用是让节点之间能够高效地传递唤醒信号。一个节点在park之前必须确保自己的前驱节点状态是SIGNAL这样当持有锁的线程释放锁、调用unparkSuccessor时能找到正确的后继节点并唤醒。这种“接力棒”式的设计使得唤醒操作是精确的、链式的不会被无关节点干扰。我见过有人试图理解AQS代码时被waitStatus的多种状态绕晕。我的建议是不要一开始就纠结所有状态先从SIGNAL和CANCELLED入手理解“谁负责唤醒谁”的接力机制再去看CONDITION和PROPAGATE它们只是条件队列和共享模式下的扩展状态。9.2 自旋、阻塞与中断AQS的三种等待策略AQS在线程竞争锁时针对不同场景采用了三种不同的等待策略自旋在acquireQueued的for循环里线程会不断尝试获锁这个自旋不是无限循环的每次循环要么尝试获取要么检查是否需要park没有CPU忙等的浪费。阻塞通过LockSupport.park挂起线程这是等待的主要手段。中断响应lockInterruptibly()和acquireInterruptibly在等待过程中能响应中断被LockSupport.park阻塞的线程在收到中断信号后会立刻返回检查中断状态并处理。三种策略的组合使用让AQS既能高效利用CPU在竞争不激烈时快速完成锁获取又能避免浪费在竞争激烈时让线程休眠还能提供灵活的控制能力超时、中断。9.3 写高并发代码时的五个AQS相关避坑建议基于以上原理总结几条实操中非常容易踩的坑第一lock()和unlock()必须成对出现且unlock放入finally块。哪怕中间的业务逻辑抛了异常也不会把锁留在手里导致其他线程全部挂死。第二await()必须在循环中调用绝不能用if。理由前面讲过唤醒之后条件未必满足。第三signal()和signalAll()的选择要谨慎。signal()只唤醒一个线程如果唤醒的是错误的条件等待者可能造成假死signalAll()唤醒所有等待者性能开销大但语义上更安全。生产上我倾向于用signalAll()除非你能明确证明队列里的所有线程都在等待同一个条件。第四不要在持锁的状态下做耗时操作如IO、RPC。锁的持有时间越长队列里阻塞的线程越多系统的吞吐量断崖式下降。这也是AQS队列会堆积的常见原因。第五锁的获取顺序要一致。如果你在多个方法里分别获取多个锁务必保证所有线程按同样的顺序加锁否则很可能形成锁的循环等待。10. 一个完整示例用ReentrantLock和Condition写一个有界阻塞队列理论说了这么多最后用一个有界阻塞队列的完整示例把整个AQS知识串起来。这个例子在面试中也非常常见但很多人只会用synchronizedwait/notify写我们这里用ReentrantLock和两个Condition实现让你直观感受多条件带来的代码简洁性。public class BoundedBlockingQueueT { private final Object[] items; private int head, tail, count; private final ReentrantLock lock new ReentrantLock(); private final Condition notFull lock.newCondition(); private final Condition notEmpty lock.newCondition(); public BoundedBlockingQueue(int capacity) { items new Object[capacity]; } public void put(T item) throws InterruptedException { lock.lockInterruptibly(); try { while (count items.length) { notFull.await(); // 队列满等待消费者取走元素 } items[tail] item; tail (tail 1) % items.length; count; notEmpty.signal(); // 通知等待的消费者 } finally { lock.unlock(); } } SuppressWarnings(unchecked) public T take() throws InterruptedException { lock.lockInterruptibly(); try { while (count 0) { notEmpty.await(); // 队列空等待生产者放入元素 } T item (T) items[head]; items[head] null; // 帮助GC head (head 1) % items.length; count--; notFull.signal(); // 通知等待的生产者 } finally { lock.unlock(); } } }这个实现的核心优势在于生产者和消费者分别等待不同的条件队列唤醒是精准定向的。put操作只唤醒消费者notEmpty.signaltake操作只唤醒生产者notFull.signal没有多余的线程被无谓唤醒。如果用synchronized和单一wait队列每次signal都可能唤醒错误类型的线程造成虚假唤醒带来的无谓竞争。这个例子完整展示了一件事AQS的同步队列负责“锁竞争”Condition的条件队列负责“条件等待”两者通过await和signal机制互相衔接。理解了这两个队列的交互AQS就算是真正吃透了。我在实际使用中一个体会并发编程最大的挑战不是API的记忆而是对谁在等什么、谁会被谁唤醒的清晰理解。当你遇到线程卡死问题时先不要急着改代码用jstack看一下所有线程的状态很多人只需要这一步就能定位到问题所在。AQS虽然复杂但它的核心思想其实很简单——排队睡醒再排队仅此而已。把这一条主线抓住再复杂的并发问题都能一步步拆解清楚。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻