ReentrantLock与synchronized深度对比:从AQS源码到高并发实战

发布时间:2026/9/9 10:33:39
ReentrantLock与synchronized深度对比:从AQS源码到高并发实战 写这篇文章前我先说个背景。最近帮几个刚转Java的朋友过面试题几乎每个人都被问到同一个问题你说说ReentrantLock和synchronized有什么区别结果十个里有八个只答出“公平锁、可中断、超时”这几个关键词再往下问AQS怎么排队、State怎么变化、Condition怎么用就卡住了。不是说这些关键词不对而是停留在背答案层面面试官再深挖一层就直接露馅。所以我想把ReentrantLock这条线完整拆一遍从设计思想到源码逻辑再到实际场景怎么用一次讲透不绕弯子。1. 可重入锁到底在解决什么问题1.1 先理解“可重入”三个字的含义可重入锁英文叫Reentrant Lock核心含义是同一个线程可以重复获取同一把锁。打个比方你进了自己家门门锁会记录你的身份你在家里进卧室、进书房不需要再掏钥匙开门因为系统已经认你了。如果换成普通锁进卧室还得重新验证一次身份自己进自己家都费劲。放到代码里场景更具体。比如一个类里有方法A和方法BA调用了B两个方法都被synchronized修饰。如果synchronized不可重入线程执行A拿到锁进入A后调用BB又尝试拿同一把锁结果自己把自己锁死了这就是死锁。好在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等待队列用来存放拿不到锁的线程。state0表示锁空闲state1表示锁被占用如果同一个线程多次获取锁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它实际调用的是nonfairTryAcquireprotected 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的tryReleaseprotected 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 完整对比维度表格面试被问到这类问题我建议从四个维度组织答案使用方式、功能灵活度、底层实现、适用场景。先把对比表放上来方便读者记忆对比维度synchronizedReentrantLock加锁方式自动加锁修饰方法或代码块手动调用lock()加锁解锁方式自动释放退出方法或异常时自动解锁必须手动调用unlock()通常放finally锁公平性默认非公平默认非公平可构造公平锁获取锁方式若不满足条件则一直阻塞tryLock支持尝试获取带超时获取中断响应线程被中断会一直等待锁lockInterruptibly支持中断响应条件变量通过wait/notify简单支持newCondition可创建多个条件队列底层实现JVM指令monitorenter/monitorexitAQS的stateCAS排队队列可重入支持支持性能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. 实战高并发库存扣减怎么用ReentrantLock5.1 低级写法为什么线程不安全很多新手写库存扣减第一反应是直接用普通变量加减。比如public void decreaseStock() { if (stock 0) { stock--; } }这在单线程下没问题但多线程下就是灾难。stock--看起来是一句话实际上对应三条指令读stock的值、算stock减一、把结果写回stock。两个线程同时读到stock1各自减一都写回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返回异常没有捕获InterruptedExceptiontryLock带超时会抛中断异常注意处理中断状态锁内执行慢整体吞吐下降锁内做了耗时操作缩小锁范围将耗时逻辑移除临界区公平锁性能明显下降上下文切换开销变大没有公平性要求时改用默认非公平锁实际排查并发问题有一个常规但很有效的工具先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这类工具排查几次之后你就不会再害怕这些锁的问题了。

相关新闻