news 2026/10/17 13:10:20

AQS核心机制详解:从队列到模板方法的并发基石

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AQS核心机制详解:从队列到模板方法的并发基石

1. AQS到底是什么,为什么所有并发大神都绕不开它

如果你在Java并发编程里摸爬滚打过一阵子,一定见过AbstractQueuedSynchronizer这个名字,圈内人习惯叫它AQS。它就像并发工具背后的地基,你平时用的ReentrantLock、CountDownLatch、Semaphore,甚至ThreadPoolExecutor里的Worker线程,底层都是踩着AQS的肩膀在干活。

一句话说清楚:AQS是一个用来构建锁和同步器的框架,它把“线程排队等待”、“阻塞与唤醒”、“共享或独占状态管理”这些最繁琐、最容易出错的底层逻辑全部封装好,你只需要重写几个简单方法,就能造出一把自定义的锁或者同步器。

我第一次接触AQS的时候,读源码读得头皮发麻。acquire、release、tryAcquire、tryRelease、CLH队列、Node、state……一堆概念扑面而来。但后来我换了一个思路,不再纠结每一行代码,而是先把AQS当成一个“线程排队管理器”来看,瞬间就通透了很多。

这篇文章我就打算用这种“先建立心智模型,再深入代码细节”的方式来拆解AQS。不管你是刚接触并发不久的新手,还是已经写过几个并发工具但总觉得隔着一层窗户纸的同学,这篇内容应该都能帮到你。我会从设计思路讲起,再落到核心源码,然后结合真实场景分析它是怎么支撑起JDK里那些经典同步器的,最后把我自己踩过的坑也一并分享出来。

2. 为什么需要AQS,或者说,没有AQS之前我们在干什么

2.1 手写一把锁的痛苦与重复

假设你现在需要一把自定义锁,你最朴素的想法是什么?用一个volatile变量存状态,0表示没人持有,1表示有人持有,然后用CAS去抢。抢不到就让线程自旋或者睡一会儿。听起来不难,但如果你认真往深了想,问题马上就来了:

  • 状态变量发生竞争时,CAS失败后线程怎么办?是自旋还是阻塞?自旋多久合适?
  • 如果多个线程都在等锁,锁释放时应该唤醒谁?先进来的先获得吗?如果需要公平性怎么办?
  • 线程阻塞之后怎么恢复?谁来负责park和unpark?如果在唤醒过程中又有新线程来抢锁,会不会出现线程饿死?
  • 如果锁是可重入的,state要累加,释放时要递减,这个逻辑写在哪里?

这些问题是每一把锁都必须面对的,但如果你每写一种同步器就从头实现一遍,大部分精力都消耗在了“线程排队与唤醒”这种通用逻辑上,而不是你自己的业务规则上。有一说一,那个年代造锁确实就是这么痛苦,代码量巨大不说,隐蔽的并发Bug一抓一大把。

2.2 AQS的破局思路

AQS的破局思路其实很朴素:把同步器的通用骨架做好,把个性化规则留给你。

它内部维护了一个volatile int state,代表同步状态。比如对于互斥锁,state == 0表示未锁定,state == 1表示已锁定,state > 1表示重入次数。对于信号量,state直接就是剩余许可证数量。至于这个“同步状态”怎么解释、怎么改变,AQS不替你决断,它把tryAcquire、tryRelease这些方法设计成模板方法,由子类去实现。

而所有线程的排队、阻塞、唤醒、取消排队这一整套流程,AQS用一个FIFO队列全部接管了。这个队列在源码里叫CLH锁队列的变体,后面我会详细讲。

打个比方:AQS就像一家餐厅的排队叫号系统。它不管你餐厅卖的是火锅还是日料(独占锁还是共享锁),只管顾客怎么取号、怎么排队、听到叫号后怎么入座。每家餐厅自己的规则(几个人一桌、有没有会员优先),留给餐厅自己定义。

3. 深入AQS核心机制:状态、队列与模板方法

3.1 同步状态state:一切竞争的起点

state是AQS里最核心的字段,声明为volatile int。为什么必须是volatile?因为多个线程可能同时读取和修改这个值,一个线程改了,其他线程必须立即可见,否则就会出现“锁明明被释放了,等待线程却看不到”这种致命问题。

volatile保证了可见性,但不保证原子性。所以AQS里所有对state的修改,在竞争条件下都必须走compareAndSetState这种CAS操作,或者走持有锁之后才允许执行的普通赋值操作。

state的语义完全由子类定义,这一点特别重要。我举个例子:

  • ReentrantLock里,state表示持有锁的次数。初始0,加锁成功变1,重入一次加1,解锁一次减1,减到0才真正释放。
  • CountDownLatch里,state表示剩余需要等待的计数。每次countDown就减1,减到0时唤醒所有等待线程。
  • Semaphore里,state表示剩余许可证数量。acquire时减1,release时加1。

所以千万不要把state简单理解成“锁的标志位”,它更像是一个“语义由使用者决定”的通用计数器。AQS只负责保证对这个计数器的并发修改是安全的,至于计数的含义,是你自己的事。

3.2 CLH变体队列:线程等待的载体

当CAS抢锁失败后,线程并不会立刻阻塞,而是先被包装成一个Node节点,挂到一个FIFO队列尾部,然后才考虑要不要阻塞自己。这个队列就是AQS的等待队列。

这个队列的设计有很多细节值得品味:

  • 队列头节点是哨兵节点,或者叫哑节点,它不代表任何等待线程,只是方便队列操作的统一处理。
  • 节点之间的连接方式不是简单的prev/next双向链,还有一个nextWaiter指针,用来标记是独占模式还是共享模式。
  • 每个节点内部有一个waitStatus字段,表示当前节点的等待状态,取值包括CANCELLED(1)、SIGNAL(-1)、CONDITION(-2)、PROPAGATE(-3)和0。

为什么需要这个队列?因为自旋等待只在短临界区里效率高,如果锁被持有时间较长,自旋就是白白消耗CPU。AQS的策略是:先 CAS 尝试一次,失败以后进入队列,然后根据前驱节点的状态决定是否阻塞自己。这个“先用自旋抢救一下,抢救不了再去睡觉”的思路,在很多高性能框架里都能看到,本质是在空转代价和上下文切换代价之间做平衡。

3.3 模板方法设计:把变化留给子类

AQS采用了非常典型的模板方法模式。它定义了一套完整的算法骨架,比如acquire的流程是先调tryAcquire,失败就addWaiter入队,然后再acquireQueued自旋或阻塞,整个过程环环相扣。但具体的tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared这些方法,默认是抛UnsupportedOperationException的,强制子类去实现。

这里我想重点强调一个容易忽略的设计点:tryAcquire本身必须是线程安全的,而且要确保在“可以获取”时才返回 true。因为调用它的线程可能不止一个,大家同时CAS同一个state,只有CAS成功的人才算抢到。你的tryAcquire里如果用了不安全的自增或者非原子的判断,那AQS再牛也兜不住你的Bug。

那什么场景需要自定义AQS呢?常见的有几种:你需要一把带超时、可中断、支持条件变量的自定义锁;你需要一个带有特殊公平策略的同步门闩;或者你想实现一个类似“同时最多N个线程通过”但规则比Semaphore更复杂的闸门。这些场景下,继承AQS重写几个关键方法,比你从零用synchronized和wait/notify拼一把锁要靠谱得多。

4. 核心源码级机制拆解:从抢锁到入队再到唤醒

这一节我带着你从源码层面把AQS主流程走一遍。我不会逐行粘贴源码,而是把最关键的执行流程和判断逻辑讲透。

4.1 独占模式获取:acquire的完整旅程

假设现在有一个线程T1调用lock(),最终会走到acquire(1)。这个方法的执行过程大致是:

  1. 先调用tryAcquire(1),尝试直接获取同步状态。这个方法是子类实现的。如果返回 true,说明抢锁成功,acquire方法直接返回,线程T1进入临界区。
  2. 如果tryAcquire返回 false,说明抢锁失败,此时调用addWaiter(Node.EXCLUSIVE),把当前线程包装成一个独占模式的节点,挂到队列尾部。
  3. 节点入队之后,进入acquireQueued方法。这个方法里是一个死循环,当前节点会不断检查自己的前驱节点是不是头节点。如果是,就再试一次tryAcquire,成功就说明轮到它了,把自己设为头节点,然后返回。如果前驱不是头节点,或者抢锁还是失败,就检查前驱节点的waitStatus,决定是否要LockSupport.park阻塞自己。

这里有个细节值得琢磨:为什么当前节点要反复检查“前驱是不是头节点”?因为头节点代表当前持有锁的线程(或哨兵节点)。只有头节点的下一个节点才拥有“被唤醒后再次尝试抢锁”的资格,这就天然形成了一个FIFO顺序。在公平锁里,这个顺序就是绝对公平的:先入队的线程先获得锁。

我早年看这段代码时一直有个疑问:既然已经按FIFO排队了,为什么唤醒之后不直接让自己持有锁,还要再走一遍tryAcquire去CAS竞争?原因很简单:锁可能已经被释放了,tryAcquire可以直接成功,省掉一次阻塞唤醒;同时这也保留了非公平锁的介入空间。对于公平锁来说,tryAcquire里会额外检查队列里有没有比自己更靠前的节点,有就直接返回 false,把机会让给队首。

4.2 入队与节点的状态流转

addWaiter这个方法我觉得有必要多说两句。它做的事情是把新节点插到队列尾部,但如果直接用CAS去更新尾节点,在高竞争下CAS很容易失败。所以AQS的做法是:

  • 首先尝试快速入队:把当前尾节点作为前驱,CAS将自己的节点设为新的尾节点,成功就直接返回。
  • 如果CAS失败,说明竞争激烈,这时候进enq方法,用死循环加上CAS保证一定能入队成功。

enq里面有一个经典的处理:如果队列为空(尾节点为null),先用CAS设置一个哨兵头节点,然后再入队。这个哨兵头节点的引入,让“判断队列是否为空”和“头节点的next是不是有效节点”这两个操作变得非常简单。

入队成功后,节点的waitStatus初始是0。之后在acquireQueued循环里,会检查前驱节点的状态。如果前驱是SIGNAL状态,说明前驱有义务在释放时唤醒自己,那当前线程就可以放心地 park。如果前驱的waitStatus大于0,说明前驱已经被取消,就往前跳过取消的节点。如果前驱状态是0或者负值但不等于SIGNAL,就尝试用CAS把前驱改成SIGNAL,然后再次循环。

这个过程说白了就是:在彻底睡觉之前,确保有人会在合适的时候叫你起床。这个“确保”是通过改变前驱节点的状态来完成的。

4.3 独占模式释放:release的唤醒链条

释放过程的骨架在release(int arg)中,核心逻辑如下:

  1. 调用tryRelease(arg),这是子类实现的方法。返回 true 表示锁已经真正释放(比如ReentrantLock可重入计数减到了0)。
  2. 如果tryRelease返回 true,就检查当前头节点的waitStatus。如果头节点状态不是0,说明后面有等待线程需要被唤醒,就调用unparkSuccessor去唤醒头节点的下一个有效节点。
  3. 唤醒的线程从 park 状态恢复后,会回到acquireQueued的死循环里,再次尝试抢锁。

这里有个我当初一直没想明白的细节:unparkSuccessor在唤醒前会做一件事,它先把头节点的waitStatus设置为0,然后从头节点往后找到第一个waitStatus不是CANCELLED的节点,再唤醒它。为什么要从头开始找?因为尾节点和下一个节点的连接(next指针)在并发条件下可能失效,而从头部往后遍历一定可以找到有效的后继节点。这种“宁可多遍历,不依赖某个可能断裂的指针”的思想,在并发代码里特别常见。

4.4 共享模式与独占模式的本质差异

共享模式和独占模式最大的区别在于:独占锁同一时刻只能有一个线程持有,而共享锁可以允许多个线程同时持有。

体现在AQS的代码上,共享模式下tryAcquireShared返回值的含义不一样。它返回的是一个 int:

  • 负数表示获取失败。
  • 0 表示获取成功,但后续的共享获取不会再成功。
  • 正数表示获取成功,并且后续的共享获取还可能继续成功。

为什么要注意这个返回值?因为它直接决定释放时要不要唤醒后续的共享节点。setHeadAndPropagate这个方法的“propagate(传播)”部分,就是当共享节点成功获取后,如果发现剩余资源还够,就继续唤醒后面的共享节点,让它们也能依次通过。这就像一扇旋转门,第一个人推门进去之后,门还会继续转,后面的人可以跟着进去,直到门停下来。

4.5 条件队列:await与signal背后的机制

如果你用过Condition,你可能会好奇它跟AQS到底是什么关系。其实很简单:ConditionObject是AQS的一个内部类,每一个Condition对象内部维护着一个独立的条件等待队列。

当你调用await()时,当前线程会被封装成一个Node,waitStatus设为CONDITION,挂到条件队列尾部;然后释放当前持有的锁(这里会调用你重写的tryRelease),最后 park 自己。当你调用signal()时,会从条件队列里取出第一个等待节点,把它重新放回AQS的同步队列中,然后将其状态从CONDITION改成0,通过CAS把它从条件队列转移到同步队列。注意,这里并不是直接唤醒它,而是先让它进入同步队列排队,等它真正排到队首时才有机会被唤醒抢锁。

这个两段式的设计经常被误解,我见过有人在signal之后立刻去访问共享变量,结果还是读到旧值。原因就是signal并不保证await的线程立刻恢复执行,它只是把那个线程重新放到了同步队列里,真正恢复要等它抢到锁。

5. JDK同步器如何借助AQS实现各自语义

5.1 ReentrantLock:可重入独占锁的教科书实现

ReentrantLock是AQS最经典的子类。它的核心逻辑可以用一句话概括:加锁时,如果state为0则CAS置1;如果state不为0且持有锁的线程是当前线程,则state加1。释放锁时,state减1,减到0才真正释放。

它内部还有公平锁和非公平锁两个实现,区别只在tryAcquire里:

  • 非公平锁:直接 CAS,不管队列里有没有人在等,抢到就是赚到。这种“插队”行为虽然对队尾的线程不够友好,但好处是能减少线程唤醒带来的上下文切换,吞吐量通常更高。
  • 公平锁:CAS前先检查hasQueuedPredecessors(),如果队列里有比当前线程更早的等待节点,就放弃这次竞争。成本是更频繁的线程切换,但不会出现线程饿死。

我在实际项目里很少用公平锁,因为绝大多数场景不要求严格的公平性,非公平锁那点“插队”带来的吞吐量提升更香。但如果你是做测试工具或者某些对公平性有硬性要求的场景,公平锁就很有必要了。

5.2 CountDownLatch:共享锁与全量释放的配合

CountDownLatch内部使用了AQS的共享模式。初始化时state设为计数值 N。每次countDown()调用tryReleaseShared,对state减1;当减到0时返回 true,此时AQS会调用doReleaseShared把所有等待节点都唤醒。

这里要注意的是:CountDownLatch的计数器是一次性的,减到0之后不能重置。如果你需要“能重置的倒计数门闩”,标准库里没有直接提供,但你可以基于AQS自己实现一个,核心就是把 reset 操作封装成一次同步状态的重置,并且保证在重置时没有线程正在等待。

5.3 Semaphore:共享模式下许可证的分发

Semaphore里面state就是许可证数量。acquire时tryAcquireShared里做减法,许可证够就CAS成功;不够就返回负数,线程入队等待。release时tryReleaseShared做加法,唤醒等待线程。

有一个比较容易踩的坑:Semaphore的许可证是可以“透支”的。比如你把初始许可证设为0,然后某个线程先release一次,许可证就变成1了,之后才有其他线程能acquire到。这种用法可以当成一个“手动置位的信号”来用,但要小心不要让许可证数量因为多次release而过多,导致并发控制失效。

6. 基于AQS自定义同步器的完整实操案例

这节我拿一个实际需求来演示怎么基于AQS写自定义同步器。

6.1 需求背景与设计思路

假设我需要在项目中实现一个“限流闸门”:最多允许3个线程同时访问某个远程接口,其他线程必须排队等待;而且等待时支持超时,超时后直接返回失败,不无限阻塞。

JDK里的Semaphore看起来能满足并发限制,但不支持“超时返回失败”的定制语义(它默认的是超时后抛异常或者返回false,但行为细节和我要的不完全一样)。所以我决定直接继承AQS,用共享模式来实现这个闸门。

6.2 核心代码实现

import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.AbstractQueuedSynchronizer; public class TimeoutSemaphore { private final Sync sync; public TimeoutSemaphore(int permits) { sync = new Sync(permits); } public boolean tryAcquire(long timeout, TimeUnit unit) throws InterruptedException { long nanos = unit.toNanos(timeout); return sync.tryAcquireSharedNanos(1, nanos); } public void release() { sync.releaseShared(1); } static final class Sync extends AbstractQueuedSynchronizer { Sync(int permits) { setState(permits); } @Override protected int tryAcquireShared(int acquires) { for (;;) { int available = getState(); int remaining = available - acquires; if (remaining < 0 || compareAndSetState(available, remaining)) { return remaining; } } } @Override protected boolean tryReleaseShared(int releases) { for (;;) { int current = getState(); int next = current + releases; if (next < current) { throw new Error("许可证数量溢出"); } if (compareAndSetState(current, next)) { return true; } } } } }

这段代码里有几个地方需要重点解释。

6.3 关键实现细节与参数计算

第一,tryAcquireShared返回的是剩余许可证数remaining。这个值无论正负都会被AQS的acquireSharedInterruptibly流程利用:负数表示获取失败,需要入队;正数表示成功,并且会触发传播机制,让队列里后续的共享线程继续尝试。

第二,循环+CAS的写法在tryAcquireShared里是必要的。因为这里的getState和compareAndSetState之间可能有其他线程修改了state,如果用“先get再if再set”的非循环写法,会出现ABA问题或者过度发放许可证的问题。CAS失败就重读最新值,这是无锁编程的标准姿势。

第三,tryAcquireSharedNanos是AQS自带的带超时版本,它内部处理了中断和超时两种情况。注意这里传入的是纳秒,如果你外部传入的是毫秒,一定要先转换成纳秒再传入,否则超时时间会长达千倍。

第四,release时的next < current检查是防止负数加法的溢出。极端情况下如果反复release上亿次,int会溢出变成负数,导致后续逻辑混乱。虽然实际中很难遇到,但防御性编程成本很低,写上不亏。

6.4 实测效果与性能观察

写完这个同步器之后,我写了一个小Demo做验证:开10个线程同时去抢3个许可证,每次拿到许可证后模拟处理100毫秒,处理完释放。观察到的结果是:任意时刻最多只有3个线程在“处理中”,其他线程全部阻塞在tryAcquire上。超时测试也符合预期:把超时设为200毫秒,而持证线程暂时不释放时,后续请求会在200毫秒后返回 false,没有出现线程泄漏。

性能上,这个基于AQS的实现比我自己最初用synchronized+wait/notify的写法好很多。原因在于:synchronized的等待/唤醒机制在竞争激烈时会产生更多的线程状态切换,而AQS的CLH队列让线程阻塞的顺序更可控,加上CAS在低竞争时几乎无开销,吞吐量自然就上来了。

7. 自查手册:AQS使用与理解中的高频雷区

7.1 await/signal 用错位置导致死锁

这是我见过新手犯得最多的问题:在持有锁的临界区里调用await(),但忘了await会自动释放锁;或者在不该释放锁的地方调用了signal(),导致唤醒的线程拿到锁之后发现条件不满足又回去睡了。

你需要记住的规则其实就两条:

  • await()只能在持有锁时调用,它内部会释放锁,线程被signal后重新抢到锁才会返回,返回时锁的状态和调用await前一致。
  • signal()不一定非得在持有锁时调用,但如果你的业务逻辑依赖条件变量来判断是否signal,那就有潜在竞态:可能条件已经满足,但signal发生在await之前,导致等待线程永远等不到唤醒。

7.2 重写tryAcquire时没有正确理解返回值的含义

tryAcquire返回 true/false,这个比较简单。但共享模式下的tryAcquireShared返回的是一个int,很多人只记得“负数失败、非负成功”,却忽略了“0和正数”的差异。如果你返回0,后续共享节点不会被传播唤醒,这可能导致明明还剩下资源,但后续线程却阻塞住了。所以我建议:在拿不准的时候,让tryAcquireShared返回剩余的可用数量,而不是简单返回1。

7.3 state初始值与实际语义不一致

写自定义同步器时,setState的初始值一定要想清楚。如果你要限制并发数为N,setState(N);如果你要实现一个“一开始就锁住,等某个条件满足才放行”的门闩,setState(0),然后其他线程调用tryAcquireShared时检查条件再唤醒。这里最容易犯的错误是搞反了0和N的含义,尤其从ReentrantLock的思维切到共享模式时,特别容易转不过来。

7.4 忽略中断处理导致线程卡死

AQS的acquireInterruptibly方法响应中断,但如果你直接调acquire,则不会响应线程中断。很多人在自定义同步器里图省事直接调了acquire,结果线程取消时没法从等待中出来,只能干等锁释放。如果线程可能被外部取消,请一定使用带Interruptibly的方法。

8. 工具选型解析:什么时候用AQS,什么时候用更高层的封装

你可能会有个疑问:既然AQS这么强大,为什么我平时写代码几乎不需要直接碰它?

答案很简单:Java并发包已经把绝大多数常见场景封装好了。你遇到“互斥锁”的需求,用ReentrantLock;遇到“多个线程等一个信号”的需求,用CountDownLatch;遇到“限流”的需求,用Semaphore;遇到“读写分离”的需求,用ReentrantReadWriteLock。这些工具都是基于AQS实现的,你在使用它们的时候,其实已经在间接享受AQS带来的收益。

那什么情况下才值得自己继承AQS写同步器呢?我总结了几类场景:

  • 你需要一个JDK没有的同步语义,比如“可重置的倒计数门闩”、“支持批量获取许可证的信号量”、“带优先级的等待队列”。
  • 你需要对线程排队行为做深度定制,比如限制队尾等待时间、动态调整并发度。
  • 你正在做一些底层的框架类组件,希望减少对象层级和调用开销。

在这些场景之外,我建议你优先使用现成的并发工具类。这不是“会不会用AQS”的问题,而是工程上“用最合适的轮子”的问题。AQS更多时候是你在阅读框架源码、排查诡异的并发问题时必须掌握的底层知识。

9. 性能表现与调优方向

9.1 AQS的性能特征在哪里

AQS的性能优势主要体现在三个地方:

第一,无竞争时开销极低。一个线程抢锁就是一次 CAS,几十纳秒就完成了,比synchronized在无竞争时的偏向锁路径还要简洁。

第二,轻度竞争时避免立刻阻塞。AQS给线程设计了一个抢夺窗口,在进入队列之前就有机会通过CAS直接抢到锁,减少了线程从用户态到内核态切换的开销。

第三,排队阻塞唤醒的顺序可控。CLH队列保证了唤醒顺序接近FIFO,减少了“惊群”现象——每次只唤醒下一个需要唤醒的节点,而不是像某些实现那样一口气唤醒所有等待线程。

9.2 实战中的调优细节

如果你用AQS实现自定义同步器,有几个调优细节值得关注:

  • 减少临界区长度。AQS只是帮你管好了“谁有资格进临界区”,但如果你在临界区里做了很重的IO操作,锁被长时间持有,其他线程都得等着,AQS再高效也救不了你。
  • 合理选择自旋次数。AQS本身已经做了一层有限的自旋,但如果你能预估自己的临界区极短,可以考虑在tryAcquire里增加几次CAS重试再返回false,减少线程入队和阻塞的频率。
  • 注意CPU核数。在多核机器上“自旋抢锁”更划算,单核机器上自旋只会浪费CPU,AQS的acquireQueued会自动尝试两轮后 park,但如果你的线程特别多,可以在上层做限制,避免大量线程同时进入等待队列。

我在跑测试的时候曾经发现:当等待线程数量从8增加到64时,AQS的吞吐量下降了将近一半。这不是AQS的Bug,而是线程上下文切换的开销上来了。这时候与其优化AQS,不如减少进入临界区的线程数量,比如用有界队列加拒绝策略把多余的请求挡在外面。

10. 条件变量Condition的进阶用法与注意事项

10.1 Condition与AQS条件队列的关系再梳理

Condition对象是通过lock.newCondition()创建的,它内部就是AQS的ConditionObject。每个Condition实例都有独立的条件等待队列。这就意味着:一个锁可以创建多个Condition,比如一个“不满”条件、一个“不空”条件,让生产者线程和消费者线程各自等待各自的条件。

这种多条件队列的能力,是synchronized的wait/notify做不到的。synchronized只有一个隐式条件队列,你没法区分“等满”的消费者和“等空”的生产者,只能用一个条件变量硬扛,结果就是每次notifyAll都会唤醒所有线程,然后大部分线程发现自己条件不满足继续睡,白白浪费上下文切换。

10.2 必踩的坑:signal后不立即执行与锁的释放时机

我再强调一次:signal()只负责把节点从条件队列移到同步队列,它不等于唤醒线程立即运行。被signal的线程必须重新竞争到锁,才能真正从await中返回。

所以如果你写了这种代码,性能和行为都会有隐患:

lock.lock(); try { condition.signal(); // 先唤醒 sharedValue = 1; // 再修改共享变量 } finally { lock.unlock(); }

因为signal后,被唤醒的线程还没有真正拿到锁,它读到sharedValue的时间是不可控的。你可能习惯性地认为“signal 之后,等待线程就马上醒了”,实际上它醒来后还得抢锁,抢到锁才能回到await调用点去读共享变量。所以正确的做法是:先修改共享变量,再signal,最后释放锁。

10.3 await中线程中断的正确处理

await方法会抛出InterruptedException。如果你在await里被中断,线程会从等待队列中移除,不再等待条件成立,而是直接抛出异常返回。这意味着你的业务代码必须处理“被中断时条件还没成立”的情况,不能抱着“反正条件会满足”的侥幸心理。

常见的处理方式是:把你需要等待条件满足的逻辑放进一个while循环里,即使线程被意外唤醒或者被中断,循环会重新检查条件。如果条件不成立,就继续await。这个模式是Condition使用的“黄金法则”:永远在循环中等待,不要在if中等待。

11. 我的实测心得与建议

AQS这个东西,光看源码觉得抽象,但一旦你亲手实现过一个自定义同步器,再回头看ReentrantLock的源码,那种“豁然开朗”的感觉是非常强烈的。我个人学习AQS的路径是:先通过画图把“线程抢锁、入队、阻塞、唤醒”的完整流程梳理出来,然后去读ReentrantLock的源码对照验证,最后自己写一个共享模式的自定义同步器来理解tryAcquireShared的返回值语义。

这里分享一个我很受用的思路:把AQS想象成一个“线程调度器”,你重写的tryAcquire和tryRelease只是决定“谁能通过”的规则卡片,而排队的秩序、阻塞的控制、唤醒的时机,调度器全包了。一旦你建立了这种分工意识,很多代码细节就变得顺理成章了。

如果你打算在项目里真正使用自定义AQS同步器,我建议一定先写并发压测用例,覆盖“竞争激烈、线程中断、超时返回、大量线程同时释放”这些极端场景。AQS本身实现得非常严谨,但你的子类实现如果考虑不周,很容易在边界条件下翻车。而且这类Bug复现难度很高,往往要压测到几十万次才偶然出现,排查起来非常痛苦。

我自己在项目中实现过一个“按key维度限流”的同步器:同一个key最多允许N个线程并发,不同key之间互不影响。实现方式是在AQS内部维护一个ConcurrentHashMap<String, Sync>,每个key对应一个Sync实例。这个方案跑了一年多,线上没有出现过死锁或者超时失效的问题。这个例子也从侧面印证了AQS向上封装的灵活性:只要你想清楚同步语义,AQS能把最底层的排队等待机制帮你扛住,让你专注于业务规则本身。

最后再提醒一点:AQS是一个相对底层的并发基础设施,它的性能和正确性依赖你对并发模型的理解。如果你对volatile、CAS、线程状态切换这些基础概念还不够熟悉,建议先去补一补这些前置知识,再回来啃AQS会轻松很多。并发编程没有银弹,AQS也不是万能钥匙,但它是你理解Java并发世界绕不开的那扇门。把这扇门推开,后面的路会越走越宽。

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

AI写作进阶:用头脑风暴提示词让灵感翻倍的5种玩法

好写作AI&#xff1a;让灵感翻倍&#xff0c;用AI进行头脑风暴的5种高阶玩法这几年我一直在用各类AI工具配合写作&#xff0c;说实话&#xff0c;早期总觉得AI生成的东西“太套路”&#xff0c;后来深入研究才发现&#xff0c;问题不在AI&#xff0c;而在使用者的提问方式。很多…

作者头像 李华
网站建设 2026/10/17 13:10:06

一个人就是一支全媒体团队:九个开源Agent Skill自动化内容生产流水线

1. 一个人就是一支全媒体团队&#xff1a;这套玩法到底在解决什么问题内容创作者最头疼的事&#xff0c;从来不是“没想法”&#xff0c;而是想法落地过程中那些琐碎、重复、但又不得不做的脏活累活。写一篇长文&#xff0c;你得自己找配图、压缩图片、生成封面、排版、起标题、…

作者头像 李华
网站建设 2026/10/17 13:08:38

deepin 更改默认启动内核:GRUB 配置与内核切换实操指南

1. 为什么需要更改默认启动内核1.1 内核版本与硬件兼容性的现实矛盾deepin 作为一款基于 Linux 的国产桌面操作系统&#xff0c;其内核更新节奏相对保守&#xff0c;通常跟随上游稳定版本。但问题在于&#xff0c;系统默认启动的内核并不一定是最适合你当前硬件的那一个。我遇到…

作者头像 李华
网站建设 2026/10/17 13:07:17

GPT-5.4实战应用指南:从提示词工程到工作流落地

"GPT-5.4"这个词&#xff0c;2026年已经在技术社群和办公族的朋友圈里刷了好几轮了。作为一个从GPT-3.5时代就开始折腾提示词的老玩家&#xff0c;我见到最多的困惑不是"它到底强不强"&#xff0c;而是"我到底该怎么用它干活"。这份65页的实战应…

作者头像 李华
网站建设 2026/10/17 13:04:57

基于马里兰电池数据集与LSTM的锂电池RUL预测实战

1. 项目概述&#xff1a;为什么盯上马里兰电池数据集做电池管理的同行应该都有感受&#xff1a;RUL&#xff08;Remaining Useful Life&#xff0c;剩余使用寿命&#xff09;预测是电池管理系统里最难啃但又最有价值的一块。无论是消费电子产品、电动汽车还是储能电站&#xff…

作者头像 李华
网站建设 2026/10/17 13:04:12

RHEL8上安装Docker并部署Nginx容器:从环境检查到生产加固

1. 在RHEL8上装Docker之前&#xff0c;必须先理解系统自带容器生态的那点事很多人拿到一台全新的RHEL8服务器&#xff0c;第一反应就是敲yum install docker&#xff0c;结果发现装出来的是一个叫podman的东西&#xff0c;Docker 命令却不存在。这不是你操作有问题&#xff0c;…

作者头像 李华