你可能见过这样的场景:一群人冲进教室,座位只有几个,谁抢到谁坐,抢不到的只能排队等着。Java并发里的ReentrantLock,本质上就是在干这件事。不过它的“排队”不是简单的先来后到,而是一套基于AQS(AbstractQueuedSynchronizer)的双向队列机制。这篇文章不打算泛泛而谈,直接扒开ReentrantLock源码,从“抢座位”这个日常比喻切入,把AQS队列从入队到唤醒的每一步拆给你看。适合正在啃并发编程的人、准备Java面试的老哥,以及那些想知道锁底层到底怎么转的硬核玩家。
1. 从“抢座位”说起:ReentrantLock的核心思想
1.1 为什么需要一把更“灵活”的锁
先问个问题:synchronized已经能用,为什么还要有ReentrantLock?因为synchronized是JVM内置的,使用简单但不够灵活。比如你想在抢锁时设置超时时间,如果抢不到就去做别的事;想响应中断;想多个线程交替执行;想判断当前是否有线程排队。这些需求,synchronized要么不支持,要么写起来很别扭。ReentrantLock作为JDK提供的锁,本质上就是弥补这些缺口。
它叫“Reentrant”,是因为同一个线程可以重复获取同一把锁。比如线程拿到锁后,再调用一个需要同一把锁的方法,不用重新去“抢”,只需要给状态值加一。这种设计避免了死锁,也让代码更自然。而这一切的核心,就是一个叫AbstractQueuedSynchronizer的类,简称AQS。ReentrantLock只是AQS的一个应用场景,CountDownLatch、Semaphore、ThreadPoolExecutor里的Worker,底层都是在用它做同步。
1.2 “抢座位”模型:状态、Owner与等待队列
现在脑子里建立一个模型:锁就是一个座位,同时只能坐一个人。线程来了,先看座位是否空着,空着就坐下并把“座位主人”设为自己。这就是state和exclusiveOwnerThread做的事。
state:锁的状态。0表示没人坐,>0表示被占用。因为可重入,每重入一次就加1。exclusiveOwnerThread:记录当前占用锁的线程,也就是“座位主人”。
如果座位已经有人了,后来的线程怎么办?两个选择:要么直接插队试一下运气,要么去队伍末尾排队。ReentrantLock提供了两种模式,默认是非公平锁,对应“插队模式”;也可以通过构造参数true选择公平锁,对应“排队模式”。在AQS里,排队的线程会进入一个双向队列,这个队列的节点是Node,每个节点保存了线程引用和等待状态。这就是标题里说的AQS队列。
这个队列还有个名字叫CLH锁队列的变体。它并不是操作系统里的那种消息队列,也不是BlockingQueue,而是一个纯粹的同步控制队列。理解这一点很关键:AQS的队列不存业务数据,只存“正在等待锁的线程”。
2. AQS到底是什么:同步器的骨架
2.1 核心字段:state、head、tail
打开AQS源码,最先看到的就是几个关键字段。摸清它们,你就掌握了80%的脉络。
// 锁状态,volatile保证可见性 private volatile int state; // 等待队列的头节点 private transient volatile Node head; // 等待队列的尾节点 private transient volatile Node tail;state的作用在上面已经说了,它是判断能否获取锁的唯一依据。head和tail则是双向队列的两个端点。队列里每个节点是内部类Node的实例,关键字段有:
volatile int waitStatus; volatile Node prev; volatile Node next; volatile Thread thread;waitStatus是一个状态标记,几个关键取值:
CANCELLED(值为1):表示该节点被取消,线程不再等待锁。SIGNAL(值为-1):表示后继节点需要被唤醒。也就是说,当前节点释放锁或取消时,要通知下一个节点。CONDITION(值为-2):表示节点在条件队列中,等待某个条件满足。PROPAGATE(值为-3):用于共享模式下,表示状态需要向后传播。
head和tail都有一个特点:懒初始化。第一次加锁时,如果队列还没建立,不会立刻创建,而是等到第一个没抢到锁的线程出现时,才会通过CAS初始化一个空的头节点,再把新线程节点挂到后面。这样做是减少不必要的对象创建。
2.2 模板方法模式:tryAcquire与acquire的协作
AQS最精彩的设计是模板方法模式。它把获取锁和释放锁的整体流程固定好,把具体怎么判断“能不能获取”留给子类实现。这样一套模板就能支持各种同步工具。
以独占锁为例,核心流程是acquire(int arg):
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }tryAcquire是留给子类实现的钩子方法。ReentrantLock的两个内部类FairSync和NonfairSync分别实现了它,从而决定是公平还是非公平。tryAcquire返回true,说明锁到手了,流程结束;返回false,则进入下一步:创建节点、加入队列、排队等待。
这种设计的巧劲在于:AQS本身不关心你是锁、信号量还是倒计时器,它只提供“排队挂起、唤醒继续”的骨架。子类只需要说清楚“什么条件下算获取成功”,其余的全交给AQS。
释放锁也有对应的模板,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也是子类实现,负责修改state并清空“Owner线程”。如果释放成功,就唤醒等待队列里的下一个线程。
3. 源码实战:ReentrantLock的加锁与解锁全流程
3.1 加锁:从小动作到排队的完整路径
先看非公平锁的lock(),它有个“小动作”:
final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }刚进入lock(),它会先尝试CAS把state从0改成1。如果成功,说明座位空着,直接抢到手。这就是非公平锁“插队”的第一层体现。如果失败,说明锁被占着,于是进入acquire(1)。
公平锁呢?直接调用acquire(1),连“插队”这一步都省了。它得先看队列里有没有人排在自己前面,有就老老实实去队尾。
acquire里先调用tryAcquire。非公平锁的tryAcquire是这样的:
final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (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; }这里有两个关键分支:c == 0表示锁空闲,再试一次CAS;如果锁已经被当前线程持有,走重入分支,state加一。所以state记录的就是重入次数。如果代码里1个方法里连续锁了3次,那state就是3,要释放3次才真正释放。
tryAcquire失败后,进入addWaiter(Node.EXCLUSIVE):
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; }这段代码把当前线程包装成Node,通过CAS插入到队列尾部。如果tail为空,说明队列还没建立,走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; failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }循环里先判断前驱节点是不是head。只有紧挨着头节点的“队首”才有资格再次尝试获取锁。如果队列前面还有人,就老老实实休息。这里有个细节:setHead(node)会把当前节点设为头节点,并清空其thread引用,这样原头节点就可以被GC了。
如果前驱不是头节点,或者尝试获取又失败了,就调用shouldParkAfterFailedAcquire检查是否应该挂起线程。它会把前驱节点的waitStatus设成SIGNAL,表示“前驱释放锁时记得通知我”。然后通过LockSupport.park挂起线程,等待被唤醒。这就是线程从“抢座位”变成“排队睡觉”的过程。
3.2 解锁:从state归零到唤醒队头
有上锁就有解锁。ReentrantLock的unlock()最终调用AQS.release(1)。release先调tryRelease,也就是在Sync里实现的:
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; }注意两个细节:第一,如果当前线程不是锁的持有者,直接抛出IllegalMonitorStateException,绝不让你乱解锁。第二,state减去releases后如果变成0,才真正释放锁。这也解释了为什么lock()了几次就必须unlock()几次,否则锁永远不释放。
释放成功后进入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); }这里有个容易忽视的坑:如果当前节点的后继节点是null,或者后继节点被取消了(waitStatus > 0),就必须从队尾往前找,找到离头最近的有效节点。为什么要从尾往前?因为在并发场景下,node.prev的赋值早于node.next的链接,往前遍历能避免漏掉节点。这个细节,synchronized的维护者们在AQS里处理得相当细腻。
3.3 可重入与公平锁的实现差异
我们已经看了非公平锁的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; }区别只在一行:!hasQueuedPredecessors()。这个方法会检查队列里是否有比当前线程更早到达的等待者:
public final boolean hasQueuedPredecessors() { Node t = tail; Node h = head; Node s; return h != t && ((s = h.next) == null || s.thread != Thread.currentThread()); }如果队里已经有人在等待,公平锁就直接放弃争夺,去队尾排队。非公平锁则不管这些,逮着机会就CAS。所以公平锁的吞吐量通常比非公平锁低,但线程排队更有序,不会出现“刚来的线程把老线程饿死”的情况。
可重入是两者共有的能力。所谓重入,就是同一个线程再次执行lock()时,state会递增,而不需要真正去“抢座”。这依赖exclusiveOwnerThread记录当前持有线程。重入次数用state保存,释放时逐层递减。
4. 从AQS到并发工具箱:阻塞队列与线程池的关联
4.1 AQS队列 vs 阻塞队列:不同的“排队”机制
很多人刚接触AQS时,会把它跟BlockingQueue搞混。实际上两者完全是两回事。
AQS队列是同步队列,存放的是等待获取锁的线程节点,它不承载业务数据。它的操作是CAS入队、LockSupport.park挂起、unpark唤醒。而ArrayBlockingQueue、LinkedBlockingQueue这类阻塞队列,是用来存放业务数据的,生产者和消费者通过它传递信息。阻塞队列内部同样会用ReentrantLock和Condition来控制并发,比如ArrayBlockingQueue源码里就有:
private final ReentrantLock lock; private final Condition notEmpty; private final Condition notFull;它用同一个锁的两把“条件钥匙”分别管理“队空”和“队满”。当队列为空时,消费者线程在notEmpty.await()上挂起,等生产者放入数据后signal()唤醒。这套机制本质上就是AQS的ConditionObject实现的。
所以可以这样理解:AQS是地基,BlockingQueue是盖在上面的房子。你在用线程池时选哪种阻塞队列,实际是在选房子的“缓冲策略”——有界、无界、还是直接丢弃。
4.2 线程池里的AQS应用
线程池ThreadPoolExecutor里也藏着AQS。它的内部类Worker继承了AbstractQueuedSynchronizer,用来表示一个工作线程是否空闲。
private final class Worker extends AbstractQueuedSynchronizer implements Runnable { // 仅实现 tryAcquire / tryRelease,用 state 判断是否空闲 }Worker通过tryAcquire(把state从0改1)来标记自己正在执行任务,执行完后再tryRelease释放。这样做是为了实现shutdown()时能中断空闲线程而不误伤正在跑任务的线程。这里没有复杂的排队,只是借用AQS的独占语义做状态控制。
另外,线程池的execute命令提交任务时,如果核心线程满了,任务会进入workQueue。这个workQueue就是阻塞队列。选择不同的阻塞队列会对线程池行为产生很大影响。比如LinkedBlockingQueue默认无界,可能导致任务堆积过多;ArrayBlockingQueue有界,配合CallerRunsPolicy能缓解堆积压力;SynchronousQueue不存任务,线程不够就拒绝。
顺带一提,关于消息队列重复消费问题,那已经是中间件层面的东西了,和AQS关系不大,但如果你理解了AQS里的CLH队列是怎么通过CAS保证线程安全的,再去看RocketMQ、Kafka的消费位点提交,会更容易理解“幂等”在分布式场景下的意义。
5. 常见问题与排查实录
5.1 公平锁和非公平锁该怎么选
这几乎是面试必问的问题。非公平锁性能更好,因为线程从park状态被唤醒后,还要重新参与CAS竞争,而新来的线程直接在用户态就尝试了一次,省去了线程上下文切换的延迟。但非公平锁也带来了“饥饿”风险——老线程可能一直等不到锁。
如果业务对响应时间有严格要求,或者希望每个线程的机会尽量均匀,就选公平锁。但如果追求吞吐量、竞争激烈程度一般,非公平锁的默认选择是合理的。实测下来,大多数互联网后端场景用非公平锁就够了,因为锁持有时间通常很短。
5.2 锁中断与超时:AQS的另一面
ReentrantLock还提供了lockInterruptibly()和tryLock(timeout, unit)。前者能让等待锁的线程响应中断;后者能在等待超时后放弃。它们的实现都在AQS的doAcquireInterruptibly和tryAcquireNanos里。
比如tryLock带超时,最终会调用:
private boolean doAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { // 每次循环检查剩余时间,超时则返回 false if (nanosTimeout <= 0L) return false; // 如果等待超时,走 cancelAcquire 取消排队 }它和acquireQueued最大的区别在于,循环中会计算剩余时间,并且支持响应中断。如果你在代码里用lockInterruptibly(),线程被中断时会抛出InterruptedException,然后做节点取消清理。
一个常见踩坑场景是:线程在parkAndCheckInterrupt()中被挂起,调用Thread.interrupt()会唤醒它,但park不会抛出异常。AQS会在循环里检测到中断标记,返回true,最终由selfInterrupt()重新设置中断标志。所以你一定要在业务代码中及时处理中断状态,否则标志会一直残留。
5.3 源码阅读心得与避坑指南
读AQS源码,我最大的体会是:不要试图一次看懂所有分支,先把“队列头尾、节点状态、CAS、park/unpark”这四个概念贯穿起来。可以用一张草图画出来:线程A在座位上,线程B排队,线程C入队。然后跟着acquireQueued跑一遍循环,你会发现所有分支都是在处理“队列空不空、前驱状态是不是SIGNAL、要不要取消”这几种情况。
另一个容易忽略的坑:Node从队尾往前遍历时,prev指针是可靠的,但next指针可能因为并发入队而暂时为null。所以在unparkSuccessor里才需要特殊处理。你要是写自定义同步器,千万别一味相信next。
最后,建议你打开源码自己跑一遍,把state的变化、节点的入队出队过程用日志打出来。纸上得来终觉浅,源码这东西,亲手扒过一遍和只看别人文章,体感完全两样。
我在实际阅读代码时,还会顺手在addWaiter和acquireQueued加几个断点,用两个线程同时竞争锁,观察它们如何进入队列、如何被唤醒。这个动手过程比任何解说都管用。你现在可以打开ReentrantLock源码,从lock()一路追到parkAndCheckInterrupt(),相信看完以后,你再看到“锁”、“队列”、“阻塞”这些词,会多一层底层画面的直觉。