AQS 的 CLH 同步队列:入队/出队、park/unpark 与“公平性”从哪来

AQS 最容易被忽略但最关键的一点是:

  • CAS 抢 state 只是"快路径",真正让高并发下系统还能跑稳的,是 失败线程的排队与阻塞唤醒机制

AQS 使用的是 CLH 变体的 FIFO 双向队列

你必须记住的 3 句话(面试直出):

  • 线程获取失败会入队,不是为了"排队直接发锁",而是为了把竞争变得有序可控。
  • 只有"head 的后继"才更有资格反复 tryAcquire,其余线程会 park 减少空转。
  • 释放时通常只唤醒一个后继,避免惊群。

1. 先讲结论:AQS 队列里存什么

队列节点(Node)大致包含:

  • thread:当前等待的线程
  • prev/next:前驱/后继
  • waitStatus:节点状态(是否需要唤醒、是否取消等)

你至少要知道两个最常见状态(细节不必死背,但要会解释含义):

  • SIGNAL:表示"我的后继需要被唤醒"(前驱释放时会 unpark 后继)
  • CANCELLED:线程超时/中断/放弃竞争后的取消节点(需要被跳过)

关键理解:

  • 队列不是"直接发放锁"的结构
  • 它只是提供秩序:让"谁有资格再去尝试抢 state"更可控

2. 入队:为什么失败后要排队

如果失败线程不排队,而是所有线程都不停 CAS 抢 state:

  • 会导致巨量的总线争用与 CPU 空转
  • 系统抖动明显(吞吐不稳定,延迟尖刺)

所以 AQS 的策略是:

  • 抢不到 -> 进入队列
  • 只有"队列头的后继"才更有资格去重试

3. 一个关键细节:为什么要把前驱标记成 SIGNAL

你会看到 AQS 在决定 park 之前,会先把"前驱节点"标记为 SIGNAL(语义是:

  • 前驱释放时,需要负责唤醒它的后继

直觉理解:

  • 这相当于建立一条责任链:谁在我前面,谁负责在释放时叫醒我。

它避免了"释放时不知道叫醒谁"的混乱,也减少了无意义唤醒。


4. 出队与唤醒:释放时为什么只唤醒一个

独占锁释放时通常只唤醒一个后继线程:

  • 因为同一时刻只允许一个线程成功
  • 唤醒多个会造成"惊群"(大量线程醒来再失败,再睡回去)

这也是为什么 ReentrantLock 的 signal 与 signalAll 需要谨慎。

补充:为什么 unpark 不是"立刻让线程拿到锁"?

  • unpark 只是在调度层面把线程从阻塞态唤醒
  • 真正能否成功仍要靠它醒来后再 tryAcquire 的 CAS 竞争

5. 取消节点(超时/中断)为什么不会把队列"卡死"

现实里线程可能会:

  • 超时放弃
  • 被中断
  • 直接取消等待

这会导致队列中出现 CANCELLED 节点。

关键点:

  • AQS 在入队、自旋、唤醒时都会跳过取消节点,并尝试修复前驱/后继指针
  • 目的只有一个:保证队列还能向前推进,唤醒链路不会断

面试人话:

  • 队列里可能有人"退出排队",但队列不会因此堵死,会自动绕过这些退出者。

6. park/unpark:阻塞与唤醒的语义

AQS 使用 LockSupport.park/unpark,典型流程:

  • 线程入队后在合适时机 park(挂起)
  • 前驱释放锁时 unpark 后继(唤醒)

注意点:

  • unpark 可以先于 park 调用(有"许可"语义),因此更适合构建并发框架

这点在面试里很加分:

  • Object.notify 需要先 wait 才有意义
  • LockSupport.unpark 可以先发"许可",后续 park 会直接通过

7. 公平性从哪来:FIFO + 前驱判断

所谓公平锁,并不是"队列里的人一定按顺序拿到锁",而是:

  • 新来的线程不会插队

在 AQS 语义下通常体现在:

  • 公平锁获取时,会先判断 hasQueuedPredecessors()
  • 只要队列里有人排在前面,就不走"直接 CAS 抢 state"的快路径

而非公平锁:

  • 先抢一次 state(允许插队),失败才入队

8. 你需要能解释的一个细节:为什么"头节点后继"才去重试

队列中的线程并不是都在自旋。

典型做法是:

  • 只有当自己的前驱是 head,才认为"轮到我"再去 CAS 尝试
  • 否则就 park

这样可以把竞争范围从"所有线程"缩小为"极少数线程"。

再补一个常见细节:为什么要有 head 哑节点?

  • head 通常是一个"哑节点"(不代表真实线程),用来简化出队/唤醒逻辑
  • "谁是 head 的后继"就是一个明确的资格判定点

9. 线上排查:怎么看出线程卡在 AQS 队列上

现象:

  • 接口 RT 飙升,线程数上涨
  • jstack 大量线程处于 WAITING (parking)

你在堆栈里常能看到:

  • java.util.concurrent.locks.LockSupport.park
  • java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire
  • ReentrantLock$NonfairSync/FairSync.lock

判读:

  • 如果很多线程卡在同一个锁的 acquire 上,说明临界区过长或锁粒度过大

进一步区分两种常见形态:

  • 大量 WAITING (parking):线程主要在排队等待(锁竞争或临界区长)
  • CPU 很高 + acquire 相关栈频繁出现:可能存在更强的自旋/重试(热点 state/CAS)

10. 自测清单(你要能顺口讲出来)

  • Q:AQS 队列是干什么的?

    • A:保存获取失败的线程,提供 FIFO 排队与阻塞唤醒秩序,避免无序竞争导致的抖动。
  • Q:公平锁为什么吞吐更低?

    • A:因为禁止插队,减少快路径命中,更多线程会入队/唤醒,调度开销更高。
  • Q:释放锁为什么通常唤醒一个线程?

    • A:独占语义下同时只能一个成功,唤醒多个会惊群。
  • Q:队列里有取消节点会怎样?

    • A:AQS 会在遍历/唤醒时跳过取消节点,保证队列还能向前推进(否则容易出现"断链/唤醒不到人"的风险)。
  • Q:为什么要把前驱标成 SIGNAL 才 park?

    • A:让前驱承担"释放时唤醒后继"的责任链,避免唤醒丢失,也避免释放时乱唤醒。
  • Q:AQS 为什么要有 head 哑节点?

    • A:简化出队与唤醒逻辑,明确"head 的后继"这个资格位点,让竞争可控。

11. 30 秒背诵稿

AQS 在获取失败时把线程包装成 Node 入 CLH FIFO 队列,通过前驱/后继指针维护 FIFO 秩序;只有 head 的后继才反复 tryAcquire,其余线程会在前驱标记为 SIGNAL 后 park 阻塞,避免空转。释放时通常 unpark 一个合适后继,唤醒不等于"交锁",醒来仍需 CAS 再竞争。公平锁通过判断队列前驱来禁止插队,非公平锁则先 CAS 抢一次以提高吞吐。

相关推荐
csbysj20203 分钟前
Perl 运算符
开发语言
Amctwd8 分钟前
【Python】从Excel中按行提取图片
java·python·excel
啃臭17 分钟前
AOP和反射
java·spring boot
西凉的悲伤25 分钟前
java 使用PNG图片隐写文件
java·图片隐写·png
有梦想的小何28 分钟前
Cursor AI 编程实战(篇一):Prompt 与案例总结
java·linux·prompt·ai编程
沐知全栈开发31 分钟前
jQuery Mobile 事件详解
开发语言
河阿里1 小时前
SpringBoot:Spring Task定时任务完整使用教学
java·spring boot·spring
jiayong231 小时前
Tool Permission 与 Sandbox 的安全流水线:Agent 工具系统的工程边界
java·数据库·安全·agent
rururunu1 小时前
Windows 下切换 Java 环境太复杂了,我做了个 cli 工具,可以快速安装,切换 Java 版本
java