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 抢一次以提高吞吐。

相关推荐
共享家95272 小时前
实现简化的高性能并发内存池
开发语言·数据结构·c++·后端
黄昏恋慕黎明2 小时前
spring的IOC与DI
java·后端·spring
千里马学框架2 小时前
aospc/c++的native 模块VScode和Clion
android·开发语言·c++·vscode·安卓framework开发·clion·车载开发
Barkamin2 小时前
JVM核心简单介绍
jvm
鱼鳞_2 小时前
Java学习笔记_Day15
java·笔记·学习·排序算法
liuqun03192 小时前
go进阶之gc
开发语言·后端·golang
鹏程十八少2 小时前
8. Android 深入插件化Shadow源码:揭秘插件Activity启动的完整链路(源码解析)
java·前端·面试
程序员清风2 小时前
OpenAI创始人学AI的底层逻辑,普通人照着做就能上手!
java·后端·面试
Memory_荒年2 小时前
Netty面试终极指南:从“Hello World”到源码深处
java·后端