AQS 原理:一套造锁的框架,撑起了半个 java.util.concurrent(037)

Java并发编程约 11 分钟阅读5,754 字符
知识库文档 38 / 251

一、为什么需要 AQS:不想每把锁都重新造轮子

前面几篇讲的 synchronized 是 JVM 内置的锁,CAS 是硬件提供的原子操作。但如果要在 Java 层面自己实现一把功能更丰富的锁——比如支持公平/非公平、支持中断、支持超时、支持一个资源被多个线程共享——每次都从零手写"线程排队、阻塞、唤醒"这套逻辑,既繁琐又容易出错。

**AQS(AbstractQueuedSynchronizer,抽象队列同步器)**就是 JDK 给出的通用答案。它把"锁"和"同步器"共有的核心逻辑抽出来,做成一套框架:具体子类只需要实现"要不要让我获取"这一个判断逻辑,剩下的排队、阻塞、唤醒、可重入、独占/共享区分,全部由 AQS 统一处理。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock、FutureTask 等,都是基于 AQS 搭建出来的。理解 AQS,相当于一次性理解了这一整类工具背后共同的骨架。

二、核心:同步状态 state

AQS 内部只维护一个核心变量:一个用 volatile 修饰的 int 类型字段,叫同步状态(state)。这个数字的具体含义完全由子类自己定义:

  • 在 ReentrantLock 里,state 表示"锁被重入的次数"——0 表示未加锁,大于 0 表示被持有且重入了几次。
  • 在 CountDownLatch 里,state 表示"还剩多少次 countDown() 才能放行"。
  • 在 Semaphore 里,state 表示"还剩多少个可用许可"。

AQS 提供了 getState()、setState(),以及最关键的 compareAndSetState()——顾名思义,修改 state 靠的正是上一篇讲的 CAS,保证多线程同时尝试修改 state 时不会互相踩踏。子类不需要关心 state 怎么被安全修改,只需要围绕"当前 state 的值,能不能满足我这次获取的条件"去写判断逻辑。

文章配图
文章配图

三、CLH 队列变体:抢不到锁的线程去哪排队

当一个线程调用 tryAcquire() 发现 state 不满足获取条件(比如锁已被别人持有),它不会无限空转自旋下去,而是会被包装成一个队列节点(Node),通过 CAS 操作接到等待队列的尾部。这个队列是CLH 锁的一种变体——一个双向链表结构,每个节点记录着前驱、后继指针,以及自己的等待状态(waitStatus)。

排队之后,节点不是彻底躺平等通知,而是遵循一个规则:只有当自己是紧挨在队头(head)后面的节点、且此刻再次尝试获取发现 state 确实满足条件时,才有机会真正拿到锁,成为新的 head。如果不满足,线程才会调用 park() 真正挂起,把 CPU 让出去,等待被前驱节点释放锁时唤醒。这种"先检查排队资格,再决定是否真正阻塞"的设计,比简单粗暴地一路阻塞或一路自旋都更高效。

文章配图
文章配图

四、独占模式与共享模式:一个位置够几个人用

AQS 把获取同步状态的方式分成两大类:

  • 独占模式(Exclusive):某一时刻只允许一个线程持有。子类需要实现 tryAcquire() / tryRelease()。典型代表是 ReentrantLock——同一把锁只能有一个线程真正拿到,其他线程必须老老实实排队。
  • 共享模式(Shared):同一时刻可以允许多个线程同时持有。子类需要实现 tryAcquireShared() / tryReleaseShared()。典型代表是 CountDownLatch(state 归零后所有等待线程一起放行)和 Semaphore(只要还有剩余许可,多个线程可以同时获取成功)。

两者最大的区别体现在唤醒方式上:独占模式下,一个线程释放锁只需要唤醒紧邻的下一个节点即可,因为资源只够一个人用;共享模式下,一个线程释放资源后,可能同时有多个等待者都满足条件,所以唤醒后还会继续向后传播唤醒,让后面符合条件的节点也一并被唤醒检查,而不是唤醒一个就停下。

文章配图
文章配图

五、Condition:锁配套的精细等待室

前面讲 synchronized 时提到过 Monitor 里的 WaitSet,配合 wait()/notify() 实现等待通知。但 synchronized 只有一个 WaitSet,所有等待的线程混在一起,没法区分"我在等条件A"还是"我在等条件B"。

AQS 提供的 Condition 解决了这个问题:一把锁(比如 ReentrantLock)可以通过 newCondition() 创建出多个独立的 Condition 对象,每个 Condition 内部维护自己单独的等待队列,和 AQS 本身的同步队列是两条不同的队列。

调用某个 Condition 的 await(),线程会从 AQS 的同步队列里"搬"到这个 Condition 自己的等待队列,同时释放持有的锁;调用这个 Condition 的 signal(),则会把它等待队列里的线程重新"搬"回 AQS 的同步队列,让它重新参与锁的竞争,而不是直接把锁交给它。这种"按条件分组等待"的能力,比 synchronized 只能笼统 notifyAll 精细得多——比如经典的生产者/消费者模型,可以用"队列不满"和"队列不空"两个独立的 Condition,分别只唤醒真正需要被唤醒的那一组线程,避免无关线程被无意义唤醒后又重新阻塞。

文章配图
文章配图

六、一个骨架,撑起一整个工具箱

正因为 AQS 把"排队、阻塞、唤醒、独占/共享、可重入"这些通用逻辑都封装好了,java.util.concurrent 包里的很多工具类实际上只是在 AQS 基础上补了一层"什么时候算获取成功"的业务判断:

  • ReentrantLock:内部持有一个继承 AQS 的同步器,state 表示重入次数,独占模式。
  • CountDownLatch:state 表示剩余计数,共享模式,state 减到 0 才算所有等待者都能通过。
  • Semaphore:state 表示剩余许可数,共享模式,acquire 时减少许可、release 时增加许可。
  • ReentrantReadWriteLock:巧妙地把一个 32 位的 state 拆成高 16 位表示读锁持有数、低 16 位表示写锁重入次数,用一个 state 同时支撑独占(写锁)和共享(读锁)两种模式。

文章配图
文章配图

七、面试追问链

从"AQS 是什么"出发,逐层追问:AQS 内部靠什么核心变量维护同步状态、这个变量是怎么被安全修改的、抢不到资源的线程被放到了什么结构里排队、独占模式和共享模式在唤醒方式上有什么本质区别、Condition 和 synchronized 的 wait/notify 相比强在哪里、以及 ReentrantLock、CountDownLatch、Semaphore 这些工具类分别是怎么基于 AQS 的 state 定义出各自语义的,这条链基本能覆盖面试官对 AQS 原理的核心考察点。

文章配图
文章配图

延伸阅读

  • java.util.concurrent.locks.AbstractQueuedSynchronizer 源码,重点看 acquire/release 和 acquireShared/releaseShared 的实现
  • ConditionObject 内部类的 await/signal 实现,理解等待队列和同步队列之间的节点转移
  • 下一篇预告:ReentrantLock——公平锁、非公平锁、可中断锁与超时获取

问答(不计入阅读时长)

Q1:AQS 里的 state 到底是"一个"变量还是"一类"变量?不同的锁用的是同一个 state 吗?

A:state 是 AQS 抽象类里定义的一个int类型字段,但每一个继承 AQS 的同步器实例(比如你new出来的每一把 ReentrantLock)都有自己独立的一份 state,不是全局共享的。真正让不同工具类"看起来不一样"的,是各自子类如何解读这同一个字段的数值含义——ReentrantLock 把它当重入次数用,Semaphore 把它当剩余许可数用。字段是同一套机制,含义是子类自己定义的。

Q2:既然轻量级锁也会自旋,AQS 的排队线程为什间不干脆一路自旋等锁,非要 park 挂起?

A:因为二者面对的场景假设不同。轻量级锁的自旋是"赌一把很快能拿到锁",只在竞争不激烈、持锁时间短的场景下自旋才划算。而 AQS 是通用框架,要同时应对持锁时间长、竞争线程很多的场景——如果所有排队线程都无限自旋,会造成大量 CPU 空转。所以 AQS 的策略是:只有紧邻 head 节点的线程才会再尝试一次获取(相当于极少量的"检查式自旋"),一旦不满足就立刻 park 真正挂起,把 CPU 让出去,靠后续释放锁的线程主动唤醒,而不是持续自旋硬等。

Q3:AQS 用的 CLH 队列和原始的 CLH 锁是一回事吗?为什么资料里常说是"CLH 变体"?

A:不完全一样。原始 CLH 锁里,节点是在自己的前驱节点的字段上自旋,等前驱标记为释放。AQS 做了改造:节点在挂起时是真正 park 掉的(不是忙等自旋),并且每个节点会显式维护 waitStatus 状态,唤醒时是由前驱线程主动 unpark 后继节点,而不是靠后继自己一直轮询前驱的字段。这种"显式双向链表+主动唤醒"的设计,兼顾了原始 CLH 的公平排队特性,又避免了忙等的 CPU 消耗,所以叫"CLH 变体"而不是原始 CLH。

Q4:独占模式下,一个线程释放锁只唤醒紧邻的下一个节点,会不会漏掉后面还在排队的线程?

A:不会漏掉,只是不会"一次性全部唤醒"。独占模式下资源只够一个线程用,唤醒紧邻的下一个节点后,这个节点会重新尝试获取,成功之后自己成为新的 head;当它之后释放锁时,又会去唤醒它自己的下一个节点,如此逐个传递下去。这种"接力式唤醒"保证了队列里的线程最终都会被依次唤醒检查,只是不需要一次性全部唤醒——毕竟同一时刻也只有一个线程能真正拿到独占资源,唤醒更多线程只会让它们醒来发现抢不到又重新挂起,白白浪费调度开销。

Q5:Condition 的 await/signal 和 synchronized 配合 Object.wait/notify,除了能不能拆分多个等待队列,还有其他实质区别吗?

A:还有几个关键区别。第一,synchronized 的 wait/notify 必须在拿到对象锁的同步块内调用,而 Condition 的用法与之类似但依托的是 Lock 接口,需要先 lock() 再调用对应 Condition 的 await/signal,权责更清晰。第二,Condition 支持 awaitUninterruptibly()、支持超时版本的 await(如 awaitNanos),可中断/超时能力上比原生 wait 更完善,这也和 AQS 整体"支持中断、支持超时"的设计理念一致。第三,因为可以按条件拆分出多个独立的 Condition,配合精确唤醒,能明显减少"惊群式"无效唤醒——这也是它相比笼统的 notifyAll 最大的实用价值。

Q6:CountDownLatch 的 countDown() 只是让 state 减一,为什么能让所有等待线程"同时"被放行?

A:因为 CountDownLatch 用的是共享模式。每次 countDown() 让 state 减一,只有当 state 减到 0 时,tryAcquireShared() 才会返回"可以获取"。在这之前每次 countDown 不会主动唤醒等待线程;直到最后一次让 state 归零的那次 countDown,才会触发唤醒逻辑,并且因为是共享模式,唤醒动作会沿着等待队列连续向后传播,把所有还在排队的等待线程依次唤醒检查,检查发现 state 已经是 0,于是它们几乎在同一批次内都被放行——这就是"看起来同时放行"背后的实际机制,本质是共享模式的连续传播唤醒,不是真正意义上的物理同时。

Q7:ReentrantReadWriteLock 把 state 拆成高16位和低16位,这样设计的意图是什么?会不会有位数不够用的风险?

A:意图是用一个 int 型的 state 同时表达两种独立的计数需求:低16位记录写锁的重入次数(独占语义),高16位记录当前持有读锁的线程数量(共享语义)。这样只需要一次 CAS 就能原子性地判断和更新"读锁数+写锁重入"这个组合状态,不需要维护两个分离的字段还要保证它们之间的一致性。16位能表示的最大值是65535,意味着写锁最多重入65535次、同一时刻最多65535个线程同时持有读锁——这个上限在绝大多数实际业务场景下完全够用,不构成实际风险。

Q8:AQS 的这套设计思想,本质上解决的是什么问题?为什么值得单独拿出来讲一整篇?

A:本质上,AQS 解决的是"如何用一套统一、正确、高效的机制,同时支撑独占和共享两类完全不同的同步语义"这个问题。在 AQS 出现之前,Java 里想实现一个自定义的同步工具,要自己处理排队、阻塞、唤醒、可重入、公平性这一整套细节,出错的空间很大。AQS 把这些通用而容易出错的部分统一实现好,暴露出 tryAcquire/tryRelease 这类简单的判断接口给子类填空,从而让 ReentrantLock、Semaphore、CountDownLatch 这些看起来完全不同的工具,底层其实共用同一套久经考验的骨架。理解了 AQS,再看这些工具类的源码会发现它们出奇地相似,这也是为什么面试里 AQS 常被当作"检验是否真正理解并发工具类底层"的一道分水岭问题。