1. 为什么并发编程绕不开AQS——从一把可重入锁说起
先抛一个很多人都有的疑惑:Java里synchronized用了这么多年,用得好好的,为什么JUC包还要搞出个ReentrantLock?更关键的是,ReentrantLock的实现核心——AbstractQueuedSynchronizer(AQS)——凭什么能成为整个Java并发工具的基石,连CountDownLatch、Semaphore、ThreadPoolExecutor都离不开它?
这个问题的答案,恰恰藏在一个最容易被忽视的事实里:synchronized是JVM层面实现的隐式锁,而ReentrantLock是JDK层面基于AQS实现的显式锁。两者不是替代关系,但AQS代表的这套"状态位+等待队列+模板方法"的设计思想,才是理解现代Java并发编程的一把万能钥匙。我没法回避一个事实:这些年我面试过的候选人里,能讲清楚ReentrantLock用法的至少有一半,但能说明白AQS内部"获取锁失败后线程到底去了哪里、被谁唤醒、唤醒后干什么"的,可能两成都不到。
我自己第一次硬啃AQS源码的时候也是一头雾水:tryAcquire、acquireQueued、hasQueuedPredecessors……这些方法名一个比一个抽象,看三遍都记不住。后来我换了个思路,把ReentrantLock当成一个"现成的例子",反推AQS的设计意图,一切才豁然开朗。所以这篇文章我不想上来就贴一堆源码让你背,而是带着你从"一把锁应该具备哪些能力"出发,一点一点把AQS的骨架搭起来,再用源码级别的细节去验证这套骨架长什么样。
在正式进入源码之前,先约定一下这篇文章的阅读路径:
- 先看
ReentrantLock的基本使用和它抛出的三个核心问题; - 再看AQS的内部资产:
state、CLH队列、模板方法; - 然后逐行拆解
lock/unlock的完整链路; - 接着讲
Condition的实现怎么把"锁"和"条件等待"缝在一起; - 最后用AQS视角重新审视整个JUC工具族,并分享一些我踩过的坑。
适合谁来看?如果你正在准备Java面试,这篇文章能帮你把"八股文"变成真正理解后的表达;如果你已经在写并发代码但总感觉心里没底,这篇文章能帮你建立一套完整的"锁运作"心智模型;如果你纯粹是想提升源码阅读能力,那跟着走一遍AQS的核心路径,比看一百篇源码分析帖都管用。
2. ReentrantLock抛出的三个问题,正好是AQS要回答的三件事
很多人学ReentrantLock只知道"它可以替代synchronized,支持公平锁,响应中断,还可以超时"。但往深了问一句"它是怎么做到的",就卡壳了。其实你只要把ReentrantLock当成一个黑盒,观察它的行为,就能反推出AQS必须解决的三个核心问题。
2.1 问题一:怎么安全地记录"锁被谁持有"
ReentrantLock是互斥锁,同一时刻只有一个线程能持有锁。这就要求有一个数据结构来记录两件事:锁当前被哪个线程持有,以及这个线程重入了几次。
你会不会觉得这太简单了?一个Thread类型的字段指向持有者,再加一个int计数器不就完了?但问题是:多个线程同时来抢锁的时候,这个字段的修改必须是原子的。从锁的语义上讲,第一个抢到锁的线程应该记录持有者;正在持有锁的线程再次加锁,应该让计数器加一而不是阻塞自己;持有锁的线程解锁一次,计数器减一,减到零才真正释放锁。
所以这个"状态"必须具备两个特性:一是原子可见性,所有线程都能读到最新的合法值;二是数值语义,在互斥锁这里它表示"重入次数",在其他同步器里它可以表示"剩余许可数"或"计数器的当前值"。
这引出了AQS的第一个答案:用volatile int state字段,配合compareAndSet(CAS)操作来原子更新。CAS是CPU指令级别的原子操作,保证"比较并交换"这一个动作不可被中断,线程在执行CAS时不会出现"读到一个中间状态"的情况。
2.2 问题二:抢不到锁的线程,该排到哪里去
互斥锁必须保证"拿到锁的线程只有一个,其余线程不能乱跑"。那拿不到锁的线程应该做什么?几件事不能做:
- 不能直接自旋死等。如果持锁线程要执行很重的业务逻辑,其他线程全部在那儿空转,CPU会被白白烧光,在大量线程竞争的场景下直接拖垮整个应用;
- 不能简单地
sleep。你不知道持锁线程何时释放锁,睡多久都不合适,睡少了浪费CPU,睡多了增加响应延迟; - 不能直接挂起然后不管。持锁线程释放锁后,必须有一种机制能把等待线程精准地唤醒,而且是按照某种合理的顺序唤醒(比如先进先出或优先级)。
最稳妥的方案,就是把抢锁失败的线程封装成一个节点,放进一个线程安全的队列里,让线程进入阻塞状态,等待前驱节点释放锁之后再被通知。
这引出了AQS的第二个答案:CLH变体队列。AQS内部维护了一个由Node节点组成的FIFO双向队列,每个节点包着一个等待中的线程。注意,经典的CLH锁原始版本是自旋锁,AQS并没有照搬原始设计,而是改造成了"自旋+CAS+阻塞唤醒"的组合方案,专门为LockSupport.park/unpark这类显式阻塞原语服务。
2.3 问题三:这些逻辑怎么让不同同步器共用
同样一套"原子状态+等待队列+阻塞唤醒"的骨架,ReentrantLock可以做成互斥锁,Semaphore可以做成共享锁,CountDownLatch可以用它做倒计数门闩。区别只在于:"什么条件下允许通过"这个判断逻辑不同。
如果AQS把所有逻辑都写死成一个具体类,那这些并发工具就没法复用了。AQS给出的解法是模板方法模式:把"获取同步状态""释放同步状态""判断是否独占/共享"这些步骤定义为模板方法,让子类去覆盖对应的tryAcquire/tryRelease(独占模式)或tryAcquireShared/tryReleaseShared(共享模式)。
子类只需要回答"这个状态下我能不能通过",至于"不能通过时入队""入队后阻塞""被唤醒后重新尝试""队列如何维护"这些通用流程,AQS全部帮你搞定。
打个比方:AQS就像一家酒店的中央厨房,把"买菜、切菜、炒菜、洗碗"的通用流程全部标准化了。每个同步器只是往中央厨房提交一份菜单(
tryAcquire等钩子方法的实现),厨房就按统一流程给你出菜。有的菜单是"只有第一个顾客能吃饭"(独占锁),有的是"最多十个人能同时吃饭"(信号量),有的是"人齐了才能开席"(门闩),但后厨的流水线完全一样。
这三个问题对应的答案——volatile state、CLH变体队列、模板方法——构成了AQS的全部骨架。接下来的几节,我们就围绕这三个答案逐个深入。
3. 深入AQS骨架:state字段、CLH变体队列与模板方法的精妙配合
如果你打开AQS的源码,第一个让你注意到的成员变量就是private volatile int state。别小看这个字段,它是整个同步器的"灵魂"。我一直跟同事说,看懂AQS只需要弄明白三件事:state怎么变、队列怎么排、钩子怎么调。这三个东西配合起来,就是一套完整的并发同步机制。
3.1 state字段:既是"锁状态",也是"资源数量"
state在AQS里是一个32位的有符号整数,用volatile修饰保证多线程可见性。它本身没有任何业务含义,具体表示什么完全由子类决定:
- 在
ReentrantLock里,state表示"重入次数"。0表示没有线程持有锁,1表示某个线程持有了锁一次,N表示同一个线程重入了N次; - 在
Semaphore里,state表示"剩余许可数量",每次acquire获取许可时减一,每次release释放许可时加一; - 在
CountDownLatch里,state表示"还要等待的计数"; - 在
ReentrantReadWriteLock里,state被拆成了高16位和低16位,分别表示读锁数量与写锁状态。
修改state必须走AQS提供的getState、setState和compareAndSetState方法。其中前两个方法读和写是分离的,在修改时必须用compareAndSetState来原子更新;如果子类使用的是setState直接赋值,必须保证没有并发竞争,比如释放锁时因为只有持锁线程才有资格释放,就可以安全地用非原子的setState。
这里有一个工程上的细节:CAS失败的线程不会重试成功。CAS比较的是内存中的实际值与预期值,预期值变了就失败,调用方需要自行决定下一步。AQS的模板方法acquire里,tryAcquire是一个返回boolean的钩子,如果返回false,AQS就把当前线程入队——所以CAS失败的全部代价只是"多走了一次入队流程",没有死循环风险。
3.2 CLH变体队列:等待线程是怎么排队的
先讲一个历史背景。AQS的前身是AbstractQueuedSynchronizer,它的队列模型脱胎于CLH锁,全称是Craig-Landin-Hagersten,由三位计算机科学家提出。原始的CLH锁是一个隐式链表结构的自旋锁,每个等待线程通过轮询前驱节点的状态来自旋等待。AQS引入了"显式的双向队列"和"Nodestatus"思想,把前驱节点变成"阻塞唤醒"而不是"自旋轮询",这是它与原始CLH最大的区别。
AQS内部的Node对象主要有这些字段:
| 字段 | 作用 | 关键值 |
|---|---|---|
thread | 当前节点包装的线程 | null表示该节点可能是头节点或已取消节点 |
waitStatus | 节点等待状态 | CANCELLED(1)、SIGNAL(-1)、CONDITION(-2)、PROPAGATE(-3)、0(初始) |
prev/next | 双向链表的前驱与后继 | 用于插入、删除、唤醒传播 |
nextWaiter | 条件队列中的下一个节点 | 在条件队列中指向下一个等待节点,在同步队列中标记独占/共享模式 |
你需要注意一个大方向:同步队列是FIFO双向队列,新来的竞争线程统一addWaiter到队尾,然后头节点是"当前持有锁的线程"(虽然持锁线程在释放后不会立即从队列里摘除,但逻辑上它代表"正在持锁"),后继节点才是排队等待的线程。
CLH变体队列最让人困惑的是waitStatus=SIGNAL的语义。它不是说"当前节点被signal了",而是说当前节点的后继节点应该被唤醒。也就是说,当一个节点释放锁或取消等待时,它要检查自己的waitStatus是不是SIGNAL,如果是,就唤醒自己的后继节点。这个约定避免了"释放锁时不知道要唤醒谁"的问题。
3.3 模板方法:AQS怎么把"判断"和"执行"分开
看AQS源码时,你会看到两类方法:
- 模板方法(已实现,一般不用重写):
acquire、acquireInterruptibly、tryAcquireNanos、release、acquireShared、acquireSharedInterruptibly、tryAcquireSharedNanos、releaseShared,这些方法实现了"尝试获取/释放、入队、park/unpark"的通用流程; - 钩子方法(未实现或提供默认实现,需要子类覆盖):
tryAcquire(int arg)、tryRelease(int arg)、tryAcquireShared(int args)、tryReleaseShared(int args)、isHeldExclusively()。
拿acquire方法来看(这是独占模式获取状态的顶层入口):
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }这段代码只有三行,却浓缩了完整的获取流程:
- 先调用子类的
tryAcquire,尝试立即获取同步状态,成功就完事; - 失败则通过
addWaiter(Node.EXCLUSIVE)把当前线程封装成独占节点放入队列尾部; - 再通过
acquireQueued让节点在队列中循环地"尝试获取-阻塞-被唤醒-再尝试获取"; - 如果
acquireQueued返回true,说明线程在等待过程中被中断过,则补设中断标志位selfInterrupt()。
这种设计的精妙之处在于:AQS不关心你的同步器是"锁"还是"计数器",它只负责搭建一套通用的等待/唤醒流水线。你实现tryAcquire时该怎么判断"能不能拿到锁",AQS全部让给你,这保证了框架的灵活度。
为了让你更直观地理解这套配合,我画出一个简化版的执行流程(纯文字描述):
用户线程调用 lock() └─> acquire(1) ├─> tryAcquire(1) 成功? → 持有锁返回 └─> 失败 → addWaiter(Node.EXCLUSIVE) 入队尾 └─> acquireQueued(node, 1) ├─ 前驱是头节点且 tryAcquire(1) 成功? │ ├─ 是 → 设置自身为头节点,返回中断状态 │ └─ 否 → shouldParkAfterFailedAcquire 判断能否park │ ├─ 能 → LockSupport.park 阻塞 │ └─ 被唤醒 → 继续循环 └─ 中断检查 → 若中断则返回true4. 源码级拆解:一次lock/unlock的完整旅行
有了前面的整体认知,我们现在把ReentrantLock的代码一块一块抠开。先申明一下:我下面的源码描述基于JDK 8以后的实现,不同小版本可能略有微调,但核心逻辑十年没变过。
4.1 lock():非公平锁的"抢"与公平锁的"让"
ReentrantLock默认构造是非公平锁,直接看它的lock:
final void lock() { // 非公平锁的第一步:不管队列里有没有人在排队,先CAS抢一次 if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }非公平锁的"非公平"体现在第一行:它不会先检查等待队列里有没有人排队,直接CAS抢锁。如果抢成功了,哪怕队列里有个线程已经等了快一小时,你也插队领先了。这看起来对老线程不友好,但它减少了一次线程阻塞/唤醒的上下文切换开销,吞吐量通常更高。
公平锁就温和多了,它的lock直接走acquire(1),而在tryAcquire中会调用hasQueuedPredecessors来检查前面有没有等待者:
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; }这段代码就是ReentrantLock自己实现的钩子方法,跟AQS框架解耦得很清楚。逻辑分两支:
- 当前无锁(c == 0):公平锁要求先确认队列中没有等待者(
hasQueuedPredecessors返回false),然后CAS把状态从0改成acquires(通常就是1),成功后把当前线程记为持有者; - 当前线程已持有锁(current == getExclusiveOwnerThread()):说明是一次重入,直接
setState(c + acquires)。这里不需要CAS,因为只有持锁线程自己才能走到这个分支,不存在竞争。
注意一个细节:重入时检查nextc < 0,防止重入次数溢出成负数。实际中你几乎遇不到这种溢出,但源码考虑到了,面试时能说出来绝对是加分项。
4.2 acquireQueued:入队之后,线程的"排队人生"
假设tryAcquire失败,线程被包装成节点丢进队列尾部,接着进入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); } }这个循环是核心中的核心。每次循环做两件事:
- 检查当前节点的前驱是不是头节点。如果是,说明"轮到我尝试了",再调一次
tryAcquire,成功就让自己成为新的头节点(setHead会把节点里的thread清空,表示这个节点现在代表持锁线程,不再是一个排队线程),旧的头节点的next指向null,帮助GC; - 如果前驱不是头节点,或者尝试获取失败,就进入判断:是不是该把自己挂起了。
shouldParkAfterFailedAcquire会前驱节点的waitStatus改成SIGNAL,这样当前驱释放时就会唤醒自己。如果前驱节点已被取消(CANCELLED),就跳过这些失效节点,找到前面第一个存活的有效节点。
parkAndCheckInterrupt是真正让线程睡眠的地方:
private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); }LockSupport.park会让当前线程进入WAITING状态。它不像Object.wait那样需要先持有某个对象的监视器锁,这就是ReentrantLock能在没有synchronized块的情况下实现阻塞的原因。
当持有锁的线程释放锁后,会调用LockSupport.unpark唤醒头节点的后继节点。被唤醒的节点从park返回,Thread.interrupted()会清掉线程的中断状态并返回是否被中断过,如果被中断,就把interrupted标记为true,但循环还会继续,线程仍然会继续尝试获取锁——这就是lock不响应中断的体现。你要用lockInterruptibly()才能真正在阻塞期间响应中断并抛异常。
4.3 unlock():释放锁与唤醒后继的完整链路
unlock走到Release方法:
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,这是ReentrantLock实现的钩子:
protected final boolean tryRelease(int releases) { int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c); return free; }注意一个关键点:tryRelease只工作到state减到0为止。如果一个线程重入了3次,它必须调用3次unlock才能把state减到0并真正释放锁。在state变0之前,tryRelease返回false,release方法不会去唤醒队列中的等待线程。
只有当真正释放锁时(free == true),才会走到unparkSuccessor。这个方法会唤醒头节点的后继节点:
private void unparkSuccessor(Node node) { int ws = node.waitStatus; if (ws < 0) compareAndSetWaitStatus(node, ws, 0); Node s = node.next; if (s == null || s.waitStatus > 0) { s = null; for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } if (s != null) LockSupport.unpark(s.thread); }这里你可能会问:为什么正常应该唤醒node.next,但代码还要考虑node.next为null或已取消的情况?因为入队操作并非严格保证next指针的一步到位——addWaiter在CAS设置队尾节点时,如果CAS失败会走enq自旋,在新节点插入过程中,偶尔会出现next指针暂时没指向最新尾节点的情况。所以要从tail向前遍历,找到离头节点最近的可用节点来唤醒。这个"从尾部向前找"的逻辑,是CLH队列设计里的一个经典细节。
4.4 中断、超时与公平性的源码佐证
AQS还提供了acquireInterruptibly和tryAcquireNanos这两个变体。两者的核心逻辑跟acquire很像,但有区别:
acquireInterruptibly在尝试获取失败后,一旦检测到线程中断,会立刻抛出InterruptedException,而不是像acquire那样记录中断状态后继续等;tryAcquireNanos额外支持超时。它把等待时间换算成System.nanoTime的deadline,每次循环都会检查是否超时。如果超时就直接返回false,不再入队等待。这个机制很适合"限时获取锁"的业务场景,比如支付回调里最多等锁200毫秒,拿不到就快速失败。
至于公平锁的"公平",前面说了核心是hasQueuedPredecessors。它的实现是:
public final boolean hasQueuedPredecessors() { Node t = tail; Node h = head; Node s; return h != t && ((s = h.next) == null || s.thread != Thread.currentThread()); }这段代码连注释都在强调它"可能短暂地返回误判",但设计上是有意为之:公平锁并不要求绝对精确,只要大致保证"先来后到"即可。两个线程同时调用lock,一个正在入队还没完成,另一个CAS抢到了锁,这在公平锁下也偶尔会发生,但这种"瑕疵"换来的是性能和正确性之间的平衡。
5. Condition的实现哲学:让锁学会等待与通知
如果说state和CLH队列解决了"互斥与排队"的问题,那Condition就是解决"条件不满足时,线程如何让出锁并等待"的问题。它很像synchronized里的wait/notify,但功能更强:一个ReentrantLock上可以创建多个Condition对象,各自维护一条独立的等待队列,这在生产者-消费者场景里特别有用。
5.1 await与signal如何与锁联动
你可能觉得await就是"让我睡一会儿",但如果真这么简单就错了。Condition.await必须原子地完成三件事:把当前线程放入条件队列、释放当前持有的锁、让线程阻塞。如果不释放锁,其他线程永远拿不到锁,也就永远无法唤醒它,会直接死锁。
AQS里对应的实现是ConditionObject,它内部维护了一个单一的等待队列(单向链表,通过nextWaiter连接)。每一次await调用,线程会被封装成一个Node节点,waitStatus置为CONDITION,然后挂在条件队列尾部。
signal方法做的事,是把条件队列头部的节点转移到同步队列尾部,然后根据情况唤醒。这个"转移"动作很重要:节点从条件队列出来,重新变成同步队列里的一个排队节点,并继续走"尝试获取锁"的流程。所以signal并不直接让线程拿到锁,它只是告诉同步队列"有个节点醒了,要重新排队抢锁"。
5.2 条件队列和同步队列的"双队列结构"
理解AQS的条件机制,关键是理解它同时有两个队列:
- 同步队列:所有渴望获取锁但暂时没抢到的线程,排队的地方;
- 条件队列:已经持有锁,但业务条件不满足,于是主动让出锁并等待的线程,排队的地方。
一个线程从await中返回,必须经历:signal(或signalAll/中断/超时)→ 从条件队列转移到同步队列 → 在同步队列中不断tryAcquire→ 成功抢到锁 → 返回。这就是为什么await必须在循环里使用的根本原因:即使被signal唤醒,也有可能被其他线程提前抢走锁,唤醒不等于条件已满足。
5.3 使用Condition最常见的大坑:条件谓词失调
下面这个代码片段,是我见过好多人在生产环境写出来的错误写法:
// 错误示范 while (!conditionMet) { condition.await(); } // 正确心态:await要放在循环里,且循环条件要基于共享状态有人觉得signal返回后就说明条件满足了,直接往下走。但想象这个场景:队列里有多个消费者在等待,生产者调用signal唤醒了一个消费者,但恰好另一个消费者插队抢到了锁并消费掉了唯一的消息——被唤醒的线程抢到锁后再看条件,发现条件不满足了。如果它不检查条件就往下走,就会读到空数据或者重复处理。
正确的写法是永远把await放在while循环中:
lock.lock(); try { while (!isReady()) { condition.await(); } // 条件满足,继续业务 } finally { lock.unlock(); }另外还有几个实战要点,都是从坑里爬出来的:
await方法会自动释放锁并在返回前重新获取锁,但它只能在没有中断的情况下正常返回。如果你用了await而不是awaitUninterruptibly,线程在等待中被中断会抛出InterruptedException;signal和signalAll的区别:signal只唤醒条件队列的第一个节点,如果有多个线程等待同一个条件,用signal可能唤醒了一个"检查条件后依然不满足"的线程,导致它再次阻塞,而真正符合条件的线程却被留在队列里。除非你能确定唤醒任意一个都无所谓,否则优先用signalAll;Condition必须在持有锁的情况下调用await/signal,否则抛IllegalMonitorStateException。这个设计跟synchronized要求持锁调用wait一样,都是为了确保共享状态的操作与等待/通知之间建立内存屏障。
6. 从ReentrantLock一跃而起的JUC工具族:state语义的百变魔法
一旦你看懂了AQS,你会发现ReentrantLock只是它的一个玩具示例。整个java.util.concurrent包的核心同步器,几乎都长在AQS的骨架上。
6.1 CountDownLatch与Semaphore:共享模式的两副面孔
CountDownLatch的用法是初始化一个计数N,N个线程各自完成后countDown,主线程await等待计数归零。它在AQS里的实现非常简洁:
state初始化为N;tryAcquireShared的实现是:return (getState() == 0) ? 1 : -1;。返回值大于等于0表示获取成功,负数表示失败;tryReleaseShared的实现是:CAS自旋把state减一,减到0返回true。
所以CountDownLatch不是"释放锁",而是"减少计数"。当计数到0时,所有在await中阻塞的线程会在releaseShared的传播机制下被逐个唤醒。这个传播机制是AQS共享模式下独有的,它通过PROPAGATE状态保证"在复杂并发下,即使后唤醒的线程还没配置好前驱状态,也不会丢唤醒信号"。
Semaphore则把state当成"剩余许可数":
tryAcquireShared(int acquires)尝试用CAS把state减掉acquires,失败返回负数;tryReleaseShared(int releases)尝试用CAS把state加上releases。
它的等待逻辑跟锁一模一样,区别只是"获取许可"和"释放许可"的判断对象不同。理解了AQS,你根本不用去背Semaphore的源码。
6.2 ReentrantReadWriteLock:一分为二的state
读写锁的实现更是把state的"位语义"玩到了极致。ReentrantReadWriteLock把32位的state拆成两部分:高16位表示读锁持有数,低16位表示写锁重入数。一个int字段同时表达两种状态,节省空间的同时还保证了读锁和写锁的状态之间的原子可见性。
写锁获取的条件是:写锁没被持有(低16位为0)或当前线程已经持有写锁,并且读锁没有被持有(高16位为0)。为什么读锁存在时写锁不能获取?因为写锁必须是独占的,如果允许读锁还没释放就获取写锁,写线程修改的数据可能正在被读线程读取,造成数据不一致。
这个类还引入了ThreadLocalHoldCounter来记录每个线程的重入读锁次数,避免频繁CAS更新高16位造成性能损耗。第一次看到这种位运算拆分时,我是真的佩服设计者的极致追求——一个int字段,被做出了两个字段的效果。
6.3 ThreadPoolExecutor中的Worker:AQS的影武者
很多人不知道,线程池里的Worker也继承自AQS。它实现了一个不可重入的互斥锁,用来保证线程池在shutdown时能安全地中断空闲线程。为什么不用ReentrantLock?因为Worker需要的锁非常特殊:不可重入。如果任务是重入的,那么一个任务在运行中再提交另一个任务给自己执行时,就可能重新获得锁,干扰线程池的运行状态。
Worker自己覆盖了tryAcquire,直接用compareAndSetState(0, 1)实现"非0即1"的互斥语义,不记录持有者,不需要重入,所以state在这里只是一个"是否被占用"的布尔标记。这再次证明了AQS的灵活性:同样的模板方法,只要你愿意,都能按自己的语义实现。
6.4 用AQS手写一个限流器:入门到融会贯通
如果上面所有的分析还让你觉得"哦但都是人家写好的",那我建议你亲自动手用AQS实现一个简单的限流器。这个练习能让你真正掌握AQS的两大模式。
下面是一个简易"同步许可证"的实现,它内部维护一个最大许可数,同一时间只允许N个线程通过:
import java.util.concurrent.locks.AbstractQueuedSynchronizer; public class SimpleLimiter { private static class Sync extends AbstractQueuedSynchronizer { private final int maxPermits; Sync(int maxPermits) { this.maxPermits = maxPermits; setState(maxPermits); } @Override protected int tryAcquireShared(int acquires) { for (;;) { int available = getState(); int remaining = available - acquires; if (remaining < 0) { return remaining; } if (compareAndSetState(available, remaining)) { return remaining; } } } @Override protected boolean tryReleaseShared(int releases) { for (;;) { int current = getState(); int next = current + releases; if (next > maxPermits) { return false; } if (compareAndSetState(current, next)) { return true; } } } } private final Sync sync; public SimpleLimiter(int maxPermits) { sync = new Sync(maxPermits); } public void acquire() { sync.acquireShared(1); } public void release() { sync.releaseShared(1); } }你只需要实现tryAcquireShared和tryReleaseShared两个钩子,AQS就帮你搞定了"许可不足时线程排队""释放时唤醒等待线程"的所有底层逻辑。写一遍之后,再回头去看Semaphore的源码,就跟看自己写的东西一样亲切。
7. 面试与实战:那些让人栽跟头的细节
最后一部分,我想把眼光从源码抽回来,聊一些更接地气的话题:面试怎么答、源码怎么读、实际开发怎么避开AQS相关的坑。
7.1 面试官真正想听到的"AQS答题路径"
很多人在面试里被问到AQS,第一反应就是背一段"volatile int state + CLH队列 + CAS",然后面试官追问两句就卡住。我建议你把AQS的回答组织成一个"先总后分、由外到内"的结构:
- 先定义:AQS是一个用于构建锁和其他同步器的框架,核心是一个
volatile int state和一个等待队列,配合模板方法模式,把"同步状态的管理"和"线程的排队阻塞"解耦; - 再讲获取流程:以
ReentrantLock.lock()为例,先说tryAcquire尝试CAS修改state,失败后addWaiter入队,acquireQueued里循环执行"前驱是头节点+再次尝试获取"的逻辑,否则park阻塞; - 讲释放流程:
tryRelease把state减到0后,unparkSuccessor唤醒头节点的后继节点; - 延伸到公平与非公平:非公平在
lock()开头直接CAS抢一次,公平锁则通过hasQueuedPredecessors检查队列是否有等待者; - 最后升华:同样的骨架,
CountDownLatch把state当计数,Semaphore把state当许可数,ReentrantReadWriteLock把state按位拆成读锁和写锁,这就是AQS的设计哲学。
按这个路径回答,基本能覆盖面试官八成以上的追问。如果面试官问"AQS为什么用双向队列"、 "为什么waitStatus用0作为初始值" 、"cancelAcquire发生什么",就是加分项了。
7.2 源码阅读方法论:不要从头读到尾,要"按路径读"
我见过太多人打开AQS源码就想从第1行读到第1000行,结果读一半就放弃。正确的打开方式是按调用路径读:
- 第一条路径:
acquire→tryAcquire→addWaiter→acquireQueued→shouldParkAfterFailedAcquire→parkAndCheckInterrupt; - 第二条路径:
release→tryRelease→unparkSuccessor; - 第三条路径:
acquireInterruptibly和tryAcquireNanos,观察它们与acquire的差异; - 第四条路径:
ConditionObject.await和signal,重点看条件队列到同步队列的转移过程; - 第五条路径:共享模式相关方法,
acquireShared和releaseShared,找一个共享同步器去验证。
每次只追一条路径,把每条路径上的"为什么这么做"记下来,比读十遍全源码都有效。我迄今还保留了当年手写的AQS调用路径图,那段经历对我后来看Netty、RocketMQ里大量的AQS应用都有很大的帮助。
7.3 实战避坑清单:写并发代码时最容易犯的错
这里列几个我在实际项目里踩过或帮别人排查过的坑,每一个都能自我介绍"我经历过":
lock()后忘了解锁。老生常谈但永远有人犯。必须lock.lock()和try { ... } finally { lock.unlock(); }配套,中间绝对不能有return跳过finally;- 用
await()判断条件时不用while。前面已经说过,signal唤醒不等于条件满足,必须用循环检查共享状态,否则会出现"伪唤醒"或"睡过头"; - 混淆
lockInterruptibly()和lock()的中断响应。lock()在等待锁的过程中不会响应中断,它只会记录中断状态。如果你需要"被中断就放弃等待",要用lockInterruptibly; - 在持锁的情况下调用耗时操作。如果锁内执行了远程调用、大查库、循环计算等耗时操作,其他线程都被卡住。AQS队列再优秀也扛不住锁内代码写得烂;
- 条件队列与同步队列混为一谈。
signal并不是"立即唤醒并让线程继续跑",它只是把节点从条件队列搬到同步队列。真正的"继续跑"还要经历抢锁。所以不要假设signal之后await立刻返回; - 对
state的理解固定在"锁重入次数"。很多读者看AQS只见过ReentrantLock,遇到CountDownLatch就认不出来了。其实state只是一个"同步状态",语义由子类自由定义。思维越灵活,读源码越轻松。
7.4 一个可以进一步探索的方向:AbstractOwnableSynchronizer
很多人在学AQS时忽略了一个小细节——ReentrantLock的"记录持锁线程"能力,并不是AQS自带的,而是来自它的父类AbstractOwnableSynchronizer。这个类提供了setExclusiveOwnerThread和getExclusiveOwnerThread两个方法,专门用于跟踪独占模式同步器当前的持有线程。为什么单独拆一个父类出来?因为很多同步器(如Semaphore)根本不需要记录"持有者是谁",把它放在AQS里面就浪费了。这个拆分也再次体现了JDK作者对"职责单一"的极致追求。
如果你想继续深挖,我建议下一步去看ReentrantReadWriteLock的tryAcquire和tryRelease完整实现,然后再去看StampedLock(注意它不是基于AQS实现的,而是基于CLH自旋+状态位的自定义实现),对比两者的设计异同。这个对比会让你对"什么时候该用AQS,什么时候该自己控制并发"有一个全新的认识。
8. 写在最后:从背八股到真正理解
我始终认为,类面试题的关键在于把"知识"变成"理解"。AQS不难,难的是你愿不愿意花一个下午的时间,把ReentrantLock当作一道数学题,顺着代码路径一步一步走完。走完这个过程后,你会发现,原来lock和unlock之间有一个完整的世界:有队列、有状态、有阻塞唤醒、有信号传递。
如果你现在正卡在源码阅读上,我的建议是:别贪多,就从ReentrantLock的lock/unlock开始,配合这篇文章的路径图,把每一行代码都搞懂,然后在IDE里打个断点跑一个多线程Demo,亲眼看一看线程的阻塞和唤醒。
最后分享一个小技巧:给AQS类的state字段和waitStatus字段分别加一个条件断点,比如state != 0或waitStatus == -1,在调试窗口里观察队列的节点关系和状态值变化。这种"可视化"的观察方式,比单纯读代码来的深刻得多,很可能读一遍源码时想不通的问题,在断点下瞬间就通了。