☰
从状态量与等待队列看懂AQS:Java并发锁与同步器的底层原理
2026/10/13 16:32:52 网站建设 项目流程

1. 从最底层看懂 AQS:一个 int 状态量、一个等待队列,怎么撑起整个 JUC 生态

很多人在并发编程里待了几年,ReentrantLock、CountDownLatch 用得很熟,但一问到 AbstractQueuedSynchronizer(AQS)就有点发虚。这东西确实不好啃,因为它是 Java 并发包的基石,层级比各种 Lock 和同步器低得多。说句实在话,我也是在踩了一个线上故障的坑之后,才真正下定决心把 AQS 源码一行一行读完。读过之后你会发现,JUC 包里面那些看似神奇的锁、信号量、倒计时器,其实都是在 AQS 这棵大树上长出来的枝杈。

AQS 的核心可以用简单的一句话概括:它用一个 int 类型的 state 变量表示共享资源的状态,再用一个内部的双向队列来管理那些暂时没抢到资源的线程。这两个东西,一个管资源,一个管排队,配合起来就解决了并发编程里最棘手的问题——线程竞争与等待。

先说这个 state。它被 volatile 修饰,保证了对所有线程的可见性,但线程安全还得靠 CAS(Compare And Swap)操作来保证。为什么要这么设计?因为你在并发环境下把一个状态从 0 改成 1,如果不同步就会出现“丢更新”。用 synchronized 当然也行,但 AQS 为了性能和灵活性,选择了更底层的 CAS。你可以把 state 理解为银行的“服务窗口数”,当前有几个窗口在营业,什么状态,都记录在这里。不同的同步器对这个状态的定义不同:ReentrantLock 里 state 表示“锁被重入的次数”,Semaphore 里 state 表示“剩余的许可数量”,CountDownLatch 里 state 表示“还需要倒数的计数”。AQS 自己不管这个状态具体含义是什么,它只负责维护状态的原子更新和线程排队,具体业务逻辑全部交给子类去定义。这就是模板方法模式的精髓所在——骨架是固定的,肉长在哪里由你决定。

再说等待队列。这是一个双向链表,每个节点包装一个线程。AQS 里的队列和普通数据结构课上学到的链表不太一样,它没有显式的容量限制,节点是懒加载创建的,head 节点指向的是当前持有资源的线程(或者一个空节点),后面的节点就是排队等待的线程。所以你去读源码时会看到,初始状态下 head 和 tail 都指向一个 dummy 节点,这样做的好处是避免空指针判断,也让入队操作更简洁。

顺着这个思路,AQS 做的事情就很清晰了:先让线程尝试通过 CAS 修改 state 来获取资源,成功了就直接运行;失败了就把当前线程包装成 Node 放入队尾,然后进入等待状态,直到前一个节点把它唤醒。这套机制,本质上就跟你去银行办事一样:窗口空着,直接上前;窗口都有人在办,你就拿号排队;排到你的时候,叫号机提醒你去窗口。

2. 拆解 Node 节点:五个等待状态、双向指针和线程的 Choom 组合

如果只看到 AQS 是一个队列,那你对它的理解就还停留在表面。真正要深入,得把 Node 这层皮剥开。队列里的每个节点,内部保存了线程引用、前驱节点、后继节点,还有一个特别关键的成员变量 waitStatus。这个 waitStatus 可不是简单标记一下“等待中”就完事了,它记录了一个节点在整个等待生命周期中的状态迁移。

  • CANCELLED(数值 1):线程等待超时或被中断,节点被取消,这个状态是终态,不会再变。
  • SIGNAL(数值 -1):当前节点的后继节点正被 park 住,当前节点在 release 时需要 unpark 后继节点。
  • CONDITION(数值 -2):该节点在条件队列中,涉及 Condition 时才会用到。
  • PROPAGATE(数值 -3):共享锁场景下使用,表示 releaseShared 应该传播到其他节点。比如 Semaphore 释放一个许可时,需要把唤醒动作往后传。
  • 0:初始化状态。

这些状态之间的流转,对应着线程从入队到被唤醒的完整生命周期。如果你去看 Thread Dump,发现大量线程处于 WAITING(parking)状态,十有八九就是排在了 AQS 的队列里。

为什么 AQS 用双向链表而不是单向链表?这是我在看源码时最先产生的疑问。后来明白了,双向链表在取消节点时可以做到 O(1) 的复杂度,而且前驱节点的状态判断在锁释放时至关重要。你想,一个线程被唤醒后,它肯定得知道自己的前驱节点还在不在、状态如何;如果前驱被取消了,它得向前跳过失效节点,重新找一个正常的前驱来挂靠。如果是单向链表,这种“回头看”的操作就非常别扭,甚至需要从头遍历。AQS 的设计者 Doug Lea 在这个细节上体现出了极强的工程功力。

说到源码,我第一次看 Node 的字段时有点头皮发麻:

volatile int waitStatus; volatile Node prev; volatile Node next; volatile Thread thread; Node nextWaiter;

这里的 nextWaiter 字段也别小看,它在独占模式和共享模式下的含义不同。独占模式下,nextWaiter 指向的是常量 EXCLUSIVE(一个空的 Node 对象);共享模式下,nextWaiter 指向的是 SHARED。通过这个标记,代码就能区分当前节点是排他等待还是共享等待。你看,一个字段可以被复用出这么多层含义,这就是优秀设计的体现。

从工程的角度讲,Node 节点本身的设计还有一个非常有效的小技巧:它的字段几乎都有 volatile 修饰,但又不完全依赖 volatile 来保证多线程间的同步。因为在 AQS 的入队过程中,线程写之前会先通过 CAS 修改 tail,这样就能形成一种 happens-before 关系,让其他线程安全地看到新节点。这种“半同步半 CAS”的思路,比全链路用锁要高效得多。

3. acquire 到 release 全流程还原:线程竞争、入队、自旋 park 和唤醒的真实走位

前面铺垫了那么多,现在到了最核心的部分:一个线程从开始抢锁到被唤醒,中间到底经历了什么。很多人背过这个流程:尝试获取锁,失败就入队,入队就 park,等到被唤醒再尝试。但真正能把每一步理解透的人不多,因为这里有几个非常容易混淆的细节。

我们拿 ReentrantLock 的 lock() 方法举例,它最终会调用 AQS 的 acquire 方法:

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

这段代码就四行,但每一行都值得细品。

第一步 tryAcquire(arg)。这是一个由子类实现的方法,ReentrantLock 的公平锁和非公平锁对它有不同的实现。非公平锁很简单:上来直接 CAS 修改 state,成功了就说明抢到了锁;失败了再去看当前线程是不是已经持有锁的线程,是的话就重入加一。要注意,这里的第一步并没有排队检查。也就是说,一个新来的线程可以插队,和队列里已经等了半天的线程再竞争一次。这就是“非公平锁”的由来。

第二步 addWaiter(Node.EXCLUSIVE)。如果 tryAcquire 失败,线程就被包装成 Node,准备入队。入队逻辑也有一点讲究,它先尝试直接通过 CAS 把新节点追加到队尾,如果失败(说明有其他线程正在入队),才进入完整的 enq 方法,用自旋 CAS 保证成功入队。

第三步 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; // help GC failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }

注意这个自旋循环:节点入队之后,不会立刻 park,而是先检查一次自己的前驱节点是不是 head。如果是 head,就再试一次 tryAcquire。为什么要预留这一次“再尝试”?因为入队和获取锁之间有时间差,也许这时持有锁的线程刚刚释放了锁,正好被你赶上。如果前驱不是 head,或者再次获取锁失败,那就进入 shouldParkAfterFailedAcquire 和 parkAndCheckInterrupt。

shouldParkAfterFailedAcquire 这个方法是很多人的知识盲区。它做的事情不仅仅是判断是否应该 park,还包括对前驱节点状态的清理工作。它会读前驱节点的 waitStatus,如果前驱节点是 SIGNAL,说明前驱承诺会唤醒自己,那当前线程就可以放心去 park 了;如果前驱是 CANCELLED(大于 0),就往前跳过被取消的节点,直到找到一个正常的前驱;如果前驱是 0 或者 PROPAGATE,就用 CAS 把它改成 SIGNAL,让它承担唤醒自己的责任。这个状态的转变非常关键,如果没有它,线程在 park 之前可能会出现“通知丢失”的竞态问题。

接下来 parkAndCheckInterrupt 做的事情很简单,就是调用 LockSupport.park(this) 挂起当前线程。到这里,线程才算真正“睡在了队列里”。

释放锁的过程相对简单一些,关键在于 release 方法和 unparkSuccessor:

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 同样由子类实现,把 state 减少到 0 就表示锁释放了。然后拿到 head 节点,判断它的 waitStatus 如果不是 0,就说明后面有节点需要唤醒。unparkSuccessor 里的一个细节是:它先去 head.next 找直接后继,如果后继为 null 或者已被取消,就需要从 tail 往前遍历,找到离 head 最近的、没有被取消的节点来唤醒。

为什么从 tail 往前找而不是从 head 往后找?这是因为入队过程中,prev 指针的 CAS 操作先完成,next 指针的赋值在后,所以在竞争极端激烈的时候,tail 到 head 之间的 next 链可能是不完整的。而 prev 链总是可靠的,从 tail 往回遍历是安全的策略。这种细节不看源码根本想不到,面试问到了就是加分项,工作里遇见了就是防坑指南。

4. 同一套骨架,三种玩法:从 ReentrantLock、Semaphore 到 CountDownLatch 的具体实现对照

理解了 acquire/release 的全流程,你会发现自己看待并发工具的方式变了。因为你会清楚地看到:ReentrantLock 和 Semaphore,本质上是同一个洗衣机,换了一下程序而已。

4.1 ReentrantLock:公平与非公平的关键在一句判断

ReentrantLock 分为 FairSync 和 NonfairSync。非公平锁的 lock 方法开头直接就是:

final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }

你看,先试着 CAS 抢一把,抢到了就直接进来了。而公平锁的 lock 则直接走到 acquire(1),也就是必须经过队列检查:

protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }

hasQueuedPredecessors 是核心,它判断队列 head 的 next 是否为空、当前线程是不是排在队首。如果有其他线程在自己之前排队,公平锁就会主动让路,自己继续进入队列排队。所以说,公平与非公平,差别真的就是这“一个判断”。

重入性的实现也在这段代码里:当 current == ownerThread 时,state 直接累加。这就是为什么 lock 多少次就得 unlock 多少次,因为 state 的计数必须归零,锁才算真正释放。

4.2 Semaphore:共享模式下的 state 减法与 PROPAGATE 的妙用

Semaphore 和锁不同的地方在于,它是共享模式,多个线程可以同时“进入”。它的 acquire 最终调用的是 acquireShared:

public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); if (tryAcquireShared(arg) < 0) doAcquireSharedInterruptibly(arg); }

tryAcquireShared 返回的是剩余许可数量。如果大于等于 0 就说明获取成功,不需要排队;如果小于 0 就得进入队列。在 doAcquireSharedInterruptibly 中,一旦发现前驱是 head 并且 tryAcquireShared 成功(返回值大于等于 0),就不仅把自己唤醒,还会把唤醒动作往后传递。这就是 setHeadAndPropagate 方法里 PROPAGATE 状态的用途——保证释放一个许可时,多个等待线程能够依次获取,不让信号传递断掉。

这里有一个很容易踩坑的点:Semaphore 的“公平”与否,同样取决于构造时传入的 fair 参数。如果使用非公平模式,在高并发下,后到的线程可能会反复截胡队列中的等待者,导致某些线程长时间的饥饿。虽然在大多数场景下性能优先,但如果业务对等待时间敏感,请务必使用公平模式。

4.3 CountDownLatch:一次性闸门,也是共享模式的经典应用

CountDownLatch 的逻辑似乎更简单:初始化 state 为 N,每次 countDown 就减一,直到 0 时释放所有等待线程。它的 await 方法调用 acquireSharedInterruptibly,而 tryAcquireShared 只有 state == 0 才返回 1(成功),否则返回 -1。当主线程 await 时,它进入队列并 park。当计数器归零时,releaseShared 触发 doReleaseShared,唤醒队列中的第一个等待者,然后第一个等待者被唤醒后通过 PROPAGATE 继续唤醒后面的等待者,相当于开闸放水,一次清空所有等待线程。

这个“传播唤醒”的设计在锁里是看不到的。锁只需要唤醒第一个排队的人,而 CountDownLatch 需要唤醒所有排队的人。这就是独占模式和共享模式的最大区别。理解了这一点,再去看 CyclicBarrier 和 CountDownLatch 的区别,你就不会停留在“CyclicBarrier 可复用”这种表面结论上了。

5. 抛开八股谈实战:中断响应、排队公平性和锁竞争的“度”

把 AQS 的骨架讲完了,最后想聊一些在实战和面试中容易让人挠头的问题。这些问题光看理论是弄不明白的,需要在真实业务里见过了才有体感。

5.1 中断与 AQS 的相爱相杀

我们熟知的 lock() 方法,是不响应中断的。也就是说,线程在 AQS 队列里 park 着,你即使对它调用了 interrupt(),它也会继续在队列里等,直到它真正拿到锁之后,才补报一个中断标记。这个行为在源码里就是 acquireQueued 里仅记录 interrupted 布尔值,最后通过 selfInterrupt() 再补一次。

而 lockInterruptibly() 就截然不同。它调用的是 acquireInterruptibly,在入口处直接检查Thread.interrupted(),park 之后也会通过 LockSupport.park 对中断做出响应。这里的区别非常重要。如果你在一个需要响应取消或超时的场景里使用了 lock(),线程可能无法及时退出。比如,某个线程调用了阻塞 API,外部系统发起了一个取消请求,你却因为“不可中断锁”等在那里,导致资源无法释放。

5.2 超时获取锁:timed acquire 的边界问题

tryAcquireNanos 这种带超时的获取方式,在代码里并不是简单地“尝试一次”,而是带着 deadline 去循环尝试的:

private boolean doAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { if (nanosTimeout <= 0L) return false; final long deadline = System.nanoTime() + nanosTimeout; ... for (;;) { ... if (nanosTimeout <= 0L) // 超时了,取消获取 return false; ... LockSupport.parkNanos(this, nanosTimeout); // park 限时沉睡 nanosTimeout = deadline - System.nanoTime(); // 重新计算剩余时间 } }

很多人写代码时不注意这个重新计算剩余时间的逻辑,以为传入一个固定值就完事了。实际上每次 park 被唤醒后,都要基于 absolute deadline 重新计算剩余时间,防止虚假唤醒或者 park 时间不准导致超时失效。这种“deadline 优先于 duration”的写法,在并发编程里是一种重要的时间处理思维,值得借鉴。

5.3 锁竞争激烈时,AQS 也会成为瓶颈

AQS 虽然设计精巧,但它不是银弹。在锁竞争极度激烈的场景下,线程频繁 park 和 unpark 会带来不小的上下文切换开销。线程被 park 后,操作系统要把它的状态保存起来,CPU 去执行其他线程;唤醒时,又要恢复现场。一两个线程还好,几百个线程同时排队抢占一把锁,哪怕每次持有锁的时间只有 200 纳秒,这种用户体验也可能让你直接在想哭的边缘。

碰到这种情况,你需要的不是更复杂的锁,而是考虑降低锁的粒度、或者换用 LongAdder、StampedLock 等更贴合具体场景的类。我在一个项目里曾经用 ReentrantLock 保护一个高频热点的计数器,当时压测 TPS 上不去,后来发现线程全在 AQS 队列里排队,改成长度的 AtomicLong 加定期刷新到数据库之后,问题彻底解决。所以在实战里,时刻要问自己:我真的需要锁吗?没有锁的替代方案是什么?

5.4 面试中的 AQS:会背不如会推演

这两年面试风向也变了,面试官不太喜欢听你背“state 加 CLH 队列”。他们更愿意给出一个具体的并发场景,让你现场分析和设计。比如问:有 100 个线程同时调用一个资源,你怎么保证最大并发数是 N?一旦你说出 Semaphore,他马上追问公平与非公平的实现差异。如果你能把前面提到的 hasQueuedPredecessors、PROPAGATE 状态、传播唤醒机制讲清楚,才算真正把 AQS 吃透了。

再说一个我在实践中受益很深的点:调优或者排查线上线程死锁时,jstack 打出来的线程堆栈常常会指向 AbstractQueuedSynchronizer 的 parkAndCheckInterrupt 或者 LockSupport.park。如果你看不懂堆栈,就非常容易慌。现在再看到这一幕,我就知道这个线程不是死锁,它只是在排队等待锁资源,真正死锁往往表现为两个或多个线程互相持有一把锁并又去等另一把。搞清楚这两者的区别,能给你的排查省下至少半天时间。

最后分享给大家一个我的阅读方法:不要把 AQS 当一个孤立的类去读,而要把自己代入到“一个带着线程的 Node 节点”的角色里。想象你在排队,你的前驱是谁,你是被谁唤醒的,你醒了之后要做什么,什么时候你会放弃排队。把这些问题想明白了,AQS 的源码也就没有秘密可言了。

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

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

立即咨询