ReentrantLock 的 lock 方法执行 CAS 修改 state 失败后,线程会被封装为 Node 插入 CLH 队列尾部

ReentrantLock 的 lock 方法执行 CAS 修改 state 失败后,线程会被封装为 Node 插入 CLH 队列尾部。CountDownLatch 和 Semaphore 同样复用这套队列,只是 state 的初始值与递减逻辑不同。

AQS 的核心数据结构是 volatile int state 和一个 CLH 队列。ReentrantLock 把 state 当作重入计数器,初始值为 0。第一次调用 lock 时,线程通过 compareAndSetState(0, 1) 把 state 从 0 改成 1,成功则获得锁。失败的线程会进入 acquire 方法,先调用 tryAcquire 再次尝试,仍然失败就调用 addWaiter 把当前线程包装成 Node,插入队列。

Node 有三种模式:CANCELLED、SIGNAL、CONDITION。默认是 SIGNAL,表示后继节点需要被唤醒。CLH 队列是虚拟的双向链表,head 节点通常是哨兵,tail 指向最后一个等待节点。入队时先把新 Node 的 prev 指向当前 tail,再通过 CAS 把 tail 更新为新 Node。如果 CAS 失败,就自旋重试,直到成功。这套机制保证了队列在高并发下也能正确追加节点。

CountDownLatch 把 state 初始化为计数器数值,每次 countDown 就调用 release 把 state 减 1。当 state 减到 0 时,所有 await 的线程被唤醒。Semaphore 则把 state 当作剩余许可数,acquire 方法尝试 CAS 减少 state,成功则获得许可,失败就排队。两者的队列操作和 ReentrantLock 完全一样,只是 state 的语义和加减规则不同。

这种设计让 AQS 成为并发工具的统一底层。开发者不用自己写队列和 CAS,只需实现 tryAcquire 和 tryRelease 两个模板方法,就能得到完整的排队、阻塞、唤醒逻辑。理解 state 和 CLH 队列的配合,是读懂 ReentrantLock、Semaphore、CountDownLatch 等类的关键。

state 在 ReentrantLock 中记录重入次数而非简单 0/1

ReentrantLock 的 state 字段不是简单的 0/1 锁标志,而是记录当前持有锁的线程的重入次数。state 为 0 表示锁空闲,大于 0 表示已被占用。getState() 返回当前值,setState(int) 直接修改,compareAndSetState 则是 CAS 操作。

在 tryAcquire 方法里,先获取当前 state 值。如果 state 为 0,就尝试 CAS 从 0 到 1,同时把 exclusiveOwnerThread 设置为当前线程。如果 state 不为 0,则检查 exclusiveOwnerThread 是否是当前线程,是则说明重入,把 state 加 1 后返回 true。这就是重入的完整逻辑。

释放锁时调用 tryRelease,先判断当前线程是否是持有者,不是则抛 IllegalMonitorStateException。接着把 state 减 1,如果减完后 state 等于 0,就把 exclusiveOwnerThread 清空并返回 true。只有 state 回到 0 时才会真正释放锁,唤醒队列中的下一个线程。

这种设计直接支持了 synchronized 的可重入语义。同一线程多次 lock 不会把自己阻塞,也不会导致死锁。state 的高位有时还被用来记录公平/非公平模式或读写锁的读写状态,但 ReentrantLock 主要用低位计数。

实际编码时要注意,state 是 volatile 的,读写都走内存屏障,保证可见性。但重入次数不能超过 Integer.MAX_VALUE,否则会溢出,不过实际中极少遇到。理解 state 的重入逻辑,能避免误以为 lock 只能被一个线程拿一次的错误认知。

CLH 队列通过双向链表和 CAS 完成入队出队

CLH 队列本质是一个由 Node 组成的双向链表,每个 Node 持有 thread、waitStatus、prev、next 四个关键字段。prev 和 next 指针把所有等待线程串成链。head 指向队首,通常是一个空 Node,tail 指向队尾。

入队操作在 addWaiter 方法中完成。新建一个 Node,把它的 prev 指向当前 tail,然后执行 compareAndSetTail 把 tail 更新为新 Node。如果成功,再把前一个 tail 的 next 指向新 Node,形成完整链表。如果 CAS 失败,说明有其他线程同时入队,就调用 enq 方法自旋重试,直到 CAS 成功。

出队发生在 unlock 后的 unparkSuccessor 方法中。它找到 head 的下一个节点,如果该节点被取消,就遍历找到第一个非取消的节点,然后把 head 指向它,并调用 LockSupport.unpark 唤醒该节点的线程。被唤醒的线程在 acquireQueued 方法里再次尝试获取锁,成功则把自身从队列移除。

CLH 队列的关键优势是每个节点只自旋等待前驱节点的 state 变化,避免了传统队列每个线程都去竞争同一把锁导致的惊群效应。Node 的 waitStatus 为 SIGNAL 时,表示后继需要被唤醒;为 CANCELLED 时表示该节点已取消,需要跳过。

源码中大量使用 CAS 来更新 tail、head 和 waitStatus,保证无锁入队和出队。这套机制让 AQS 在高并发场景下依然保持较高性能。理解指针的指向和 CAS 的重试逻辑,是排查队列卡死问题的必备知识。

Semaphore 的 state 直接表示剩余许可数量

Semaphore 把 state 直接当作剩余许可数量。构造时传入的 permits 值就是 state 的初始值。acquire 方法调用 tryAcquireShared,内部通过循环 CAS 把 state 减去需要的许可数。如果 state 足够,就成功返回;如果不够,就返回负数,进入 doAcquireShared 把线程加入队列。

release 方法调用 tryReleaseShared,把 state 加上 1,并唤醒队列中等待的线程。Semaphore 支持公平和非公平模式,公平模式下会检查队列中是否有等待线程,非公平则直接尝试 CAS。

与 ReentrantLock 对比,Semaphore 的 state 没有重入概念,每次 acquire 和 release 都是对许可的增减。state 可以大于初始值,也可能被减到 0。tryAcquire 方法还支持超时和非阻塞版本,灵活性更高。

实际场景中,Semaphore 常用来控制并发访问资源的线程数,比如数据库连接池限制同时查询线程。state 的值直接反映当前可用资源数,调试时打印 getAvailablePermits 就能看到实时剩余许可。

如果使用不当,比如只 acquire 不 release,state 会一直减少,最终所有线程都阻塞在队列里。源码中 release 没有检查调用者是否之前 acquire 过,这点和 ReentrantLock 的 owner 检查不同,需要开发者自己保证配对使用。

条件变量 await 把节点从同步队列移到条件队列

ConditionObject 是 AQS 的内部类,每个 Condition 维护一个单独的等待队列。调用 await 时,先把当前线程封装成 Node,节点类型为 CONDITION,然后加入 condition 队列尾部。接着调用 fullyRelease 把持有的锁完全释放(state 归 0),并把节点从同步队列的 CLH 链表中移除。

fullyRelease 会循环调用 tryRelease,直到 state 为 0。如果释放过程中发现当前线程不是锁持有者,会抛出异常。释放成功后,线程调用 LockSupport.park 把自己挂起,等待 signal。

await 方法还支持带超时和可中断版本,中断时会把节点从 condition 队列移除并抛 InterruptedException。节点在 condition 队列中时,其 waitStatus 是 CONDITION,prev 和 next 只在 condition 队列内有效,和同步队列的指针是分开的。

这一步实现了「释放锁并等待」的原子操作,避免了释放锁后还没来得及 park 就被 signal 导致的信号丢失问题。源码中 transferAfterCancelledWait 等方法处理了中断和 signal 竞争的情况,保证节点最终要么在 condition 队列,要么回到同步队列。

理解 await 的节点迁移路径,能帮助开发者看清「锁释放-线程挂起-节点转移」三部曲,这是 Condition 正确使用的核心。

signal/signalAll 触发节点从条件队列返回同步队列

signal 方法从 condition 队列头取出一个节点,调用 transferForSignal 把节点迁移到同步队列。迁移时把节点的 waitStatus 从 CONDITION 改成 0,并把节点插入 CLH 队列尾部。如果前驱节点的 waitStatus 是 CANCELLED,就直接唤醒该节点,否则把前驱的 waitStatus 设为 SIGNAL。

signalAll 会把 condition 队列里的所有节点依次迁移到同步队列。迁移后的节点会参与正常的锁竞争,调用 acquireQueued 再次尝试获取锁。获取成功后,线程从 await 方法返回,继续执行后续代码。

整个过程不直接唤醒线程,而是把节点放回同步队列,让队列机制统一处理唤醒。这样做的好处是避免了线程过早被唤醒却拿不到锁的问题,符合「先入队再竞争」的 CLH 设计。

signal 之后,被迁移节点的线程会在下一次调度时被 unpark。调度延迟可能导致 signal 后短时间内线程仍处于等待状态,这是正常现象。源码中 isOnSyncQueue 方法用来判断节点是否已经回到同步队列,避免重复迁移。

在生产环境中,signal 通常放在 finally 块里,确保异常情况下也能唤醒等待线程。signalAll 适用于需要广播状态变更的场景,比如所有等待同一条件的线程都需要被通知。

忘记 unlock 或 signal 会导致队列节点永久阻塞

实际开发中最常见的坑是忘记在 finally 里调用 unlock。ReentrantLock 加锁后如果中间抛异常,锁没有释放,后续所有尝试获取锁的线程都会进入 CLH 队列永久等待。state 一直大于 0,head 后面的节点永远无法被 unpark。

Condition 的 signal 忘记调用同样致命。await 的节点停留在 condition 队列里,永远不会被迁移回同步队列。即使锁被释放,线程也拿不到信号,表现为线程一直阻塞在 park 调用上。线程 dump 时会看到大量 WAITING 状态的线程,栈帧停在 await 方法。

另一个坑是错误使用 Condition。多个线程 await 在同一个 Condition 上,却只调用了一次 signal,导致部分线程永远醒不过来。或者在没有获得锁的情况下调用 await/signal,会直接抛 IllegalMonitorStateException。

优化建议是严格遵循「lock-unlock」和「await-signal」配对原则,把 unlock 和 signal 放在 finally 块中。使用 try-with-resources 配合 ReentrantLock 也可以减少遗漏。监控方面,可以定期打印 AQS 的 state 值和队列长度,一旦队列持续增长而 state 不为 0,就需要立刻排查。

高并发场景下,建议优先使用 Semaphore 而非手动维护计数器,因为 Semaphore 内部已经处理了队列和许可的原子性。读写场景推荐 ReentrantReadWriteLock,它的 state 高 16 位存读锁计数,低 16 位存写锁状态,原理类似但更复杂。

排查此类问题时,jstack 结合 jconsole 查看线程状态和锁持有者是最有效的手段。理解 AQS 内部的 state 流转、CLH 指针变化和节点在两个队列间的迁移路径,是避免这些坑、写出健壮并发代码的基础。

参考来源