一、什么是AQS
AQS英文全称为AbstractQueueSynchronizer,即抽象队列同步器。它的功能和Synchronized类似,都是提供多线程同步管理的工具,但是AQS的功能更丰富。
二、AQS数据结构
1.state同步状态
state是volatile类型的int变量,用于标记当前的锁状态。提供get和set方法读取和设置变量,更重要的是提供了基于CAS实现的compareAndSetState方法,用于多线程抢占该锁资源。当state为0时,表示锁资源可获取,state大于0时,表示重入次数(无重入则为1)
2.node线程节点
Node节点,Node节点用于保存和表示并发获取该锁资源的线程,每个等待线程会被封装成一个Node放入等待队列中。Node类型包含下面字段。
thread:当前节点所代表的线程。
waitStatus:节点等待状态,主要有以下几个取值:
SIGNAL (-1):当前节点的后继节点需要被唤醒。
CANCELLED (1):当前节点因超时或中断被取消,不会再参与竞争。
CONDITION (-2):当前节点在条件队列(Condition)中等待。
PROPAGATE (-3):在共享模式下,状态需要无条件传播。
0:初始状态。
prev / next:指向前驱和后继节点的引用,用于构建双向队列。
nextWaiter:用于在条件队列中指向下一个节点。
3.等待队列
当并发线程获取锁资源失败时,会以Node节点形式进入等待队列。该等待队列类似CLH队列。
head队头指针:指向队列队头第一个Node节点
tail队尾指针:指向队列中最后一个节点
Node队列:由Node对象通过Node.prev和Node.next指针串联起来的双向链表结构
compareAndSetHead方法:
private final boolean compareAndSetHead(Node update) { // headOffset(头指针), null(head原来空的null引用), update(传入的新head引用) return unsafe.compareAndSwapObject(this, headOffset, null, update); }三、AQS工作原理
下面通过三个线程A、B和C看看AQS是如何工作的。
1.初始状态(无竞争)
AQS 引用:
head = null,tail = null。链表:不存在任何
Node对象。资源状态:
state = 0(子类定义 0 为空闲)。
2.线程 A 首次调用acquire()
线程 A 执行 AQS 的acquire(int arg)模板方法。
调用
tryAcquire(arg)(钩子方法):子类检查
state,当前为 0,返回true。
AQS 反应:
acquire方法中的if (!tryAcquire(arg) && ...)短路,直接返回。结果:线程 A 直接通过,无需创建队列。此时
head和tail依然为null。
3.线程 B 调用acquire()
此时线程 A 持有资源(state = 1),线程 B 开始执行acquire。
3.1 试探失败
调用
tryAcquire(arg),子类看到state = 1,返回false。
3.2 入队(addWaiter)与队列初始化(enq)
AQS 执行addWaiter(Node.EXCLUSIVE),因为tail==null,进入enq方法通过自旋加入队尾:
private Node enq(final Node node) { // 这里的 node 是线程B的节点 for (;;) { // 线程B在这里自旋 Node t = tail; if (t == null) { // 第1次循环:true // 必须初始化虚拟头节点 if (compareAndSetHead(new Node())) // 线程B 通过CAS设置 head tail = head; // 线程B 将 tail 指向刚创建的虚拟头节点 } else { // 第2次循环会进入这里 node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } } }循环第 1 次:
获取
tail,发现tail == null。AQS 创建第一个节点(虚拟头节点):compareAndSetHead(new Node())(
thread = null,waitStatus = 0),通过 CAS 将head指向新建的head节点,然后将tail也指向head,完成等待队列初始化。
循环第 2 次:
获取
tail,现在tail指向head。操作nodeB:将线程 B 的
nodeB.prev指向 head。操作tail:通过 CAS 将
tail从head改为指向nodeB。操作head:将
head.next指向nodeB。为什么要自旋第二次才设置head,不能初始化虚拟头节点后直接设置head吗?不能,因为可能存在多个线程同时初始化头节点。
3.3 开始自旋等待(acquireQueued)与阻塞
final boolean acquireQueued(final Node node, int arg) { for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { // ① 只有满足位置条件才抢锁 setHead(node); return false; } if (shouldParkAfterFailedAcquire(p, node) && // ② 无论是否抢锁,都要执行 parkAndCheckInterrupt()) { return true; } } }完成队列初始化和入队后,线程 B 进入acquireQueued自旋(nodeB, arg),由于B是第一个等待者,所以可以再次尝试获取锁,如果还是不成功,那么就将前驱节点p(对于B来说是head)置为SIGNAL(-1),在前驱节点释放资源时会唤醒当前节点。
获取前驱
p = nodeB.prev(指向虚拟头节点)。p == head成立(B 是第一个真实等待者)。由于B是第一个等待者,所以可以再次调用
tryAcquire(arg),state仍为 1,失败。调用
检查前置节点(head)的shouldParkAfterFailedAcquire(p, nodeB):waitStatu:情况1-waitStatus为 0(当前场景下是这种情况):通过 CAS 将虚拟头节点的waitStatus改为SIGNAL(-1)。语义:“虚拟头节点,你释放资源时必须唤醒我(B),然后返回 false。为什么不在enq的时候设置status?因为如果存在多个线程初始化头节点,那么入队后并非都是第一个等待者,同时,自旋的时候还可以重新获取一次锁,这样尽可能避免队列等待。情况2-waitStatus为 大于0(CANCELLED1):说明前置节点已经超时或中断,此时需要循环将当前节点越过所有死掉的节点,将nodeB挂到最近的一个活着的节点后面(修改node.prev和活节点.next)这是为什么要传nodeB的原因。然后返回false。情况3-waitstatus为-1:说明已经就绪,什么都不需要做,返回true。
若shouldParkAfterFailedAcquire返回true,如情况3,则停止自旋,调用parkAndCheckInterrupt(),通过LockSupport.park(this)挂起线程 B。
若shouldParkAfterFailedAcquire返回false,则继续自旋。如果是情况1返回的true,则再次自旋时会变成情况3返回true停止自旋,如果是情况2返回的false,则再次自旋可能进入情况1或者情况3,情况1又会进入情况3,如果该线程无法在过程中获取到锁,那么最终都会返回true停止自旋。
为什么不一步到位,而要自旋分多个步骤完成剩下的事情?
实际最基本的思想就是,每次操作时,你获取到的当前AQS状态最好都是最新的,你在检查一次状态后做了很多事情,这些事情在做的时候,整个AQS随时会发生变化,再次自旋获取状态只是一次读操作,一次读操作避免掉很多无效的操作,这绝对是有意义的,同时在自旋过程中,每次自旋都会重新尝试获取锁,如果并发数量不高则不会挂起线程,尽最大可能避免了挂起排队造成的上下文切换,下面针对情况1和情况2返回false然后自旋的原因详细说说:
情况1:假设情况1检查到前置状态为0,将前置节点设置为-1后,就将当前线程挂起,那么可能会出现一种情况,在你尝试挂起之前,前置节点已经设置为-1,然后前置节点可能会马上失效去唤醒当前节点,假设这个唤醒动作比挂起动作执行得快,最终当前节点就会先接收到唤醒命令后接收到挂起命令,前置节点的唤醒命令已经发出并且前置节点已经失效,从而当前节点永远没有机会被唤醒。同时,在自旋时会重新尝试获取锁,如果能获取成功,则不会挂起线程,避免了线程上下文切换带来的开销。
情况2:再假设情况2完成后挂到了最后面的一个有效节点的后面,然后直接修改前置状态,那么可能会出现,当前节点已经成了第一个等待节点,很有可能马上就可以执行,而直接设置前置状态为-1后,第二次自旋会直接获取到该线程,那么这次设置前置状态的CAS就是浪费了的。而这里不设置前置状态为-1也没影响,因为如果它成了第一个等待节点,那么假设锁被释放头节点没有唤醒我们这个第一个等待节点,由于释放后锁state已经释放,我们自己也会再自旋获取到锁,但是这里我们避免了一次CAS写操作带来的性能开销,而下次自旋检查当前节点是否是第一个头节点操作是读操作,性能消耗远小于CAS写,如果自旋中发现我们不是第一个等待节点或者锁没有被释放,那么在自旋中再设置前置状态为-1就好了,虽然多了一次自旋,但是这个自旋带来的额外消耗只是检查节点是否第一个等待节点以及检查锁状态尝试获取锁,都是读操作,对比浪费一个CAS来说还是划算的,因为如果能直接获取到的话,就可能节省一个CAS操作。如果这次自旋设置了状态,下次自旋又会尝试获取锁,又多了一次获取锁的机会,尽可能避免了线程挂起带来性能消耗。
4.线程A释放锁
假设上面的线程B操作时是情况1,即线程A没有在B进入等待队列的过程中释放。然后线程A释放锁执行release():
调用
tryRelease,AQS的state变为0,返回true。获取当前
head(指向虚拟节点),发现head.ws = SIGNAL(-1)。执行
unparkSuccessor(head):CAS将
head.ws从SIGNAL改为0(表示唤醒指令已发出),然后不再承担唤醒责任,A会将head置为A节点,然后由A唤醒B,C则由B唤醒。取
head.next,得到nodeB,确认其未取消。调用
LockSupport.unpark(nodeB.thread),线程B被唤醒。
5.线程B被唤醒
线程B从park()返回,回到acquireQueued的自旋中:
重新开始自旋(被唤醒后继续):
获取前驱
p = nodeB.prev(虚拟节点)。p == head成立。(这里要注意一点,线程A释放时并没有操作等待队列,线程A不是head,线程A是获取到锁直接跑起来的,A释放只需要修改state锁状态)。调用
tryAcquire,此时state==0,成功获取资源。
执行
setHead(nodeB)使当前节点成为新的head哨兵:AQS的
head引用改为指向nodeB。nodeB.thread = null(B退化为新的哨兵)。nodeB.prev = null(断开与旧虚拟头的链接)。
旧head虚拟节点,在B唤醒时失去head指向它的引用,根据GC算法没有引用指向的对象会被回收。
后续线程B释放唤醒线程C的过程和线程A释放唤醒B是一样的,head指向第一个等待线程,AQS指向head哨兵的节点始终稳定。
四、总结
通过上面的讲解,应该可以了解到AQS可以很好地管理锁资源抢占,那么它对比synchronized一定更好吗?不一定,synchronized有偏向锁、轻量级锁和重量级锁三个等级,synchronized的性能在无竞争的情况下性能是最好的,只需要对比mark word,AQS的话则是检查state,性能也很快,但是AQS的性能还是略低于synchronized。而synchronized的轻量级锁和AQS的自旋获取线程性能差不多,轻量级锁也是自旋CAS实现的。synchronized升级到重量级锁之后,其性能比AQS的非公平锁实现要差,且synchronized不会随着竞争降低降级回到轻量级锁或偏向锁,所以在竞争比较强的情况下,使用AQS非公平锁性能更好,但是,如果必须避免线程饥饿(可能某些等待线程一直无法获得资源)时,只能使用AQS公平锁,synchronized是非公平锁。
下面是不同锁实现的差异对比。
| 锁类型 | 无竞争场景 (单线程/空闲) | 轻量竞争 (线程交替,极少冲突) | 中等竞争 (偶发阻塞,锁持有时长不定) | 高竞争 (大量线程频繁阻塞) | 最适应场景 | 核心实现原理 |
|---|---|---|---|---|---|---|
1.synchronized偏向锁 | ⭐⭐⭐⭐⭐绝对王者 耗时~1-3 ns,仅比对线程ID,零CAS、零内存屏障。 | ⚠️性能断崖 触发偏向撤销(需STW),耗时飙升,退出此赛道(升级)。 | — (已升级) | — (已升级) | 绝对单线程环境(如单线程循环、无竞争的Vector操作) | 对象头Mark Word存储线程ID,无任何同步指令。 |
2.synchronized轻量级锁 | ⭐⭐⭐⭐很快 耗时~10-20 ns,单次CAS替换 Mark Word。 | ⭐⭐⭐⭐并列第一 耗时~50-100 ns,用户态自旋CAS消化冲突,无内核切换。 | ⚠️拐点出现 自旋超阈值后膨胀为重量级锁,性能开始下滑。 | — (已永久升级为重量级,不可降级) | 线程交替执行(锁持有时间极短,几乎无并发冲突) | CAS 将对象头复制到栈帧锁记录(Displaced Mark Word)。 |
| 3. AQS 非公平锁 | ⭐⭐⭐⭐略逊于轻量级 耗时~20-50 ns,CAS修改 state,多了一层CLH队列检查。 | ⭐⭐⭐⭐与轻量级持平 耗时~50-100 ns,CAS重试,不会膨胀升级,状态稳定。 | ⭐⭐⭐⭐⭐开始反超 耗时~1-5 μs,少量线程入队阻塞,但利用唤醒延迟让新线程插队,吞吐量最高。 | ⭐⭐⭐⭐高吞吐冠军 耗时~10-50 μs,CLH队列高效,插队机制将切换延迟“抵消”为有效工作。 | 中高并发核心链路(追求极致吞吐量,允许短时插队) | CAS操作state;CLH双向队列;释放时仅唤醒head.next,不阻止新节点CAS抢锁。 |
4.synchronized重量级锁 | ⭐遗留陷阱 因不可降级,若历史有过竞争,无竞争时仍走Mutex,耗时~0.5-1 μs。 | ⭐⭐表现不佳 因不可降级,轻竞争下依然走内核互斥量,耗时~1-5 μs。 | ⭐⭐逊于AQS非公平 耗时~5-20 μs,虽有自适应自旋,但锁移交由内核调度,缺乏用户态优化。 | ⭐⭐⭐中规中矩 耗时~20-100 μs,由操作系统调度,虽稳定但吞吐量低于AQS非公平。 | 竞争不频繁且不在意降级损耗(或传统遗留代码) | JVM维护ObjectMonitor,底层基于pthread_mutex_t或futex,完全由内核调度。 |
| 5. AQS 公平锁 | ⭐⭐自带拖累 每次加锁检查前驱节点,耗时~50-100 ns,比非公平慢。 | ⭐过早阻塞 强制新线程入队并 park(),放弃CAS抢锁,耗时~5-20 μs。 | ⭐吞吐量垫底 耗时~20-100 μs,频繁维护CLH节点状态( SIGNAL),CAS导致CPU缓存行失效频繁。 | ⭐高并发最差 耗时~50-200 μs,上下文切换最频繁,Java层CAS开销比内核 futex还重。 | 严格防止线程饥饿(低并发下严格FIFO顺序) | 继承AQS,tryAcquire中强制检查CLH队列是否有等待者;有则直接入队,严格FIFO唤醒移交。 |
| 6. 🆕 单线程池 ( newSingleThreadExecutor) | ⭐⭐⭐业务零锁 耗时~100-300 ns(队列内存屏障)。注意:虽无锁竞争,但跨线程传递需刷新CPU缓存,比偏向锁慢。 | ⭐⭐⭐⭐稳定输出 耗时~1-5 μs,无业务锁争用,队列采用CAS或轻量锁,绝不膨胀。性能优于重量级锁。 | ⭐⭐吞吐量瓶颈 耗时~20-100 μs,单核处理能力上限成为瓶颈,队列开始积压,内存压力增大。 | ⭐严重积压/阻塞 耗时~100 μs-∞,单线程处理不过来,队列爆满导致提交线程阻塞(有界队列)或OOM(无界队列),吞吐量远低于AQS非公平。 | 严格的状态隔离 (如日志异步刷盘、单线程GUI事件派发,且业务QPS必须小于单核处理能力) | 依靠BlockingQueue(如LinkedBlockingQueue)实现生产者-消费者模型。完全消除共享状态,通过队列的happens-before内存语义传递数据。 |
JAVA提供了基于AQS实现的多种实现类,欲知后事如何请关注我。