做 Java 并发这块,我见过太多“会用但说不清”的开发者。问 ReentrantLock 怎么加锁解锁,都能写出来,但一问“它跟 synchronized 到底差在哪”“AQS 是怎么让它排队的”,就卡壳了。这篇文章不打算给你背八股文,而是想把它当成一个完整的工具来拆解:什么时候该用、API 怎么用才算规范、底层 AQS 怎么工作、面试官到底在考什么,以及我实际写代码时踩过哪些坑。如果你正在准备 Java 面试,或者要在生产项目里做锁选型,这篇内容应该能直接拿去用。
1. 为什么有了 synchronized,还要用 ReentrantLock
先说个真实场景。几年前我接手一个订单处理服务,某个下游接口突然变慢,结果所有请求线程全部阻塞在 synchronized 上,线程池被打满,日志里看不到任何异常。手动 dump 线程的时候,一堆java.lang.Thread.State: BLOCKED (on object monitor)。关键是,我们根本没办法让这些线程“等一段时间就放弃”。优雅关闭的时候,线程中断信号发过去,synchronized 也不响应。最后只能重启服务才恢复。
这就是 synchronized 的天然短板:一个线程在等待锁的时候,既不能超时,也不能被中断。
1.1 synchronized 的三块天花板
synchronized 是 JVM 语言级的锁,简单可靠,但它有三个绕不过去的限制:
- 等待锁不可中断。线程进入 BLOCKED 状态后,只有拿到锁才能继续,外部无法打断它。这在处理故障时非常被动。
- 无法超时获取。锁被某线程长期持有时,其他线程只能无限等下去。没有“等 3 秒拿不到就算了”这种操作。
- 一个锁只有一个等待集合。
wait/notify只能在一个隐式的条件队列上操作。生产者-消费者场景里,你想分别唤醒“等待写入”的线程和“等待读取”的线程,用 synchronized 做不到细粒度控制。
此外,synchronized 的加锁和解锁由 JVM 字节码指令(monitorenter/monitorexit)管理,加锁和解锁必须发生在同一个代码块结构内。ReentrantLock 本质上就是一个带状态的 Java 类,lock()和unlock()可以在不同方法里调用。这一点带来了灵活性,但也是双刃剑,后面我会专门说它带来的坑。
1.2 两把锁的能力对比
我把两个锁的关键能力拉了一张表,方便你快速对比:
| 能力 | synchronized | ReentrantLock |
|---|---|---|
| 等待锁时响应中断 | 不支持 | lockInterruptibly()支持 |
| 超时获取锁 | 不支持 | tryLock(3, TimeUnit.SECONDS)支持 |
| 公平性 | 非公平 | 默认非公平,可指定公平 |
| 条件变量 | 只有一个 wait/notify 集合 | newCondition()可创建多个 |
| 加锁/解锁位置 | 必须同一代码块 | 方法级任意位置(不推荐,但允许) |
| 锁状态监控 | 不支持 | isHeldByCurrentThread()、getQueueLength()等 |
| 可重入 | 支持 | 支持,且可以查询持有次数 |
面试里问到“为什么有了 synchronized 还要 ReentrantLock”,本质上就是让你说出后面那几行能力,而不是背个定义就完事。
1.3 非块结构锁:灵活是把双刃剑
synchronized的语义很像“自动门禁”,进去、出来都由系统管着,永远不会忘记关门。而ReentrantLock更像“手动门禁”,进门刷卡、出门刷卡,漏一次就会出大问题。
跨方法加解锁在写框架底层时偶尔会用到,但普通业务代码我强烈建议保持“同一层方法内 lock / unlock”,最好再加 try/finally。否则一个分支漏掉 unlock,锁就永远不释放,比 synchronized 定死在同一代码块还要难排查。记住一个原则:用 ReentrantLock 的前提是你能管住自己每一次 unlock。
2. 核心 API 与生产级写法:从 lock() 到 Condition
ReentrantLock 的 API 不多,但每个方法背后都有明确的使用场景。我按“从最常用到最进阶”的顺序逐个拆。
2.1 lock() / unlock() 标准模板:lock 必须在 try 外面
最基础的用法是三件套:
Lock lock = new ReentrantLock(); lock.lock(); try { // 临界区:只有这里需要互斥 } finally { lock.unlock(); }注意lock()必须放在try之前。新手最容易写错的是下面这样:
try { lock.lock(); // 业务 } finally { lock.unlock(); }这写法有隐患:一旦lock()之前或本身抛出异常(比如之后用lockInterruptibly()被中断),线程根本没有持有锁,却会执行finally里的unlock(),直接抛出IllegalMonitorStateException。所以标准模板就是:lock 在外,try 在里,finally 永远 unlock。
另外,加锁后临界区要尽量短。锁里只放必须互斥的代码,别把整个方法都包进去。我见过有人把远程调用、数据库查询都放进临界区,那等于直接把并发性能做成串行,还容易因为网络超时把锁持有几分钟。
2.2 lockInterruptibly():可中断等待锁
lockInterruptibly()与lock()的唯一区别:线程在等待锁的过程中,如果收到中断信号,会抛出InterruptedException,从而退出等待。
典型使用场景:线程池 shutdown 时,想中断那些正在等锁的任务。如果线程还在lock()上傻等,中断信号对它没有效果,任务就退不掉;换成可中断等待,才能保证线程池优雅关闭。
try { lock.lockInterruptibly(); try { // 业务 } finally { lock.unlock(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 响应中断后,重新设置中断标记 // 做降级或清理 }这里有个小细节:捕获InterruptedException后,一定要重新Thread.currentThread().interrupt()设置中断标志,不然上层调用方感知不到这次中断。很多代码踩过这个坑,表面的响应中断做了,实际把中断状态“吃掉了”。
2.3 tryLock():抢锁与超时放弃
tryLock()是 ReentrantLock 比 synchronized 最实用的能力之一。它有两种形式:
tryLock():尝试抢锁,拿到返回 true,拿不到立即返回 false,不阻塞。tryLock(long timeout, TimeUnit unit):等最多 timeout 时间,期间可响应中断。
安全的用法是先判断返回值,再进临界区:
if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 拿到锁,处理业务 } finally { lock.unlock(); } } else { // 超时没拿到锁,走降级逻辑 }这个能力对破坏死锁特别有用。当代码里存在多个锁嵌套时,两个线程各自持有一把锁,又在等对方释放,就形成了循环等待,这是死锁的经典条件之一。如果所有人约定“拿第二把锁用 tryLock,超过 2 秒就释放第一把锁”,循环等待就能被打破,死锁也就不存在了。
2.4 公平锁与非公平锁:构造器的那个 true
new ReentrantLock()默认是非公平锁;new ReentrantLock(true)创建公平锁。
我直接说结论:
- 非公平锁:新来的线程在锁刚释放时,可以“插队”抢到锁,不一定要排到队尾。
- 公平锁:基本按照请求锁的顺序(FIFO)来,先来先得。
- 公平锁在底层会调用
hasQueuedPredecessors()检查队列里有没有排在自己前面的线程,有了就让位。
面试高频问题“为什么非公平锁性能更好”,有两点原因:
- 减少上下文切换。锁刚释放时,如果让持有锁的线程立刻重新拿到锁并继续执行,新线程的那段计算往往还在旧线程栈或缓存里,直接继续跑比唤醒队列里的另一条线程成本低得多。
- 避免唤醒开销。公平锁每次释放都要唤醒队尾或者队头的阻塞线程,唤起一个 BLOCKED 线程需要操作系统介入,耗时远大于一次 CAS。
但非公平锁的代价是,极端情况下队尾线程可能长时间吃不到锁,也就是“饥饿”。实际生产里,这种饥饿概率很低,所以默认大家都用非公平锁。只有业务上对处理顺序敏感,比如交易撮合、任务分配严格按提交顺序来执行时,才考虑公平锁。
2.5 newCondition():一锁多条件
这是 ReentrantLock 区别于 synchronized 的另一个大杀器。synchronized一个锁只有一个隐式条件队列,所以wait/notify唤醒时没法区分“因为队列满而等待的生产者”和“因为队列空而等待的消费者”。
newCondition()可以创建任意多个条件队列,每个 Condition 都有自己的await()和signal():
Lock lock = new ReentrantLock(); Condition notEmpty = lock.newCondition(); // 队列非空条件 Condition notFull = lock.newCondition(); // 队列未满条件生产者等待notFull,消费者等待notEmpty。队列有位置时,只signal()唤醒生产者;队列有元素时,只signal()唤醒消费者。精准唤醒,不会像notifyAll那样把不相关的线程全部叫起来抢一遍锁。
3. 深入 AQS:ReentrantLock 的底层引擎
ReentrantLock 的代码本身不复杂,真正的复杂度全在 AbstractQueuedSynchronizer(AQS)里。你搜“aqs java”,网上分析很多,但核心其实就三件事:状态、队列、阻塞唤醒。
3.1 AQS 是什么:一套被复用无数次的同步器骨架
AQS 不是一个锁,而是一个用来构建锁和同步器的抽象框架。ReentrantLock 的同步逻辑,CountDownLatch 的计数逻辑,Semaphore 的信号量逻辑,底层全是 AQS。
它的设计思路很简单:把“占用/释放”这个动作抽象成修改一个 int 状态,把不能立刻获取资源的线程放进一个队列里排队等待,等占用者释放时再按规则唤醒来。
3.2 state:那个决定谁有资格执行关键代码的数字
AQS 内部有一个volatile int state。对 ReentrantLock 来说,这个就是锁的持有次数:
state == 0:锁没有人持有。state == 1:某个线程持有锁一次。state > 1:持有锁的线程又多次重入,每重入一次加一。
为什么要用int而不是布尔值?两个原因。一是可重入计数需要支持累加;二是 AQS 要被共享模式使用(比如信号量、CountDownLatch),它们需要计数语义。volatile保证这个状态在多个线程之间的可见性,配合 CAS 操作来实现原子修改。
线程拿锁的核心动作就是:CAS 把 state 从 0 改成 1,成功就说明抢到了,之后把“当前持有锁的线程”记成自己。就这么简单。
3.3 CLH 变体队列:线程怎么排队
抢锁失败的线程不会直接死循环,而是被封装成一个Node节点,进入一个 FIFO 双向链表,然后调用LockSupport.park()挂起自己。这个队列是 CLH 锁的一个变体,在 AQS 里是内部类Node组成的双向队列。
每个 Node 有四个关键信息:
thread:排队的线程对象。prev/next:前驱和后继节点。waitStatus:当前节点的等待状态,常见的有:CANCELLED = 1:线程被取消,不再等待。SIGNAL = -1:当前节点的后继线程需要被唤醒。CONDITION = -2:线程在条件队列中等待。
整个流程可以这样理解:线程 A 抢锁失败,被放进队列尾部并挂起;线程 B 释放锁时,会找到队列里第一个有效的节点,调用LockSupport.unpark()唤醒它。被唤醒的线程醒来后再次尝试抢锁,成功则退出队列,失败则继续挂起。
这里最妙的设计是把“排队”和“阻塞唤醒”组合在一起:不需要用户态自旋消耗 CPU,也不需要每次都做全系统调用,平均性能非常均衡。
3.4 公平与非公平:就差了一个 hasQueuedPredecessors
公平锁和非公平锁在 AQS 里的实现差异小到惊人。
非公平锁的tryAcquire核心逻辑:
final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { // 直接 CAS 抢锁,不管队列里有没有人 if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { // 可重入计数 int nextc = c + acquires; setState(nextc); return true; } return false; }公平锁在此基础上多了一行判断:
if (c == 0) { // 如果队列里有排在自己前面的线程,就放弃抢锁 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } }hasQueuedPredecessors(),翻译成人话就是:我的前面还有没有其他线程在排队?有就让,没有才抢。所以公平锁不是复杂,而是多了一个“检查队首”的动作。这个动作本身也有开销,这就是公平锁吞吐更低的直接原因。
3.5 一次完整的加锁解锁旅程
我用非公平锁举例,把整个链路串起来:
- 线程 A 执行
lock(),调用 AQS 的acquire(1)。 - 执行
tryAcquire(1),此时 state=0,CAS 成功,设置exclusiveOwnerThread = A,线程 A 进入临界区。 - 线程 B 执行
lock(),此时 state=1,CAS 失败,tryAcquire返回 false。 - B 被
addWaiter()封装成 Node,加入队列尾部,随后acquireQueued()里把自己挂起(LockSupport.park())。 - 线程 A 执行
unlock(),调用release(1),执行tryRelease(1):state 从 1 减到 0,清空exclusiveOwnerThread,返回 true。 release继续调用unparkSuccessor(),唤醒队列头部的 Node,也就是线程 B。- 线程 B 从 park 返回,再次执行
tryAcquire,CAS 成功,进入临界区。
一条线下来,你会发现 ReentrantLock 的全部魔法其实就是state + CAS + LockSupport + 双向队列四样东西的组合。
4. 面试高频拷问与工程决策
到了这一节,我把面试里针对 ReentrantLock 最常见的几个问题,以及生产里我实际踩过的错误放在一起说。
4.1 为什么 state 要设计成 int 而不是 boolean
面试官喜欢从一个细节切入。前面说了,int 能支持可重入计数,这是直接原因。但更深一层是 AQS 本身的通用性:CountDownLatch需要把 state 看成“剩余倒数次数”,Semaphore需要把 state 看成“剩余许可证数”,这些场景全部需要计数能力。如果用 boolean,AQS 就做不成这些工具了。
所以回答时不要只说“为了可重入”,要把“AQS 作为通用同步器框架,需要支持计数语义”这个维度补上,面试官会觉得你理解了设计目的。
4.2 非公平锁为什么性能反而更好
前面第 2 节提过,这里再往深补一层。非公平锁的代码路径最短:新线程到达时,如果锁正好空闲,直接 CAS 改 state 结束,不需要操作队列,不涉及唤醒线程。而公平锁必须经历“检查队列→入队→park→unpark→再次抢锁”的完整流程,线程挂起和唤醒都是重量级操作。
这也是为什么 Java 官方把 ReentrantLock 的默认实现做成非公平的——与 synchronized 的 monitor 行为保持一致,也符合大多数并发场景的性能预期。公平是需求,非公平是默认。
4.3 synchronized 与 ReentrantLock:现在到底怎么选
到 JDK 8 之后,synchronized 经过锁升级(偏向锁、轻量级锁、重量级锁)优化,简单互斥场景下性能已经和 ReentrantLock 没有明显差距,而且代码更简洁、不会忘记解锁。所以我的选型清单是:
- 默认优先 synchronized:没有特殊需求,直接用,简洁且安全。
- 必须用 ReentrantLock 的场景:需要超时获取锁、需要等待锁可中断、需要多个条件队列、需要公平锁、需要锁状态监控。
- 读多写少场景:考虑
ReentrantReadWriteLock或StampedLock,读读不互斥能明显提高吞吐。 - 能不用锁就不用锁:优先考虑原子类、并发集合、不可变对象设计。
你可以把 synchronized 想象成公交车,到站就停,谁都能上;ReentrantLock 更像打车,能指定路线(公平性、超时、中断),但需要你自己记着付钱(unlock)。
4.4 三个典型的 Lock 生产错误
第一类:把 lock() 放进 try。前面讲过,这在lockInterruptibly()时会直接引发IllegalMonitorStateException。
第二类:在 Condition 上面用 if 而不是 while。await()可能发生虚假唤醒,也可能被多个线程同时唤醒,条件可能再次不满足。正确姿势永远是:
while (条件不满足) { condition.await(); }第三类:忘记在提前 return 时 unlock。比如临界区里写了多个分支,某个分支直接return,如果没统一在 finally 里 unlock,锁就泄漏了。锁泄漏比线程泄漏更难发现,因为代码还能跑,只是性能越来越差、最终线程池耗尽。所以必须无条件使用 try/finally,不要相信自己的记忆力。
5. 实战:手写一个双条件阻塞队列
最后用一个完整案例收尾:基于 ReentrantLock 的双 Condition 实现一个简单的阻塞队列。这个例子几乎覆盖了前面所有知识点,面试里也经常让你手写。
5.1 需求与设计思路
阻塞队列需要两个动作:
put():如果队列已满,生产者阻塞等待,直到有空位。take():如果队列为空,消费者阻塞等待,直到有元素出现。
如果用 synchronized +wait/notifyAll,每次唤醒会把所有等待线程都叫起来,然后各自检查条件,存在大量无效竞争。用两个 Condition 就可以做到精准唤醒:队列满时生产者 await,队列空时消费者 await;写入成功后唤醒“队列非空”条件上的消费者,取出元素后唤醒“队列未满”条件上的生产者。
5.2 代码实现与逐段解读
import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class SimpleBlockingQueue<T> { private final Queue<T> items = new LinkedList<>(); private final int capacity; private final Lock lock = new ReentrantLock(); private final Condition notEmpty = lock.newCondition(); private final Condition notFull = lock.newCondition(); public SimpleBlockingQueue(int capacity) { this.capacity = capacity; } public void put(T item) throws InterruptedException { lock.lockInterruptibly(); try { while (items.size() >= capacity) { notFull.await(); // 队列满了,生产者挂起 } items.add(item); notEmpty.signal(); // 通知消费者:有东西可以取了 } finally { lock.unlock(); } } public T take() throws InterruptedException { lock.lockInterruptibly(); try { while (items.isEmpty()) { notEmpty.await(); // 队列空了,消费者挂起 } T item = items.poll(); notFull.signal(); // 通知生产者:有位置可以放了 return item; } finally { lock.unlock(); } } }代码核心点:
lockInterruptibly()放在 try 外,然后 finally 里无条件 unlock。这是标准姿势。- 两个 Condition 分别服务生产者和消费者的等待,信号互不混淆。
- 条件判断用
while,防止虚假唤醒,也防止多消费者同时被唤醒后“抢到空队列”。 signal()是精准唤醒一个线程。多个生产者或消费者时建议用signalAll(),否则可能出现“我只唤醒了一个生产者,但它又因为条件不满足继续等,导致所有生产者都睡着”的情况,这叫信号丢失。
5.3 这个案例说明了什么
对比一下 synchronized 版本,你会发现 ReentrantLock 的两个核心价值在这段代码里全部体现了:
- 多条件变量:一个锁配两个 Condition,实现生产者和消费者互不干扰的精准唤醒。
- 可中断等待:
await()期间收到中断会抛出异常,线程可以退出阻塞;换成wait()虽然也能响应中断,但条件控制和信号精度差一截。
如果只是把代码跑通,这个例子很简单。但真正把它想透,你对 AQS、Condition 和锁规范的理解就已经达到生产级了。
最后说点个人经验。我给团队定过一个规矩:默认写 synchronized,只有遇到超时获取、可中断等待、多条件队列、公平锁、锁监控这五类需求才换成 ReentrantLock。换的时候,代码评审上重点盯三件事:lock 是否在 try 外、finally 是否无条件 unlock、Condition 是否用了 while 循环判断。守住这三条,Lock 基本不会给你惹麻烦。