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