☰
ReentrantLock与AQS源码解析:从排队机制到公平锁与非公平锁
2026/9/26 6:36:40 网站建设 项目流程

先说结论:如果这辈子只想读一个Java并发源码,我建议你读ReentrantLock和它背后的AQS。ReentrantLock是Java并发包中实现最完整、注释最清晰、使用场景最典型的锁实现之一;AQS(AbstractQueuedSynchronizer)则是几乎所有JUC同步器——Semaphore、CountDownLatch、ReentrantReadWriteLock、ThreadPoolExecutor的worker,甚至未来的虚拟线程同步原语——的公共底座。把这两个类读透,整个java.util.concurrent的骨架就摊在你面前了。

这篇文章不打算按源码顺序逐行翻译,而是用“抢座位”这个场景把AQS的队列机制讲明白:锁就是座位,没抢到的人就得去排队,排队排到哪、什么时候轮到你、有人插队怎么处理、中途不想等了能不能走人——这些问题恰恰就是AQS源码里addWaiter、acquireQueued、parkAndCheckInterrupt、ConditionObject在回答的问题。下面我从头到尾拆一遍,文末附上我实际阅读源码时踩过的几个理解误区,希望能帮你少绕弯。

1. 为什么AQS队列可以理解成“抢座位”

1.1 先把“抢座位”的比喻立起来

想象这样一个场景:自习室里只有一个靠窗座位,大家都想坐。谁来坐?先到先得是一种方式,但如果有个人力气大、腿脚快,即使晚到也能抢在别人前头坐下,这就是另一种方式。Java里的锁就是那个座位,而AQS维护的CLH变体队列,就是用来管理“没抢到座位的人按什么规则等”的那张排队表。

具体来说:

  • 有座位(锁空闲),直接坐下——对应CAS无锁拿锁;
  • 没座位(锁被占用),先去排队——对应addWaiter入队;
  • 排到队首且座位空出来了——对应acquireQueued里被唤醒后抢锁;
  • 中途不想等了,或者等待时被中断——对应lockInterruptibly和中断恢复逻辑。

这个比喻最大的好处是,它把“队列”从抽象的数据结构变成了你每天都会遇到的场景:队列不是用来“传数据”的,它是用来“记录等待者顺序”的。AQS里的那个双向链表,每个节点里存的并不是业务数据,而是一个个被阻塞的线程以及它们的状态。

1.2 AQS到底做了什么关键决策

AQS的核心设计是一个模板方法模式加一个状态变量。父类AbstractQueuedSynchronizer只负责两件事:维护一个volatile int state,以及维护一个基于CLH改进的双向等待队列。至于“什么条件下算拿到锁”“拿到锁后如何处理”,全部交给子类去实现tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared这几个钩子方法。

这种设计的妙处在于,不同同步器之间几乎不需要重复实现排队、唤醒、中断处理这些复杂机制,一个框架就能复用到各种场景。比如:

  • ReentrantLock的非公平锁:tryAcquire里直接CAS抢state;
  • 公平锁:tryAcquire里先检查队列中是否有等待者,有这个字面意思就是“如果该释放了,我来接”;
  • Semaphore:tryAcquireShared尝试扣减state;
  • CountDownLatch:tryReleaseShared里的state减到0时,所有等待线程同时被唤醒。

AQS不需要关心子类锁的“业务语义”,它只像一个严格的门卫:你告诉我“这个人有没有资格进门”,我来负责“让他排队,叫号,催他”。

所以读AQS源码时,不要盯着子类看,先把AQS的队列状态模型吃透,也就是我下面要拆的State、WaitStatus、Node、head/tail那套东西。

2. 核心源码拆解:从lock()到排队到底经历了什么

2.1 lock()入口:先试试“抢”,抢不到才去排

以ReentrantLock最常用的非公平锁为例,调用lock()时实际执行的是:

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

compareAndSetState(0, 1)的意思是:如果当前state是0,就原子地改成1,同时把“占用线程”记录为当前线程。这一步是彻头彻尾的“抢座位”——不管队列里有没有人排队,我直接尝试坐下。如果抢成功了,整个lock()结束,线程继续执行临界区代码。抢不到,就进入acquire(1)走排队流程。

这里有一个新读者容易忽略的点:非公平锁的“非公平”就体现在这个入口的抢先CAS上。明明有人在排队,新到的线程还是可以先试一次抢锁,抢不到才老老实实去队尾。这种设计不是bug,而是性能取舍,这个后面专门展开。

而公平锁的lock()则不同,它直接走acquire(1),里面的tryAcquire会先调用hasQueuedPredecessors()检查队列里是否有排在自己前面的线程,有就直接返回false,老老实实入队,连试都不试。所以公平锁的入口根本不给你“插队”的机会。

2.2 抢不到之后:addWaiter是怎么把线程塞进队列的

当acquire发现tryAcquire返回false,就执行:

Node node = addWaiter(Node.EXCLUSIVE);

addWaiter做的事很简单:把当前线程包装成一个Node节点,放到双向队列的尾部。但这个“简单”背后有两个关键实现细节。

第一,入队用的是CAS + 自旋,而不是synchronized或Lock。代码是这样的:

private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); Node pred = tail; if (pred != null) { node.prev = pred; if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } enq(node); return node; }

队列为空,或者CAS尾插失败,都会走enq方法。enq内部用for(;;)自旋,不断尝试“把节点CAS到队尾”,直到成功为止。为什么不用锁来保护入队操作?因为AQS本身就是要实现锁,如果用锁去保护“入队”,那这个锁又得再实现一次队列,无限套娃。CAS自旋几乎是不可避免的选择。

第二,这个队列是双向链表,节点持有prev和next两个指针。为什么要双向?因为在等待队列中,线程被唤醒后需要往后找后继节点,而取消排队时又需要从后往前清理前驱节点。只有单向链表的队列在“删除中间节点”这件事上非常痛苦,双向链表可以优雅地处理取消等待的场景。

2.3 排进队列之后:acquireQueued的自旋与阻塞

addWaiter返回的是入队的Node,但这仅仅是“排上了”,还没完。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); } }

这个循环体只有两种情况会让线程继续:一是前驱节点正好是head,说明轮到它了,它再次尝试拿锁,成功则结束循环;二是拿锁失败,那就进入shouldParkAfterFailedAcquire + parkAndCheckInterrupt,把线程真正阻塞起来。

值得注意的是“轮到你了”并不是“直接给你锁”,而是再给你一次抢锁的机会。因为锁可能在释放的瞬间,又有新线程“插队”把它抢走了(非公平锁)。所以即使你的前驱是head,tryAcquire也不一定成功,无法成功就得继续park。这个细节点破了很多人的一个误区:CLH队列不是“锁的接力棒”,它只是“等待者的排队表”,真正能不能拿到锁,还是看CAS抢不抢得到。

parkAndCheckInterrupt是将当前线程挂起的操作,底层调用LockSupport.park()。一旦线程park,就不会占用CPU。这也回应了“自旋锁”和“阻塞锁”的区别:AQS队列里虽然用了自旋来入队,但等待时的线程是真正阻塞的,不是无限忙等。只有在入队、唤醒、抢锁这些瞬时操作上用了自旋。

2.4 释放锁:unlock和unparkSuccessor的唤醒链

当线程执行完临界区代码,调用unlock():

public void unlock() { sync.release(1); }

release方法会先调用tryRelease(arg),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; }

注意一个容易踩坑的细节:如果state减完不是0,tryRelease返回false,release方法根本不会唤醒队列中的任何线程。这是因为ReentrantLock是可重入锁,同一个线程可能连续lock了两次,state是2。第一次unlock时减到1,说明这个线程还没有完全释放锁,当然不能唤醒别人。只有state减到0,锁才算真正释放,才轮得到unparkSuccessor去唤醒后继线程。

unparkSuccessor的逻辑看似简单,其实有个很讲究的处理:它默认找后继节点,但后继节点可能已被取消(waitStatus为CANCELLED),这种情况下会从tail往前扫描,找到最靠近head的有效节点来唤醒。为什么是从tail往前扫,而不是从head往后扫?因为节点入队时先设置prev再CAS尾指针,在并发极端情况下,next指针可能还没有来得及设置,从前往后扫可能漏掉节点;而prev指针在节点入队那一刻就已经固定了,从后往前扫更可靠。这是AQS源码里一段非常微妙的注释,值得反复体会。

3. 公平锁与非公平锁:两种“排队规则”的本质差异

3.1 公平锁的tryAcquire里那一步hasQueuedPredecessors

公平锁和非公平锁在acquire排队之后的流程完全一致,差异只在tryAcquire这一个方法:

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; }

和2.1里看到的非公平锁入口相比,差别就在于c == 0分支里多了一条hasQueuedPredecessors()判断。这个方法的实现核心是:

public final boolean hasQueuedPredecessors() { Node t = tail; Node h = head; Node s; return h != t && ((s = h.next) == null || s.thread != Thread.currentThread()); }

它回答的问题是:队列里是否有等待线程比当前线程更早?注意最后那个条件s.thread != Thread.currentThread()——如果头节点的后继恰好是当前线程自己,说明当前线程已经在队首等待,此时不算“有前驱”,可以去尝试拿锁。这个细节不能漏,否则会出现“重复排队”的边界问题。

3.2 两者性能差在哪

非公平锁的性能优势来自两点。一是新线程在锁释放瞬间不用被唤醒,直接CAS抢锁,省掉了线程上下文切换的等待时间;二是即使需要唤醒队列里的线程,被唤醒的线程和插队线程会同时竞争,谁赢都可以,整体吞吐量更高。代价是队首线程可能被多次“插队”,出现饥饿的极端场景,但ReentrantLock的非公平策略并不会完全饿死队列线程,因为插队线程如果没抢到,还是会乖乖排到队尾。

公平锁则严格FIFO,谁的等待时间最长谁先获得锁,线程几乎不会饿死。但代价是频繁的线程阻塞与唤醒,上下文切换开销大,吞吐量通常只有非公平锁的几分之一。Netty在绝大多数场景默认用非公平锁,ThreadPoolExecutor内部也用非公平锁来调度work thread,因为这些场景对“公平性”不敏感,但对吞吐量敏感。

还有一个实际经验:如果你不确定该用哪种锁,优先用非公平锁。公平锁只有在业务上对“等待顺序”有严格要求的场景才值得引入,比如某些交易撮合队列、任务执行顺序敏感的场景。否则,你会为公平性付出一笔不小的性能税。

3.3 锁重入:state不只是0和1

可重入锁的实现本质是把“锁的持有次数”记在state里。每次lock成功,state加1;每次unlock,state减1。当state减到0,锁才真正释放。这种设计最大的好处是,锁的占用和释放可以分布在不同代码层级,不用像传统自旋锁那样,同一线程再次进入时必须先释放外层锁。

不过这里有个隐藏的调试陷阱:如果你在代码里手动调用了tryLock、unlock,或者通过反射获取state值,你会看到state可能是2、3这种大于1的数。不要慌,那是重入次数。但如果你看到state大于1且长时间不变,说明某线程重入了但没有成对释放,恭喜你,找到bug了。

4. 中断响应与条件队列:AQS的另一半

4.1 lockInterruptibly是怎么实现“中途走人”的

普通的lock()对中断是无视的,线程被park后就算收到中断信号,也会继续留在阻塞队列里等待;而lockInterruptibly()在等待途中收到中断,会直接抛出InterruptedException并退出队列。

关键代码在AQS的doAcquireInterruptibly方法里:

private void doAcquireInterruptibly(int arg) throws InterruptedException { final Node node = addWaiter(Node.EXCLUSIVE); boolean failed = true; try { for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; failed = false; return; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } }

和acquireQueued的唯一区别就是:检测到中断时,立即抛出InterruptedException。一旦抛出,finally块里的cancelAcquire就会执行,把当前节点从队列里清理掉,同时调整前驱后继的指针。这和我第2.4节提到的“从后往前清理取消节点”的机制正好呼应。

4.2 Condition:AQS里的第二个队列

ReentrantLock关联的ConditionObject其实是AQS的一个内部类。调用condition.await()时,线程会从“锁队列”转移到一个“条件队列”,同时释放锁;当其他线程调用condition.signal()时,条件队列中第一个等待节点会被转移到锁队列的尾部,等待重新获取锁。这里的核心是await方法里的两步操作:

  • 把当前线程封装的节点加入条件队列(不是AQS的主队列);
  • 完整释放锁,一直释放到state为0,否则阻塞中的线程永远等不到锁释放。

两个队列的关系就类似于候诊室和诊室:诊室(锁队列)里的人在排队等着看医生,候诊室(条件队列)里的人是被医生点名“先出去玩会儿”的,等医生喊号(signal),才回到诊室外继续排队。

有一个经典问题:signal方法执行后,被唤醒的线程真的能立刻执行吗?答案是否定的。signal只是把节点从条件队列搬到锁队列,该线程仍然要等锁、重新排队。这在生产者消费者场景里经常被误解,导致很多初学者以为signal = 获得锁。

5. 常见问题与避坑实录

5.1 “队列是FIFO的,为什么非公平锁还会插队?”

这是我在社区里看到最多的疑问。FIFO管的是“等待队列里的人按顺序被唤醒”,但非公平锁的入口CAS发生在入队之前。新线程根本不需要先排队再插队,它直接在入队前就抢了一次锁。所以等待队列内部确实是公平的,只是“还没入队的人”可以先抢。这不算队列不公,而是“入口没有排队”而已。

5.2 用jstack排查ReentrantLock卡死

如果你怀疑项目中某个线程被ReentrantLock卡住了,jstack是非常趁手的工具。在jstack输出里,等待ReentrantLock的线程会显示为:

"thread-1" #... waiting on java.util.concurrent.locks.ReentrantLock$NonfairSync@...

你还会看到该线程处于WAITING或TIMED_WAITING状态,说明它在park中。这时候重点关注两个信息:被等的锁对象是谁,哪个线程持有它。如果持有锁的线程堆栈卡在一个无限循环或IO等待里,那就基本定位到问题了。

可问题是,ReentrantLock不像synchronized那样在堆栈上直接打出monitor owner,所以最好在代码里额外打印持有锁线程名称,或者用jstack配合jcmd Thread.dump_to_file对比,定位更准确。

5.3 tryLock和lock的区别别用错

tryLock()是非阻塞的,最多立即尝试一次,抢不到就返回false。tryLock(timeout, unit)则是带超时等待的。这是很多人写业务代码时的高频bug来源:有些人用了tryLock却忘了处理false,导致业务逻辑在并发下静默失败;有些人滥用tryLock实现“一次性任务互斥”,结果因为不知道tryLock非公平语义,在竞争激烈时反复抢不到锁。

我个人的经验法则是:锁等待时间上限明确的用tryLock带超时;必须等到锁的用lock或lockInterruptibly;不希望被中断且可以无限等的用lock(但一定要防止持锁线程不释放的异常分支)。不要一律用tryLock逃课。

5.4 源码阅读建议:别从头读到尾,从场景切入

很多朋友读AQS源码读不下去,是因为从类的第一个方法开始顺序读,读完Node字段就晕了。我建议反过来:先写一个ReentrantLock的最小示例,加断点从lock()进去,一步步看线程在几个if之间跳转,再把AQS的四个模板方法单独拿出来看子类实现。这样依赖场景驱动,不容易迷路。

还有一个技巧:在阅读时,自己画一下节点的prev、next变化过程。像我第一次读addWaiter时,就是因为没有画图,对“为什么入队不用锁”始终不得要领。后来把三个节点画在纸上,模拟head、tail变化,一下就通了。

6. 一点个人体会

ReentrantLock源码我前前后后读了三四遍,每一遍都有新的收获。第一遍关注的是“锁怎么拿怎么放”,第二遍关注的是“队列怎么维护取消节点”,第三遍开始注意到那些异常处理分支——比如checkInterruptWhileWaiting、cancelAcquire里的复杂指针操作,才意识到AQS能在如此复杂的并发环境下保持正确性有多不容易。

后来我在看Netty的EventLoop、Dubbo的异步调用、甚至自己写轻量级任务调度器时,都会不由自主地回到AQS的设计思路里找灵感。它教我的最重要一件事是:并发编程里,最难的不是“加锁”,而是让所有等待者在公平、效率、可中断之间找到平衡。AQS用一个state加一个双向队列就做到了,这个设计放到今天依然不过时。

如果你正在准备面试、在阅读Java并发源码、或者只是想搞明白“为什么别人写的并发工具类那么稳”,那就从ReentrantLock + AQS开始吧。希望你读完这篇文章后,也能体会到那个“抢座位”场景背后的优雅。

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

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

立即咨询