写这篇文章前,我先说个背景。最近帮几个刚转Java的朋友过面试题,几乎每个人都被问到同一个问题:你说说ReentrantLock和synchronized有什么区别?结果十个里有八个只答出“公平锁、可中断、超时”这几个关键词,再往下问AQS怎么排队、State怎么变化、Condition怎么用,就卡住了。不是说这些关键词不对,而是停留在背答案层面,面试官再深挖一层就直接露馅。所以我想把ReentrantLock这条线完整拆一遍,从设计思想到源码逻辑,再到实际场景怎么用,一次讲透,不绕弯子。
1. 可重入锁到底在解决什么问题
1.1 先理解“可重入”三个字的含义
可重入锁,英文叫Reentrant Lock,核心含义是同一个线程可以重复获取同一把锁。打个比方,你进了自己家门,门锁会记录你的身份,你在家里进卧室、进书房,不需要再掏钥匙开门,因为系统已经认你了。如果换成普通锁,进卧室还得重新验证一次身份,自己进自己家都费劲。
放到代码里,场景更具体。比如一个类里有方法A和方法B,A调用了B,两个方法都被synchronized修饰。如果synchronized不可重入,线程执行A拿到锁,进入A后调用B,B又尝试拿同一把锁,结果自己把自己锁死了,这就是死锁。好在synchronized本身就可重入,所以这个例子不会出问题。ReentrantLock同样具备这个能力,并且它在可重入的基础上提供了更多控制权。
这个“更多控制权”体现在哪里?拿锁的方式、等待锁的方式、排队的方式,你都可以自己定制。synchronized是JVM帮你管,你插不上手。ReentrantLock是给你一把工具,你想怎么用、什么时候用、用多久,都由你决定。这就是它存在的根本意义。
1.2 可重入锁和synchronized的定位差异
很多新手有个误解,觉得ReentrantLock是synchronized的升级版,性能更好。这个说法在JDK 1.5之前勉强成立,但从JDK 1.6开始,synchronized经过锁升级优化,性能已经不输ReentrantLock,甚至在低竞争场景下更优。所以选型的关键不是性能,而是功能需求。
synchronized的优点是代码干净,不需要手动释放锁,出了异常JVM会自动释放,永远不会出现锁泄漏。缺点是功能单一,做不到尝试获取锁、限时等待、公平排队、多个条件变量。ReentrantLock恰恰在补这些短板。
我的建议很简单:如果业务逻辑只需要简单的互斥,或者你不想为锁的释放操心,优先用synchronized。如果需要灵活的加锁策略,比如拿不到锁就去做别的事、最多等2秒、让等待线程按先来后到的顺序拿锁,这时候ReentrantLock才是正确的选择。搞清楚这一点,你就不会在面试里说出“ReentrantLock性能更好所以都用它”这种行外话了。
2. ReentrantLock源码核心机制拆解
2.1 AQS:所有锁的地基
ReentrantLock的源码如果不提AQS,基本等于白看。AQS全称AbstractQueuedSynchronizer,是Java并发包的基石,Semaphore、CountDownLatch、ReentrantReadWriteLock全都建立在它之上。你可以把它理解成一个管排队叫号的系统。
AQS内部维护两个核心东西:一个是volatile int state,用来记录锁的占用状态;另一个是FIFO等待队列,用来存放拿不到锁的线程。state=0表示锁空闲,state>=1表示锁被占用,如果同一个线程多次获取锁,state就累加,释放一次减一,减到0才算完全释放。
等待队列是双向链表,每个节点包含线程引用和等待状态。线程拿不到锁,就包装成节点挂到队尾,然后调用LockSupport.park()把自己挂起。持有锁的线程释放锁时,会唤醒队首的等待线程。这套机制不复杂,但设计得极其精巧,所有同步器的模板方法都围绕它展开。
2.2 lock方法的背后:走了哪些逻辑
看ReentrantLock的lock()方法,如果你点进去会发现它分两步。先看一下内部类NonfairSync的代码逻辑:
final void lock() { // 非公平锁进来先直接抢一次,抢到了就设置独占线程为当前线程 if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }这里的关键点是,非公平锁在正式走排队流程之前,先通过CAS插队试了一次。这就是“非公平”的直接体现——新来的线程可以和队列里排队的线程竞争锁,谁抢到算谁的。compareAndSetState(0, 1)的意思是,只有当当前state是0时,才把它改成1,这个过程是原子的,靠CPU的CAS指令保证。
如果CAS失败,说明锁被占用或者正在被别的线程竞争,就进入acquire(1)。acquire是AQS的模板方法,内部做了三件事:再次尝试获取锁、如果获取不到就把当前线程封装成节点入队、入队后挂起线程等待被唤醒。
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }tryAcquire在ReentrantLock中被重写,这里能看出可重入的具体实现。看NonfairSync的tryAcquire,它实际调用的是nonfairTryAcquire:
protected final boolean tryAcquire(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()) { // 当前线程已经持有锁,可重入,state加1 int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }注意第二个分支:判断当前线程是不是锁的持有者。如果是,state直接加1,不需要CAS,因为锁本来就归你,没有竞争。这就是可重入的底层实现,简单到令人发指,但就是这行代码支撑起了可重入锁的整个语义。
2.3 unlock方法的对称逻辑:一个都不能少
解锁和加锁是对称的,理解了解锁你才能理解什么叫“加锁几次就要解锁几次”。unlock()内部调用的是AQS的release方法,最终落到Sync的tryRelease:
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才把独占线程清空。因为可重入,每次unlock()只能把state减1,必须减到0,锁才算真正释放。这就是为什么加锁N次必须解锁N次,多解会报IllegalMonitorStateException,少解会导致别的线程永远拿不到锁。
写代码最容易踩的坑就在这。很多人只lock不unlock,或者lock和unlock不在同一个try-finally层级,代码一复杂就漏掉。一个通用的稳妥写法是这样的:
ReentrantLock lock = new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }这是标准范式,核心原则是unlock必须放在finally里保证一定执行。这个写法不是最优的变体,但它是你一定不会错的写法,尤其适合刚接触ReentrantLock的阶段。
3. 公平锁和非公平锁:底层差异在哪
3.1 两种模式的源码对比
面试里关于公平锁的问题频率极高,问的细的面试官会让你说出公平锁和非公平锁的实现差异。两者的代码差异就在lock()方法的入口。
公平锁的lock一开始不会直接CAS抢锁,而是直接走acquire(1),最终进入tryAcquire。公平锁的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()是AQS中的方法,用来判断当前线程前面是否还有等待的线程。如果有返回true,说明你必须排队,不许插队。返回false,说明你可能是队首,可以尝试获取。公平锁保证先来后到,代价是线程上下文切换更频繁,吞吐量通常低于非公平锁。
3.2 为什么默认是非公平锁
ReentrantLock默认构造器用的是非公平锁:
public ReentrantLock() { sync = new NonfairSync(); }只有显式传true才创建公平锁:
public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }为什么偏向非公平?两点原因。第一是性能,非公平锁允许新线程直接抢一次,抢不到才去排队,减少了线程挂起和唤醒的开销。高并发场景下,非公平锁的吞吐量通常高于公平锁。第二是公平锁在极端情况下可能出现线程频繁唤醒反而降低效率的问题,而且“公平”本身也只是相对的,并不能带来更好的用户体验。
所以实际项目里,除非业务明确要求等待时间公平,比如交易撮合系统要求严格按到达顺序处理,否则默认用非公平锁即可。这个选择不是拍脑袋,是性能和需求的权衡结果。
4. 与synchronized对照:面试怎么答才显得有深度
4.1 完整对比维度表格
面试被问到这类问题,我建议从四个维度组织答案:使用方式、功能灵活度、底层实现、适用场景。先把对比表放上来,方便读者记忆:
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 加锁方式 | 自动加锁,修饰方法或代码块 | 手动调用lock()加锁 |
| 解锁方式 | 自动释放,退出方法或异常时自动解锁 | 必须手动调用unlock(),通常放finally |
| 锁公平性 | 默认非公平 | 默认非公平,可构造公平锁 |
| 获取锁方式 | 若不满足条件则一直阻塞 | tryLock支持尝试获取,带超时获取 |
| 中断响应 | 线程被中断会一直等待锁 | lockInterruptibly支持中断响应 |
| 条件变量 | 通过wait/notify简单支持 | newCondition可创建多个条件队列 |
| 底层实现 | JVM指令monitorenter/monitorexit | AQS的state+CAS+排队队列 |
| 可重入 | 支持 | 支持 |
| 性能 | JDK 1.6优化后与Lock差距不大 | 高并发复杂场景下更可控 |
这个表格看一眼就能记住大概,但面试要的不是背表格。你要能把关键差异用代码场景展开。
4.2 面试官最常追问的三个细节
追问一:“你说synchronized不能中断,那它有办法退出等待吗?”这个问题有陷阱。synchronized等待锁的过程是阻塞的,线程不会因为被interrupt()而退出等待,必须等拿到锁后中断标志才会被处理。ReentrantLock的lockInterruptibly()完全不同,它是可中断的,线程在等待锁期间被中断会立刻抛出InterruptedException退出等待。这个差异在实际场景中很重要,比如服务要优雅停机,线程等待锁不能被无限阻塞。
追问二:“tryLock有什么用?和lock有什么区别?”这是面试中的高频考点。lock()拿不到锁就一直等,没有退路。tryLock()立刻返回,拿不到就返回false,你可以走分支逻辑。还有tryLock(3, TimeUnit.SECONDS),限时等待3秒,超时返回false。这种“拿不到锁就干别的”的能力,在高并发场景很有用,比如用Redis优化库存时多个实例抢锁,抢不到就不等了,直接返回失败给上层。
追问三:“多条件变量Condition解决了什么问题?”这是一个加分点。synchronized只有一个等待池,一个锁只能配合一组wait/notify,你想区分“等待队列满”和“队列空”两种情况很难,得自己用标志位分。ReentrantLock可以创建多个Condition,每个Condition有自己独立的等待队列,signal()精确唤醒有条件的选择。
我之前写过组合锁的例子来演示多Condition的价值,这是Java并发编程中一个经典的生产者消费者模型:
ReentrantLock lock = new ReentrantLock(); Condition notEmpty = lock.newCondition(); Condition notFull = lock.newCondition();生产者put数据后执行notEmpty.signal(),只唤醒等待取数据的消费者。消费者take数据后执行notFull.signal(),只唤醒等待放入的生产者。互不干扰,语义清晰,这就是多Condition的实际价值。
4.3 代码例子:“限时尝试获取锁”的典型写法
业务里有这样一个需求:把用户提交的任务写入队列,但队列满了,就不让用户一直等,而是去查数据库。怎么优雅实现?tryLock就能派上用场:
ReentrantLock lock = new ReentrantLock(); boolean acquired = false; try { // 最多等500毫秒,拿不到就算了 acquired = lock.tryLock(500, TimeUnit.MILLISECONDS); if (acquired) { // 拿到锁,写入队列 System.out.println("获取锁成功,执行任务写入"); } else { // 拿不到锁,降级处理,直接走数据库 System.out.println("获取锁超时,降级处理"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (acquired) { lock.unlock(); } }有个细节值得注意:finally里解锁前要先判断acquired,因为若是没拿到锁就解锁会抛异常。这也是容易踩的坑,尤其在你用tryLock的时候。
5. 实战:高并发库存扣减怎么用ReentrantLock
5.1 低级写法为什么线程不安全
很多新手写库存扣减,第一反应是直接用普通变量加减。比如:
public void decreaseStock() { if (stock > 0) { stock--; } }这在单线程下没问题,但多线程下就是灾难。stock--看起来是一句话,实际上对应三条指令:读stock的值、算stock减一、把结果写回stock。两个线程同时读到stock=1,各自减一,都写回0,但实际应该扣两次变-1,这就丢了更新。这就是典型的竞态条件。要保证正确性,就得让加减操作变成原子操作,要么用锁,要么用AtomicInteger。
5.2 用ReentrantLock保护库存操作
这里写一个模拟真实场景的库存扣减例子。有10个商品,20个线程同时抢购,每个线程最多买1个。用ReentrantLock保证剩余库存的计算是原子操作:
import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantLock; public class StockService { // 模拟数据库中的库存 private int stock = 10; private final ReentrantLock lock = new ReentrantLock(); // 统计成功购买次数 private final AtomicInteger successCount = new AtomicInteger(0); public boolean purchase() { lock.lock(); try { if (stock > 0) { // 模拟做一些耗时操作,比如写订单、更新缓存 Thread.sleep(5); stock--; successCount.incrementAndGet(); return true; } return false; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { lock.unlock(); } } public int getStock() { lock.lock(); try { return stock; } finally { lock.unlock(); } } public static void main(String[] args) throws InterruptedException { StockService service = new StockService(); int threadCount = 20; CountDownLatch latch = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { new Thread(() -> { boolean success = service.purchase(); if (success) { System.out.println(Thread.currentThread().getName() + " 购买成功"); } else { System.out.println(Thread.currentThread().getName() + " 购买失败,库存不足"); } latch.countDown(); }, "用户-" + i).start(); } latch.await(); System.out.println("剩余库存: " + service.getStock()); System.out.println("成功购买次数: " + service.successCount.get()); } }运行结果符合预期,20个线程抢10个商品,最终成功购买10次,剩余库存0。整个购买过程被锁保护,不会出现超卖。
有一点值得注意的是,在purchase方法里我做了一次Thread.sleep(5)来模拟耗时操作,这虽然把锁的持有时间拉长了,降低了并发度,但它保证了可见性和原子性。真实项目里,需要保持锁内操作尽量短,不要在锁里做过重的IO;如果你非要在锁里做网络请求或数据库写操作,要确认业务能接受这个锁等待时间。这是一个实际项目中需要取舍的地方,不一定有标准答案,但你要清晰知道自己在牺牲什么。
5.3 锁内操作尽量短:一个实际教训
我见过一个真实线上问题,有个同事在持有ReentrantLock的代码块里调了远程服务,结果远程服务超时5秒,所有线程全堵在锁上,系统吞吐直接降级到0,日志里全是线程阻塞堆积。最后加了连接超时、缩了锁范围才恢复。
这是一个典型的反面教材。锁内只应该包含必须互斥的临界区代码,任何耗时操作都要想办法挪出去。如果临界区必须做IO,可以考虑用读写锁区分读多写少场景,或者用tryLock加超时避免无限期排队。
这里顺手说个判断标准,当你在锁内准备加一行代码时,先问自己三个问题:这行代码会影响共享变量的正确性吗?它能被挪到锁外面吗?它能被我容忍较长的执行时间吗?如果答案分别是“否、能、不能”,就应该把它移出去。
6. 常见问题与面试题速查
6.1 高频问题排查表
结合网上的高频问题和我自己的经验,整理了几个典型问题和排查方向,直接看表:
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 程序卡死,所有线程都阻塞 | 锁没有正确释放,lock和unlock数量不匹配 | 检查finally中是否保证unlock;用jstack查看线程栈确认卡在哪个锁 |
| 抛IllegalMonitorStateException | 非持有锁的线程调用了unlock | 检查加锁和解锁是否在同一把锁实例上,解锁前是否确认已获取锁 |
| tryLock返回异常 | 没有捕获InterruptedException | tryLock带超时会抛中断异常,注意处理中断状态 |
| 锁内执行慢,整体吞吐下降 | 锁内做了耗时操作 | 缩小锁范围,将耗时逻辑移除临界区 |
| 公平锁性能明显下降 | 上下文切换开销变大 | 没有公平性要求时改用默认非公平锁 |
实际排查并发问题有一个常规但很有效的工具:先jps找到进程号,再用jstack <pid>看线程转储。你会在栈信息里清楚看到线程卡在哪个LockSupport.park或哪个ReentrantLock.lock检查点,结合代码定位就能快速找到是哪个锁不肯释放。
6.2 我建议你理解的三个底层概念,而不仅仅是背
第一,state的意义。它不是简单的“0表示没锁,1表示有锁”,在可重入场景它是重入次数的计数器。理解这一点,你就会主动保证lock和unlock次数对称。第二,LockSupport.park与unpark。AQS的等待队列其实就是靠这两个方法挂起和唤醒线程,synchronized背后也是类似机制,理解它你会明白为什么等待锁的线程可以被中断。第三,Condition的等待队列。它和AQS主队列是两条独立的队列,所以才能做到精准唤醒。
应付面试和实际写代码不一样。面试只需要定义清晰,回答流畅;实际写代码就要考虑锁粒度、锁时长、降级方案、故障排查。我见过太多人面试时侃侃而谈,线上出问题却不知道怎么用jstack查线程,这不是技术深度不够,而是经验积累不够。多花点时间在写真实并发场景的代码上,比背一百道面试题都管用。
6.3 一个小技巧:打印一下AQS的等待队列信息
想直观感受ReentrantLock的排队机制,可以写一个简单demo,让几个线程同时竞争同一把锁,然后在持锁线程里打印jstack信息。在JDK 8以上版本,你可以在持锁代码块加一行:
System.out.println(Thread.currentThread().getName() + " 持锁中"); // 查看线程转储 for (StackTraceElement[] stack : Thread.getAllStackTraces().values()) { for (StackTraceElement element : stack) { if (element.getClassName().contains("concurrent.locks")) { System.out.println(element); } } }运行时会看到其他线程停在AbstractQueuedSynchronizer.parkAndCheckInterrupt,这就是它们挂起等待的位置。亲眼见到这个过程,比看任何源码分析都记得牢。
写在最后
说实话,ReentrantLock的源码我读过不止一遍,每次都有新收获。一开始关注的是怎么加锁解锁,后来看AQS队列,再后来才意识到设计精髓在于state和线程状态的精确控制。尤其是可重入这个机制,看似简单,但它和同步器整体的状态管理紧密绑定。如果你也在学Java并发,我的建议是不要只盯着某一把锁看,花点时间把AQS整体走通,ReentrantLock、Semaphore、CountDownLatch的很多疑问都会迎刃而解。遇到项目里的并发问题,多试试jstack和jvisualvm这类工具,排查几次之后你就不会再害怕这些锁的问题了。