理解 AQS (AbstractQueuedSynchronizer) 是掌握 Java 并发编程的核心。它是 java.util.concurrent 包(JUC)的基石,像 ReentrantLock、Semaphore、CountDownLatch 等工具类,全都是基于它构建的。
简单来说,AQS 是一个用于构建锁和同步器的框架。
1. AQS 的核心三要素
要理解 AQS,只需要盯住这三个核心组件:
深入解析 Java AQS(AbstractQueuedSynchronizer)的核心原理。AQS 是 JUC 并发包的基石,通过 volatile 状态位 state、CAS 操作及双向同步队列(CLH 变体)实现线程同步。文章阐述了独占与共享两种模式,对比了公平锁与非公平锁在抢锁时机上的区别,并详细说明了可重入、中断处理及节点唤醒机制。通过银行柜台与奶茶排队的比喻,帮助读者理解 AQS 如何避免惊群效应并高效管理线程阻塞与唤醒。
理解 AQS (AbstractQueuedSynchronizer) 是掌握 Java 并发编程的核心。它是 java.util.concurrent 包(JUC)的基石,像 ReentrantLock、Semaphore、CountDownLatch 等工具类,全都是基于它构建的。
简单来说,AQS 是一个用于构建锁和同步器的框架。
要理解 AQS,只需要盯住这三个核心组件:
这是一个由 volatile 修饰的 int 变量。它代表了共享资源的状态:
ReentrantLock 中,state = 0 表示锁空闲,state > 0 表示锁被占用(数值代表重入次数)。Semaphore 中,state 代表剩余的许可(Permit)数量。AQS 使用 Compare And Swap (CAS) 指令来原子性地修改 state 的值。只有修改成功的线程才算'抢到了锁'。
如果线程抢锁失败了怎么办?AQS 会把该线程封装成一个 Node 节点,放入一个双向同步队列(CLH 队列的变体)中挂起。当持有锁的线程释放锁时,会唤醒队列中排在最前面的线程。
AQS 设计得非常巧妙,它支持两种资源共享方式:
ReentrantLock。Semaphore(信号量)和 CountDownLatch(倒计时器)。我们可以把 AQS 理解为一个银行柜台业务系统:
state 是 0,表示柜台没人;如果是 1,表示有人在办事。state 变回 0),他会拍拍后面排队的人:'兄弟,该你了。'如果没有 AQS,每个开发者在写 ReentrantLock 或 Semaphore 时,都得亲自去处理:
AQS 像是一个优秀的模版方法。它把排队、阻塞、唤醒这些复杂的逻辑全部封装好了。子类(如 ReentrantLock)只需要实现几个简单的方法(如 tryAcquire)来决定如何修改 state 即可。
// 抢锁逻辑
if (tryAcquire(arg)) {
// 抢到了!直接执行业务
} else {
// 没抢到,入队并挂起线程
addWaiter(Node.EXCLUSIVE);
acquireQueued(node, arg);
}
在 ReentrantLock 中,公平锁(Fair Lock)与非公平锁(Nonfair Lock)的区别,本质上在于**'抢锁的时机'**。
ReentrantLock 内部定义了一个抽象内部类 Sync 继承自 AQS,而公平锁和非公平锁分别是 Sync 的两个具体实现:FairSync 和 NonfairSync。
两者的区别可以用一句话概括:非公平锁允许新来的线程直接尝试'闯入'抢锁,而公平锁强制新来的线程先看有没有人在排队。
非公平锁是 ReentrantLock 的默认实现。它的抢锁逻辑分为两步'插队'尝试:
state(从 0 改到 1)。如果运气好刚好前一个线程释放了锁,它就直接拿锁走人,根本不看队列。nonfairTryAcquire。即使此时队列里有现成的线程在等,它依然会再次尝试 CAS 抢锁。公平锁严格遵守 FIFO(先进先出) 原则。
tryAcquire。但在尝试 CAS 修改 state 之前,它会先调用一个关键方法:hasQueuedPredecessors()。在 AQS 的框架下,两者的代码差异极其微小,仅仅多了一个判断条件:
| 锁类型 | 抢锁核心逻辑(tryAcquire) |
|---|---|
| 非公平锁 | if (compareAndSetState(0, 1))直接抢,不看队列。 |
| 公平锁 | if (!hasQueuedPredecessors() && compareAndSetState(0, 1))先看有没有人排队,没人排队才抢。 |
| 特性 | 公平锁 | 非公平锁 |
|---|---|---|
| 吞吐量 | 较低。频繁地挂起和唤醒线程,上下文切换开销大。 | 较高。新线程可能直接获取锁,利用了 CPU 执行的时间片。 |
| 线程饥饿 | 不会。每个线程最终都能拿到锁。 | 可能。运气差的线程可能一直被新来的'插队者'抢走锁。 |
| 适用场景 | 对任务执行顺序有严格要求。 | 大多数追求高并发性能的场景(默认推荐)。 |
实际上,AQS 内部使用的并不是原始的 CLH 锁,而是 CLH 锁的一种变体(改进版)。CLH 是以其发明者名字(Craig, Landin, and Hagersten)首字母缩写的。
原始的 CLH 锁是一种基于链表的、高性能的自旋锁。
locked 变量。locked 设为 true。locked 是否为 false。false),当前线程就开始执行。原始 CLH 锁有一个致命缺点:它是自旋锁。如果前驱节点迟迟不释放锁,后继线程会一直疯狂循环消耗 CPU。
AQS 为了适配复杂的 Java 业务场景,对它进行了重大改造:
AQS 的节点中增加了一个 waitStatus 变量。如果一个线程发现前驱没释放锁,它不会一直死循环,而是会将自己挂起(LockSupport.park),进入休眠状态以节省 CPU。
原始 CLH 是单向的,但 AQS 改成了双向队列(增加了 prev 和 next 指针):
prev 找到前驱,把自己从链表里抠出来,并让前驱指向自己的 next。在原始 CLH 中,后继节点通过自旋'发现'前驱结束了。
在 AQS 中,当前驱节点释放锁时,它会主动通过 next 指针找到后面的'邻居',并把它唤醒(unpark)。
主要原因是为了解决'惊群效应'(Thundering Herd):
我们可以把 AQS 的队列理解为**'带有阻塞/唤醒功能的双向 CLH 队列'**:
| 特性 | 原始 CLH 锁 | AQS 变体队列 |
|---|---|---|
| 等待方式 | 循环自旋(费 CPU) | 挂起/阻塞(省 CPU) |
| 链表方向 | 单向(指向前驱) | 双向(前驱 + 后继) |
| 通知方式 | 被动观察前驱 | 前驱主动唤醒后继 |
| 节点状态 | 只有 locked (true/false) | 复杂的 waitStatus (CANCELLED, SIGNAL 等) |
理解了 AQS 的核心组件(state、CAS、CLH 队列)后,我们可以将它的完整工作流程串联起来。
AQS 的精髓在于:能抢锁时直接抢,抢不到时才排队,排队时就睡觉,等前任叫醒。
AQS 的工作流程主要分为四个阶段:尝试获取、入队、阻塞、释放/唤醒。
当一个线程(我们称之为线程 A)调用 lock() 时:
state 从 0 改为 1。state 已经是 1,说明有人占着锁,进入第二步。抢锁失败的线程 A 不甘心,但也没办法,只能准备排队:
Node(包含线程引用、等待状态等)。for(;;) 死循环中不断尝试入队,直到成功。进入队列后,线程 A 并不会立即'睡觉',它还会最后挣扎一下:
LockSupport.park(),让出 CPU 资源,正式进入休眠状态。检查位置:如果线程 A 发现自己排在队列的第一个(紧跟在 Head 节点后面),它会再次尝试 tryAcquire 抢一次锁。
为什么要再抢一次? 因为在它入队的过程中,持锁线程可能刚好释放了锁。
当持锁线程执行完业务逻辑,调用 unlock() 时:
state 减回 0,清空独占线程标记。next 指针,如果后面有节点在排队,就调用 LockSupport.unpark(thread) 唤醒排在最前面的那个线程。tryAcquire。waitStatus)在整个流程中,AQS 依靠节点的状态位来决定'该做什么':
在公平锁模式下,如果队列里已经有线程在排队,新来的线程不仅不会抢到锁,甚至连'抢锁'这个动作(CAS)都不会触发。
我们可以从源码逻辑和执行流程两个维度来拆解这个过程:
hasQueuedPredecessors()在 ReentrantLock 的公平锁实现类 FairSync 中,抢锁的 tryAcquire 方法里有一个非公平锁没有的关键判断:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 【关键点】在尝试 CAS 修改状态前,先调用了 hasQueuedPredecessors()
if (!hasQueuedPredecessors() && compareAndSetState(0, c + acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// ... 后续重入逻辑
}
hasQueuedPredecessors() 的逻辑非常严谨,它会检查:
只要'队列不为空'且'排在第一位的不是我',该方法就会返回 true。 由于代码中用了 !hasQueuedPredecessors(),这意味着条件不成立,线程会直接跳过 CAS 抢锁,老老实实去走入队排队的流程。
当一个新线程(线程 C)在公平锁模式下尝试获取锁时:
state == 0(假设此时前一个线程刚释放锁,锁现在是空闲的)。hasQueuedPredecessors(),发现队列里已经有线程 A 和线程 B 在等了。addWaiter 将自己封装成节点,挂到线程 B 的后面。LockSupport.park() 进入等待状态。有一种情况,即使队列里有人,线程依然能'抢锁'成功,那就是重入。
如果当前线程已经持有了锁,它再次请求锁时,AQS 不会去检查队列,而是直接增加 state 的值。因为逻辑上,它已经在'柜台'办事了,不需要重新排队。
hasQueuedPredecessors),只要有人在排队,他就会自觉走向队尾,绝对不碰那个 state 变量。在 Java 的 AQS 框架(如 ReentrantLock)中,实现'可重入'的核心逻辑非常直观。它通过记录持有者线程和计数器两个关键信息来协同工作。
实现可重入主要分为两个步骤:获取锁时的判定和释放锁时的递减。
当一个线程尝试获取锁时,AQS 会执行以下逻辑:
state:
state == 0,说明锁空闲,直接抢锁,并将 exclusiveOwnerThread(当前持有锁的线程变量)指向自己。state > 0,说明锁被占了。state = state + 1,并返回 true(获取锁成功)。既然进去了多次,自然要出来多次。释放锁的过程如下:
unlock(),AQS 会执行 state = state - 1。state 仍然大于 0,说明这只是其中的一层嵌套,锁依然被当前线程持有。state 减到 0 时,才表示所有嵌套都已退出。exclusiveOwnerThread 设置为 null。我们可以用一段简化的伪代码来还原 ReentrantLock 的重入实现:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 锁空闲,正常抢锁
if (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); // 更新 state,不需要 CAS,因为只有持锁线程能操作
return true;
}
return false;
}
如果没有可重入机制,会发生严重的死锁。
场景示例:
public synchronized void methodA() {
// 此时线程拿到了锁
methodB();
}
public synchronized void methodB() {
// 如果没有可重入,methodB 会尝试再次获取同一把锁
// 但锁被 methodA 占用,它会一直等待,导致自己把自己锁死
}
可重入性允许同一个线程在不释放锁的情况下,多次获得同一把锁,这极大地方便了递归调用和代码块的嵌套。
lock() 和 unlock() 必须成对出现。如果你 lock() 了两次,却只 unlock() 了一次,state 就不会归零,其他线程将永远无法获取这把锁。state 是一个 int 类型,可重入次数是有上限的(虽然通常不可能达到 2^31-1 次)。AQS 处理中断的方式可以总结为:'先记账,后算账'。
当一个线程在 AQS 队列中阻塞等待时,如果它被中断了,它并不会立刻跳出来(这样会破坏队列结构),而是会经历一个'感知、记录、补领'的过程。
当线程在队列中被 LockSupport.park() 挂起时,如果其他线程调用了它的 interrupt() 方法:
park() 并不响应中断异常(不抛出 InterruptedException),但它会直接返回(线程醒了)。Thread.interrupted() 检查自己是不是被中断了。interrupted = true,然后继续尝试抢锁。这是 AQS 最稳健的地方:即使被中断了,也要拿到锁才能'死'。
在 acquireQueued 方法的循环里,即使线程被唤醒且发现有中断标识,它依然会尝试去抢锁。只有当它真正拿到锁(排到第一名并 CAS 成功)后,它才会返回这个中断标记。
当 acquireQueued 最终拿到锁并返回主逻辑时,它会告诉调用者:'我拿到锁了,但在排队过程中有人中断过我。'
此时,AQS 会根据你调用的方法类型采取不同的策略:
lock())即使感知到了中断,它也只是在拿锁成功后,通过 selfInterrupt() 方法给自己补发一个中断信号。
lock() 的语义是'必须拿到锁'。它把处理中断的权利交给用户,让用户在业务代码里通过 Thread.currentThread().isInterrupted() 自行判断。lockInterruptibly())如果你调用的是这个方法,AQS 的处理就会非常果断:
InterruptedException。cancelAcquire)。如果线程在队列中间直接抛出异常并消失,会导致队列断裂:
prev 和 next 指针)。因此,AQS 采取了最安全的方式:让中断后的线程也必须走完抢锁流程(或者走专门的取消逻辑),确保队列的完整性。
park 结束,线程通过 Thread.interrupted() 发现中断。lock():拿到锁后补个中断标识,业务代码自行决定怎么办。lockInterruptibly():一旦发现中断,立即抛异常并清理队列。在 标准的 CLH 锁 和 Java 的 AQS 变体 中,情况略有不同。
简单来说:在正常流程下,是的,线程只会被它的前驱(前一个节点)唤醒;但在特殊情况下(如节点取消),前驱会跳过一些节点去寻找最近的'活着的'后继。
在 AQS 的工作流中,这是一种典型的'接力'。
next 指针。next 节点存在且状态正常,线程 A 会调用 LockSupport.unpark(B.thread)。这种机制保证了没有'惊群效应':锁释放时不会吵醒所有人,只精准唤醒下一个。
如果前驱节点释放锁时,发现它的直接后继(下一个节点)已经**取消(Cancelled)**了(比如超时或被中断),该怎么办?
这时,前驱节点会执行一个**'从后往前'**的扫描逻辑:
为什么从后往前找? 因为在 AQS 入队时,
prev指针(指向前驱)是先设置的,而next指针是后设置的。从后往前扫描可以保证一定能遍历到所有已经入队的节点,避免因为高并发下next指针还没来得及赋值而漏掉节点。
虽然 AQS 借鉴了 CLH,但它们的唤醒逻辑本质不同:
unpark 把它叫醒。虽然队列里是前驱唤醒后继,但在非公平锁模式下,被唤醒的线程(比如 B)从睡梦中醒来去领锁时,可能会发现锁已经被一个**刚来的新线程(比如 D)**抢走了。
此时:

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online