☰
Java并发基石AQS源码详解:从state状态位到CLH队列锁原理
2026/10/11 7:32:52 网站建设 项目流程

做Java开发到一定阶段,很难绕过AQS这个名字。AQS全称AbstractQueuedSynchronizer,翻译过来是抽象队列同步器,几乎所有你在并发编程里用到的锁和同步工具,ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock,甚至ThreadPoolExecutor内部的Worker,底层都直接或间接建立在它上面。很多人在面Java并发时被问到一个问题:你讲讲AQS的原理?然后就没有然后了。这篇博客我打算把AQS的骨架彻底摊开讲清楚,包括state状态位、CLH变体等待队列、获取锁和释放锁的完整链路、公平与非公平的实现差异,再配上我实际排查线程问题时的经验教训。适合已经写过synchronized、用过ReentrantLock但没仔细看过源码的人,也适合准备面试想系统梳理并发基础的读者。

1. AQS到底解决了一个什么问题

1.1 为什么Java要单独造一个AQS

很多人一开始学并发时会觉得奇怪:synchronized不是已经能解决线程互斥了吗?为什么还要搞出一个AQS出来?这就要从synchronized的局限性说起。

早期的synchronized性能确实不怎么样,依赖监视器锁,竞争激烈时会有重量级锁的开销。后来JVM做了很多优化,偏向锁、轻量级锁、锁膨胀,性能其实已经很好了。但关键是synchronized提供的功能太“死板”:它没法响应中断,线程拿着锁死等;它没有超时机制,不能设置最多等多久;它没有公平性选择,谁抢到算谁的;它一个监视器对象只能绑一个等待队列,实现多个条件变量很麻烦。

业务场景里经常需要“等一个条件满足了再继续”,比如连接池满了要等待连接释放,但不能无休止地等,超过3秒就该失败。这种需求用synchronized很难干净地实现,而用ReentrantLock配合Condition就能优雅解决。为了让各种锁和同步器都能复用同一套线程阻塞和唤醒机制,Doug Lea在JDK 1.5里设计了AQS这套通用框架。说白了一句话:AQS把“抢锁、排队、阻塞、唤醒”这套通用流程管起来,具体“什么条件下可以抢到锁”由子类说了算。

1.2 一个状态位加一个等待队列

AQS的设计核心可以浓缩为两个关键概念:一个是int类型的state状态位,另一个是双向的CLH变体等待队列。

state是个volatile int字段,代表同步状态。对不同的同步器,这个state的含义完全不一样。在ReentrantLock里它表示持有锁的次数,0代表没人持有,大于0代表重入了几次;在Semaphore里它表示剩余的许可证数量;在CountDownLatch里它表示还需要倒计数的次数。怎么修改state呢?AQS提供了getState、setState和compareAndSetState三种基本操作,底层依赖CAS来保证原子性。

有线程申请锁、但state不满足条件时,这个线程不能就这么干等,它必须有个地方去“排队”。AQS内部维护了一个双向链表组成的队列,每个排队线程被封装成一个Node节点。入队和出队都通过CAS操作完成,支持线程的阻塞和唤醒。这个队列用的是CLH锁的变体,后面我会单独讲它跟原始CLH锁的差异。

1.3 模板方法模式:框架和子类各干各的活

AQS最漂亮的设计是用模板方法模式把“流程”和“决策”拆开。父类把获取锁、释放锁的整体流程用final方法写死,子类只需要重写几个空方法来决定某一种同步器的具体逻辑。

这几个需要子类实现的方法是:tryAcquire(int arg)、tryRelease(int arg)、tryAcquireShared(int arg)、tryReleaseShared(int arg)、isHeldExclusively()。前两个用于独占锁,比如ReentrantLock;中间两个用于共享锁,比如Semaphore、CountDownLatch;最后一个用于判断当前线程是否持有独占资源。

AQS的公开方法,比如acquire(int arg)、release(int arg)、acquireShared(int arg)、releaseShared(int arg),它们负责完成“尝试失败后入队、入队后自旋、必要时阻塞、释放后唤醒后继”这些复杂流程。子类只管回答“我能不能拿到这个锁”“我能不能释放这个锁”。这种设计的好处在于,不管实现多少种同步器,队列管理、线程调度、中断处理的代码只存在一份,不容易出错,而且每个同步器只要写很少的逻辑就能复用完整能力。

2. 核心机制逐层拆解:state、Node节点与CLH变体

2.1 state状态位:所有同步逻辑的中枢

先说state这个字段。在AQS里它被定义为private volatile int state,修饰词很讲究。volatile保证多线程之间的可见性,int而不是long是因为32位就够了,而且在大多数平台上int的CAS操作非常高效。AQS暴露了三个操作state的方法:getState、setState、compareAndSetState。

compareAndSetState依赖sun.misc.Unsafe的compareAndSwapInt实现,这是一个CPU级别的原子操作,底层指令是cmpxchg。当多个线程同时对state做CAS操作时,只有一个线程能成功,其他线程会失败,失败的线程就会进入后面的排队流程。这种“先试一把CAS,不行再排队”的思路,在后面的非公平锁里会被用到极致。

state的语义由子类自行定义,这是AQS灵活性所在。举个例子:ReentrantLock刚初始化时state为0,线程第一次lock成功,通过CAS把state改成1,同时记录当前独占线程是自己;同一个线程再次lock,state变成2,代表重入了一次;释放时state递减,减到0才真正释放锁。而Semaphore初始化时state就是许可证总数,每次acquire相当于做一次state减1的CAS,减到负数说明许可证不够了,线程去排队,每次release是state加1,唤醒等待线程。

2.2 Node节点:等待队列里的“人”

每个排队等待的线程在AQS里都被包装成一个Node对象,这个Node是AQS内部类,字段不少,每个字段都有明确的含义。

  • thread:当前被阻塞的线程本身,入队时赋值,被唤醒获取到锁后置为null。
  • waitStatus:节点当前的等待状态,包括CANCELLED(1)、SIGNAL(-1)、CONDITION(-2)、PROPAGATE(-3)和初始值0。
  • prev和next:双向队列的前驱和后继指针。
  • nextWaiter:在条件队列里使用时,指向下一个等待条件满足的节点;在普通同步队列里也有特殊用途,比如标记当前节点是共享模式还是独占模式。

waitStatus是很多人容易忽略但又特别重要的字段,我单独做张表说明:

状态值名称含义
1CANCELLED节点等待超时或被中断,需要从队列中移除
-1SIGNAL当前节点的后继节点处于等待状态,当前节点释放锁后需要唤醒后继
-2CONDITION节点在条件队列中等待,不在同步队列中
-3PROPAGATE共享模式下,唤醒要向后传播
0无状态新节点初始状态

很多讲AQS的文章只在讲“入队出队”时介绍waitStatus,但其实它才是保证队列正确运行的关键。特别是SIGNAL状态,一个节点在想睡眠之前,必须先把自己的前驱节点状态置为SIGNAL,意思是告诉前驱:“你释放锁的时候记得叫醒我。”如果不设置这个状态,前驱释放锁时根本不知道后面还有人等着,就会漏唤醒。

2.3 入队与出队:CLH变体队列到底改了什么

AQS的等待队列脱胎于CLH锁,CLH锁是Craig、Landin、Hagersten三位作者提出的一种自旋锁,核心思路是让每个线程通过CAS把自己追加到队列尾部,并且不断轮询前驱节点的状态来判断自己是否获得了锁。

原始的CLH锁有几个问题:它主要面向自旋场景,所有线程都在CPU上轮询,非常浪费资源;而且它一般没有处理线程取消、超时的逻辑。AQS在这个基础上做了变体,所以源码注释里叫“CLH variant”。关键改动有三点。

第一,由自旋改为阻塞加自旋结合。新入队的线程不会无限自旋,它先自旋一小段时间尝试获取锁,如果还拿不到,就让出CPU进入阻塞状态,依赖前驱节点在释放锁时去唤醒它。

第二,增加显式的prev和next双向指针。原始CLH锁通常只需要前驱指针,AQS增加了后继指针,这样阻塞唤醒时可以从head节点一路往后找到真正需要唤醒的节点。这里有一个细节:入队时prev指针用CAS更新,所以prev是可靠的,而next指针在节点被取消时可能断掉,因此遍历队列找后继时如果发现next为null,需要从tail往前找,这是很多源码分析文章反复强调的“从后往前找”的原因。

第三,用head节点作为哨兵。队列中第一个实际等待的节点不是head,head是一个已经获取到锁的线程的节点(获取成功后setHead会把自己的thread清空),真正的等待线程从head.next开始。这么做的好处是,唤醒时判断条件变得简单且安全。

3. 从源码看获取锁与释放锁的完整链路

3.1 获取锁:tryAcquire失败后到底发生了什么

以独占锁为例,AQS对外提供的最核心方法是acquire(int arg),源码逻辑很紧凑:

public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }

先调用子类实现的tryAcquire尝试获取一次锁。如果成功,整个流程直接结束,线程继续执行,这是最理想的情况,性能最好。如果失败,先调用addWaiter包装一个独占模式的Node入队,再调用acquireQueued对当前线程进行“自旋等待获取”的过程。最后,如果acquireQueued返回true,说明等待期间线程被中断过,但AQS不会直接在中断时抛出异常,而是补上selfInterrupt把中断状态重新设置给调用者,让外层代码自行判断。

addWaiter的逻辑是:先把当前线程包装成Node,然后通过CAS把自己追加到队列尾部。如果尾部还没初始化,或者CAS失败,就进入enq方法。enq里是一个for循环,配合CAS不断重试,直到成功入队。这实际上是AQS里最常见的“CAS自旋”套路:并发修改队列尾部时,多个线程只有一个能成功,失败的在下个循环继续试,不会死锁也不会丢线程。

3.2 acquireQueued:排队后的自旋、睡眠与被唤醒

acquireQueued是整个阻塞等待的核心,代码看起来不长,但每一行都有自己的讲究:

final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }

新节点入队后进入一个死循环。在每次循环里,先检查自己的前驱节点是不是head。head是当前持有锁的线程所在节点,如果自己的前驱是head,说明轮到自己了,就再试一次tryAcquire。这里有一个容易被忽略的设计:为什么不是一入队就直接阻塞等待唤醒?因为从入队到真正park之间是有时间差的,等待唤醒的开销很大,不如在自旋里多试几次,如果恰好锁被释放了,直接拿锁更好。这就是前面说的“自旋加阻塞结合”的体现。

tryAcquire失败后,进入shouldParkAfterFailedAcquire。这个方法检查前驱节点的waitStatus,决定当前线程要不要真的阻塞。如果前驱是SIGNAL状态,说明前驱释放锁时会唤醒自己,那就放心地park。如果前驱是CANCELLED状态,说明前驱已经放弃了等待,那就往前跳过这些取消节点,重新调整队形。如果前驱状态是0或PROPAGATE,则通过CAS把前驱状态更新为SIGNAL,然后进入下一次循环。为什么要先把前驱改成SIGNAL再去park?因为如果不设置这个标记,释放锁的线程看不出队列里还有人等着,就不会调用unpark,这样当前线程可能永远睡不醒。

parkAndCheckInterrupt做的就是LockSupport.park阻断当前线程,线程在这里停住,直到被前驱节点unpark唤醒。唤醒后再回到循环开头,重新执行“前驱是head?再试一次tryAcquire”的逻辑。如果获取失败就继续park,如果成功就setHead。

3.3 释放锁:唤醒后继者的细节处理

释放锁的入口是release(int arg),同样很短:

public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; }

tryRelease由子类实现,只有在独占锁彻底释放时才会返回true。以ReentrantLock为例,state需要减到0才算真正释放,仅仅从2变成1不会触发唤醒,因为锁还被重入一次。这也是可重入锁在“释放次数不匹配”时容易坑人的原因:lock了两次却只unlock一次,锁永远不释放,其他线程永远等下去。

真正要唤醒时,并不是唤醒队首的第一个等待节点,而是通过unparkSuccessor找到距离head最近的一个非取消节点来唤醒。实现里有个关键细节:从头节点往后找后继时,如果next为null或者后继节点已经是CANCELLED,就要从tail往回遍历找。原因前面提过,取消节点的next链接可能断裂,但prev指针是可靠更新的,所以反方向扫描更稳妥。唤醒也不是直接让线程去tryAcquire,而是执行LockSupport.unpark,让线程从parkAndCheckInterrupt里醒来,回到acquireQueued的循环里重新争抢。这样设计的好处是,把唤醒和抢锁解耦,锁状态的管理始终保持在AQS内部,不会出现刚唤醒了才发现锁又被别人抢走还是要重新park的状态错乱。

3.4 中断与超时获取:两个常用变体方法

独占锁除了标准的acquire,还有acquireInterruptibly和tryAcquireNanos,一个支持响应中断,一个支持超时。它们的核心实现分别是doAcquireInterruptibly和doAcquireNanos,流程跟acquireQueued非常像,区别在于park醒来后如果发现线程被中断:

  • doAcquireInterruptibly会直接抛出InterruptedException,而不是默默设置中断标志。
  • doAcquireNanos会先检查剩余时间是否足够,如果剩余时间小于一个阈值(spinsForTimeoutThreshold,默认1000纳秒),就放弃park,直接进入自旋快速尝试,因为park/unpark的系统调用开销可能比自旋还大。

这个阈值判断非常实用,也是AQS里一个很值得借鉴的性能优化点:当等待时间极短时,阻塞唤醒的开销大于自旋轮询,干脆不阻塞了。理解了这一点,在写自己的一些并发控制逻辑时也能用到类似思路。

4. 公平、非公平与多种同步器实现

4.1 非公平锁为什么“快”

ReentrantLock的默认实现是非公平锁,内部是NonfairSync。它跟公平锁最大的区别在于lock方法的第一行:直接做了一次CAS,尝试把state从0改成1。如果成功,不管队列里有多少线程在排队,这个新来的线程直接拿到锁。

为什么允许这种“插队”行为?因为大多数情况下锁竞争并不激烈,新线程直接抢一次能省掉入队、park、unpark、再抢锁这一整套过程,整体吞吐量更高。代价是队列里的线程可能被长时间饿着,但统计上来说线程总有轮到的时候,所以工程上默认采用非公平是合理的。我把两者对比写出来:

对比项非公平锁公平锁
首次获取锁先直接CAS抢一次先判断队列中是否有前驱等待
是否排队不完全按先来后到严格FIFO
吞吐量更高略低
适用场景默认场景、追求性能对接业务要求严格公平、避免线程饿死
实现关键lock里先compareAndSetStatehasQueuedPredecessors判断

4.2 可重入计数是怎么实现的

ReentrantLock的可重入体现在两个细节。第一,重入时state不是置为1而是累加当前值加1:在线程已经持有锁的情况下,再次lock会执行“如果当前线程是独占线程,则setState(nextc)”,把state加1。第二,释放时state递减,直到decrement到0才认为锁真正释放,同时把独占线程owner清空。

这时候再看isHeldExclusively方法就清楚了:它判断当前线程是不是owner线程。ReentrantLock里的tryAcquire会先判断当前state是否大于0,如果是,再看当前线程是否是独占线程,不是的话直接返回false,线程就要去排队,而不会发生一个线程暴力改掉别人持有中的锁这种事故。这套逻辑在ReentrantReadWriteLock里也类似,只是state被拆成高16位和低16位,分别表示读锁和写锁的持有情况。

4.3 从AQS派生出的常见同步器

  • Semaphore:使用共享模式。state表示剩余许可数,acquire时tryAcquireShared做“当前许可数减1”的CAS,如果结果小于0就入队等待;release时tryReleaseShared加1,并唤醒等待线程。
  • CountDownLatch:同样共享模式。初始化state为倒计数总数,countDown时releaseShared逐次递减,等state归零时,所有await的线程会被统一唤醒。
  • ReentrantReadWriteLock:把state拆成两部分,高16位是读锁计数,低16位是写锁计数。读写、写写互斥,读读不互斥,代码实现要比ReentrantLock复杂得多,但AQS依然只提供一套队列管理机制来支撑。
  • ThreadPoolExecutor中的Worker:Worker继承AQS,实现了一个不可重入的独占锁。它用state从0变1来标记工作线程是否空闲,这样在shutdown时可以防止正在执行任务的线程被中断,而空闲线程不受影响。

5. ConditionObject:AQS里的条件队列

5.1 await和signal的内部流转

除了同步队列,AQS还通过内部类ConditionObject实现了Condition接口,这就是Java里经典的生产者消费者等待机制。每个Condition对象内部维护着一个由Node节点串起来的条件队列,用的是nextWaiter指针,不是同步队列里的next指针。

调用condition.await()时,当前线程必然是持有锁的。它会被封装成一个waitStatus为CONDITION的Node,加入到条件队列尾部,然后调用fullyRelease把持有的锁全部释放掉(要处理重入,所以是全量释放),最后通过LockSupport.park阻塞自己。这一步很关键:必须先释放锁再阻塞,否则其他线程没法拿到这把锁去执行signal,就永远唤不醒它。

signal()做的事恰好相反:找到条件队列里的第一个节点,调用transferForSignal把它从条件队列移到同步队列尾。这个转移过程核心是把节点的waitStatus从CONDITION改成0,再用CAS把它追加到同步队列尾部。转移到同步队列后,这个节点并不会被立刻唤醒,而是等它的前驱节点释放锁时,走正常的unparkSuccessor流程来唤醒它。这也解释了为什么signal之后需要很多线程重新抢锁而不是立刻执行。

5.2 条件队列与同步队列的分工

很多初学者会把两个队列搞混。同步队列里排队的线程是想获取锁而没拿到锁的线程;条件队列里排队的线程是已经拿到了锁、但是因为某个业务条件不满足而主动让出锁等待的线程。一个线程在同一时刻只能位于其中一个队列。await把线程从同步队列“挪”到条件队列,signal再把线程从条件队列“挪”回同步队列。两个队列各自独立,但都复用Node这个结构,AQS用一个类把这套机制统一了起来。

我在真正搞懂这段逻辑之前,一直很奇怪:明明是同一个线程在等,为什么waitStatus会有两种情况?理解了条件队列后就很自然了:在条件队列里是CONDITION状态,一旦被signal转移到同步队列,状态就变成0,等它的前驱释放锁时又会被改成SIGNAL。每一环都指向一个明确的语义。

6. 常见的坑与排查经验

6.1 一眼定位线程卡在AQS哪里

线上遇到线程一直不返回,最常见的手段是生成jstack线程转储文件。如果是卡在锁上,状态会显示为WAITING (parking)或者BLOCKED,堆栈里会往下找到LockSupport.park、AbstractQueuedSynchronizer.acquireQueued这些方法。

我踩过的一个比较隐蔽的坑是:两个线程互相持有对方需要的锁,形成死锁。jstack能直接检测出来,它会打印“Found one Java-level deadlock”并列出等待环。但还有一种情况更麻烦:多个线程都在AQS的队列里等待,队列里的节点因为某些原因一直没有被唤醒。这种问题不太可能是AQS本身的bug,而是业务代码的问题,比如Condition的signal没有被执行,或者Unlock少了一次导致state没有归零。遇到这种问题,我会先看线程状态是WAITING还是BLOCKED,再看这个线程有没有对应的持有者线程在正常运行。从一个锁关联的所有线程整体分析,思路会更清楚。

另外,Arthas是我排查并发问题时的常备工具。它可以直接看某个对象的内部字段,我经常用它查看AQS的head、state和tail字段,能直观看到排队线程数和锁状态。在分析压测中的锁竞争时,比看堆栈效率高很多。

6.2 五个容易踩的坑

  • 可重入锁的lock/unlock次数不匹配。lock两次却只unlock一次,锁永远不会真正释放。用try-finally包裹业务代码时,finally里只放一个unlock,但业务里可能调用了多次lock的重入方法,这种不对称会导致其他线程全部阻塞。
  • 在持锁时做耗时的网络IO或数据库查询。这会把锁的竞争时间拉长,队列里的线程大量堆积。别人看起来是锁没释放,本质是临界区太大。
  • Condition判断条件时不用while循环。await被唤醒是“可能条件满足了”,不是“一定满足了”,要用while重新检查条件。比如在等待队列大小的场景,多个生产者消费者协作时很容易出问题。
  • 把AQS当成自旋锁用,让大量线程在acquireQueued里长时间自旋。AQS的默认行为会让线程很快park,但如果前驱节点更新SIGNAL状态失败,或者重写了tryAcquire导致CAS一直失败,线程可能在循环里出不来。
  • 直接继承AQS而不是使用已经封装好的同步器。AQS设计出来是给同步器开发者用的框架,业务代码直接继承AQS是极不推荐的,因为你很难把所有边缘情况处理好。

6.3 学习AQS的几条实战心得

如果想让AQS这块知识真正变得可用,我建议动手做三件事:第一,源码阅读不要逐行看,先抓住state和队列这两个锚点,然后把acquire和release的流程画出来,每次方法调用要搞清楚它改了什么字段。第二,自己写一个自定义同步器,比如实现一个只允许N个线程同时通过的门禁,只需要重写两个方法,按我前面的思路用state计数就能跑起来,比看十遍源码都有效。第三,遇到并发问题优先怀疑业务代码而不是框架代码,AQS在JDK里运行了这么多年,绝大多数问题都出在线程间协作逻辑上。

个人体会最深的一点:AQS并不是一个需要你“背下来”的算法,而是一个关于“如何高效地让线程排队和唤醒”的工程答案。它的每一项设计几乎都能在现实世界里找到对应:银行窗口办业务,有人成功取号,有人排队等待,叫号员只喊下一位,过号要重新排队。理解了真实场景里“等待”这件事需要什么,回看源码时就不会觉得晦涩了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询