资讯动态

深入ReentrantLock:补足synchronized超时、中断与公平协作短板

发布时间:2026/10/17 5:55:47 来源:尧图企业网站定制
做后端开发这些年synchronized一直是我处理并发问题的默认选择语法简单、JVM自动管理加锁解锁写起来几乎不费脑。不过有两回线上事故让我对它产生了动摇一次是某抢购活动开始后一个持有锁的线程因为外部接口超时迟迟不释放锁后面的请求全部卡在锁上我从日志里什么都看不出来连限时等待锁这个基本诉求都无法用synchronized表达另一次是批量对账任务执行到一半需要优雅中断却发现等待锁的线程根本响应不了interrupt信号。这两次教训之后我把ReentrantLock完整研究了一遍并把它引入了核心链路。这篇Java进阶教学系列文章就来系统讲一讲ReentrantLock它到底补上了synchronized哪些短板、内部机制如何理解、lock/tryLock/lockInterruptibly三种获取方式怎么选以及Condition配合多线程协作的实战写法。末尾还会分享一次我真实经历过的线程全部挂起排障过程希望能让你少走弯路。内容面向已经会用synchronized、想深入掌握显式锁机制的Java开发者。1. synchronized不够用的时候ReentrantLock在补什么1.1 三个让synchronized无能为力的场景接着开头那两个事故往下说。synchronized的问题不是性能而是它在控制力度上给得太少。场景一无法限时等待。synchronized一旦进入阻塞只能被动等待锁释放没人能保证那个持锁线程什么时候退出。在依赖外部服务的链路里这种无限等待往往意味着整个线程池被拖垮。我当时最直接的诉求是最多等三秒等不到就返回失败synchronized给不了。场景二无法响应中断。批量任务发布下线指令后希望等待锁的现场线程能立刻感知中断并退出synchronized的阻塞被设计为不响应interrupt线程只能等到获取锁后才能检查中断标志这个机制在任务可以随时被叫停的场景里非常被动。场景三无法控制公平性。多个线程争抢同一把锁时synchronized采用的是非公平策略——新来的线程可以插队。绝大多数场景下非公平吞吐更好但在某些对先来先服务有要求的场景比如有严格顺序的批处理任务你需要一把公平锁。这三个场景锁定了ReentrantLock的存在意义它提供可中断、可超时、可选公平策略的显式锁把加锁和解锁的主动权完全交到开发者手里。1.2 Lock接口ReentrantLock只是第一个实现ReentrantLock从Java 1.5开始加入它实现了java.util.concurrent.locks.Lock接口。这个接口定义了锁操作的基本契约常用方法如下public interface Lock { void lock(); // 获取锁拿不到就阻塞 void lockInterruptibly() throws InterruptedException; // 可中断地获取锁 boolean tryLock(); // 非阻塞尝试立即返回 boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 限时尝试 void unlock(); // 释放锁 Condition newCondition(); // 创建与该锁绑定的条件变量 }ReentrantLock是Lock接口最典型、用得最多的实现。类名里的Reentrant可重入直接点出了它最重要的特性。很多人在第一次接触时会困惑我用synchronized好好的为什么要引入一个必须手动unlock的锁答案就在上面三个场景里——你希望获得对阻塞等待过程的控制权就必须接受手动管理锁生命周期的责任。synchronized底层是monitorJVM帮你在字节码层面完成了加锁和解锁的埋点ReentrantLock则完全是API层面的调度它把什么时候加锁、什么时候释放、等不及了怎么办这些决策全部交给了你。2. 重入与公平两个决定ReentrantLock行为的关键设计2.1 可重入性同一线程可以反复持有同一把锁可重入的意思是如果某个线程已经持有了这把锁那么它再次执行lock()时不会阻塞自己而是直接成功每成功获取一次内部计数加1每解锁一次计数减1当计数归零时锁才真正释放给其他线程。看一个典型例子public class ReentrantDemo { private final ReentrantLock lock new ReentrantLock(); public void outer() { lock.lock(); try { System.out.println(outer 获取锁); inner(); } finally { lock.unlock(); } } public void inner() { lock.lock(); try { System.out.println(inner 再次获取锁); } finally { lock.unlock(); } } }如果把锁换成不可重入的实现outer()调用inner()时会直接死锁——自己把自己堵在外面。synchronized同样是可重入的所以这个特性我们平时几乎感知不到但理解它很重要因为它直接关联到ReentrantLock的底层计数机制。ReentrantLock内部基于AQSAbstractQueuedSynchronizer实现核心是一个volatile的int state字段配合一个先进先出的等待队列。state等于0表示锁空闲大于0表示当前持有线程重复获取的次数。每次lock()让state加1每次unlock()让state减1归零后AQS会唤醒队列中下一个等待线程。这也是为什么unlock()必须在finally里调用一旦你在某个分支漏掉一次unlockstate就可能无法归零锁永远不会被释放。计数机制没有任何兜底全靠调用者自律。这里顺便说一个排障时很好用的APIgetHoldCount()返回当前线程持有这把锁的次数isHeldByCurrentThread()判断当前线程是否持有锁getQueueLength()看有多少线程在等待。我排查死锁类问题时会用它在关键节点打日志比事后看线程快照更直观。2.2 公平锁与非公平锁选公平不等于选正确构造ReentrantLock时可以传入fair参数new ReentrantLock(true)表示公平锁new ReentrantLock()默认是非公平锁。非公平锁的含义是一个线程来抢占时会先尝试一次CAS直接改state抢到了就直接拥有锁完全无视后面排队的人抢不到才进入AQS的等待队列。而公平锁要求线程必须先检查等待队列里是否有人在排队有人排队就必须老老实实排到队尾。这里有个常见的误解觉得公平锁肯定更好因为更公平。实际上非公平锁在大多数业务场景下吞吐更高。原因在于刚到达的线程往往还处于活跃状态、CPU缓存还热着让它直接抢锁能减少一次线程切换而公平锁为了保证先来后到被唤醒的线程从WAITING到RUNNING本身就伴随上下文切换开销。我曾在压测里对比过同一套订单编号生成逻辑80并发下非公平锁的TPS大约比公平锁高10%而公平锁的最大等待时间反而更短整体等待队列更稳定。结论默认用非公平锁只有当业务对顺序有硬性要求或者你能接受一定吞吐损失来换取不饿死等待线程时才考虑公平锁。比如某个发放优惠券的任务要求严格按请求到达顺序处理公平锁才是正确选择。3. lock、tryLock、lockInterruptibly三种加锁姿势的选择3.1 lock()最基础的请求必须配finallylock()是最直接的阻塞获取方式。调用后线程会一直等待直到拿到锁。它不响应中断这一点和synchronized一样。标准写法private final ReentrantLock lock new ReentrantLock(); public void deductStock(int count) { lock.lock(); try { stock - count; // 这里放真正的业务逻辑 } finally { lock.unlock(); } }记住一个口诀lock()后面必须紧跟tryfinally里必须unlock。任何把unlock放在try之外、或者放在某个if分支里的写法都在给线上故障埋雷。我给A同学review代码时就看到过一段有人在拿到锁之后调用了另一个加锁方法因为某种异常提前return导致unlock根本没执行锁泄露之后整个服务三分钟内全部线程陷入等待。这类问题测试阶段很难暴露往往要到高并发压测或者线上突发流量时才现形。3.2 tryLock()拿不到锁就优雅降级tryLock()返回布尔值拿不到锁时不会阻塞而是立即返回false。它还有一个带超时参数的版本也是我日常用得最多的if (lock.tryLock(2, TimeUnit.SECONDS)) { try { // 拿到锁执行业务 return doBusiness(); } finally { lock.unlock(); } } else { // 超时未获取锁走降级策略 return degradeResult(); }tryLock(time, unit)相比普通tryLock()多了两个特性一是限时等待二是可响应中断——等待期间收到interrupt信号会抛出InterruptedException。典型用法是兜底保护比如秒杀扣减库存如果两秒内拿不到锁就不再傻等直接返回系统繁忙避免请求线程无限堆积。这里有个细节很多人没注意无参的tryLock()不会抛InterruptedException因为它根本不等待而带时间的tryLock()会。如果你的代码里两种都用了对InterruptedException的处理必须分开考虑不能混为一谈。另外一个高频踩坑点是用tryLock()实现轮流尝试多个锁时有人会在循环里反复调用无参tryLock()并配合Thread.sleep(几十毫秒)形成忙等。正确做法是优先用带超时的tryLock(long, TimeUnit)让底层AQS帮你做高效的定时唤醒而不是自己sleep。3.3 lockInterruptibly()可被叫醒的等待lockInterruptibly()和lock()唯一的区别是等待获取锁的过程中如果线程被interrupt()会抛出InterruptedException并退出等待而lock()即使看到中断标志也会继续等直到获取锁才能感知中断。try { lock.lockInterruptibly(); try { // 关键业务 } finally { lock.unlock(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 恢复中断状态 // 记录日志退出任务 }顺便说一个很多教程没强调的细节捕获InterruptedException后一定要调用Thread.currentThread().interrupt()把中断标志恢复回去。因为异常抛出时中断标志会被清除如果别的框架或上层逻辑依赖这个标志来做协作式取消你直接吞掉异常就等于把有人想取消我这个消息弄丢了。线程池的shutdownNow()、Future.cancel(true)都依赖中断协作这个标志一旦丢失任务可能永远停不下来。三种获取方式的选择其实很简单允许无限期等待就用lock()希望控制最大等待时间、拿不到就降级就用tryLock(time, unit)任务需要被外部随时取消、或在线程池关闭时要快速停摆用lockInterruptibly()。4. Condition条件队列把wait/notify升级成精确信号4.1 为什么需要Conditionsynchronized配套的线程协作只有一套wait/notify/notifyAll而且它们和锁强绑定无法单独指定哪类线程被唤醒。比如一个容量有限的队列缓冲区满时生产者等待缓冲区空时消费者等待。用wait/notifyAll一次唤醒是广播式的生产者被唤醒后还要重新检查条件因为notifyAll可能唤醒的是另一个生产者而消费者也没法只唤醒某一个生产者只能整个队列全部唤醒容易产生惊群效应。ReentrantLock提供的newCondition()可以从同一把锁上创建多个条件队列精确控制这次signal只唤醒等待不满的生产者或者只唤醒等待不空的消费者。语义上相当于把wait/notify拆成了多套互相独立的信令通道。4.2 多条件生产消费者一份可以直接抄的代码下面是我在实际项目里用过很多次的骨架代码。容量为10的任务队列用两个条件变量分别管理生产者与消费者的等待public class TaskQueue { private final ReentrantLock lock new ReentrantLock(); private final Condition notFull lock.newCondition(); private final Condition notEmpty lock.newCondition(); private final QueueString queue new LinkedList(); private final int capacity 10; public void put(String task) throws InterruptedException { lock.lock(); try { while (queue.size() capacity) { notFull.await(); // 队列满生产者等待 } queue.offer(task); notEmpty.signal(); // 唤醒一个消费者 } finally { lock.unlock(); } } public String take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); // 队列空消费者等待 } String task queue.poll(); notFull.signal(); // 唤醒一个生产者 return task; } finally { lock.unlock(); } } }几个关键点第一条件判断必须用while而不是if。防止虚假唤醒spurious wakeup是await自身的语义要求哪怕代码看起来刚被唤醒条件一定满足也必须在循环里重新校验。第二await()在阻塞前会自动释放锁被signal唤醒并重新拿到锁后才返回。因此在await返回之后到下一次lock之间的状态都是线程已经重新持锁、条件可能已经变化的状态必须回到循环开头再判断一次。第三signal()和signalAll()的选择要克制。如果只有一个线程在等这个条件signal足够如果有多个线程在等同一个条件比如多个生产者都在等notFullsignal只唤醒队首一个如果这个被唤醒的线程因为其他原因再次进入等待就可能出现丢失唤醒的隐性风险。拿不准的时候用signalAll()牺牲一点性能换确定性我个人在核心链路上更倾向于signalAll()。第四await有很多带超时的变体awaitNanos(long)、await(long, TimeUnit)、awaitUntil(Date)。awaitNanos返回的是剩余等待纳秒数可以用来判断是超时醒来还是被signal唤醒。在可靠性要求高的链路上我建议至少给一个变体加超时值作为兜底具体做法放在最后的排障案例里讲。5. 选型边界ReentrantLock、synchronized与其他锁的权衡5.1 一张表看清不同锁的差异ReentrantLock不是并发的唯一解甚至不总是最优解。先把几种常见锁的特性摆出来特性synchronizedReentrantLockReentrantReadWriteLockStampedLock可重入是是是否可中断获取否是是是限时获取否是是是公平策略否可配置可配置否多条件队列单套wait/notify支持多个写锁支持多个不支持读写分离无无读写分离读写分离乐观读典型场景简单互斥复杂互斥控制读多写少读极多、写极少StampedLock要单独提醒一点它不可重入如果代码里出现了嵌套获取直接死锁。我在一个数据加载组件里用过StampedLock的乐观读性能确实出色但后来因为在同一个方法里既读又写、加上嵌套调用把自己卡死过一次最后换回了ReentrantReadWriteLock。ReentrantReadWriteLock也有一个隐藏限制读锁ReadLock不支持Condition只有写锁WriteLock能创建Condition。原因是读锁本来就允许多线程同时持有让多个读线程等待一个条件在语义上是矛盾的。想用Condition又用了读写锁记得只能从写锁上newCondition()。5.2 我的选型经验我自己定的选型底线是这样的能用synchronized讲清楚的场景坚决不上ReentrantLock。很多开发者觉得synchronized是老古董实际上从JDK 6开始synchronized就引入了偏向锁、轻量级锁等一系列优化手段在低竞争、短临界区的场景下和ReentrantLock几乎没有差距而且它永远不会因为忘记unlock而出问题。ReentrantLock真正的主场是这几类需要限时或可中断获取锁的需要多个Condition协作的需要公平锁保证顺序的以及希望在调试时调用isHeldByCurrentThread()、getQueueLength()、hasQueuedThreads()等方法实时掌握锁状态的。说句实在话大多数业务系统里用到其中一项就足够了不要为了看起来高级而引入显式锁手动管理的每一把锁都是未来的排查成本。另外再提醒一个常见误区ReentrantLock并不能替代所有原子操作。字段级的简单自增、状态标记位更新用AtomicInteger、AtomicBoolean或者LongAdder更合适锁的开销再小也是锁能无锁处理就别上锁。6. 一次线上假死排查锁等待问题从现象到根因6.1 现象任务堆积、线程全部WAITING去年有段时间某订单系统的MQ消费链路忽然大面积告警消费者线程迟迟不处理消息消息堆积从几百涨到几万。现场同事第一反应是下游数据库慢但数据库监控完全正常。我拿到jstack线程快照后发现十几个消费者线程全部停在同一个位置TaskQueue.take()方法里的notEmpty.await()以及一个生产者线程停在notFull.await()。队列是空的消费者等着有任务队列也是空的生产者却还在等不满。这显然不是正常的忙碌或竞争而是一个典型的状态卡死。6.2 用jstack还原现场jstack里能看到类似这样的关键信息consumer-5 #12 prio5 os_prio0 cpu0.00ms elapsed1832.11s java.lang.Thread.State: WAITING (parking) at jdk.internal.misc.Unsafe.park(java.base17/Native Method) at java.util.concurrent.locks.LockSupport.park(java.base17) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base17) at TaskQueue.take(TaskQueue.java:28)WAITING (parking)对应Condition.await()的阻塞点。线上所有相关线程都停在这里且持续半小时以上说明await之后永远没有收到对应的signal或者signal被丢失了。再结合队列为空这个状态反推至少有一个消费者成功take过任务却没有成功发出notFull.signal()导致唯一在等不满的生产者永久挂起。排查这种问题一定先拿到线程快照再动手不要急着重启。重启只能临时恢复根因不挖出来下次流量一上来还会复现。而且多个线程快照之间要留一点时间间隔方便观察线程状态是持续固定还是来回切换。6.3 根因与修复一个漏掉的finally查代码后发现消费任务的方法里有一段数据校验逻辑遇到格式异常的任务会直接return而notFull.signal()恰好写在return之后、又没有用finally保护。于是故障链路是这样的生产者发现队列满进入notFull.await()消费者成功take一个任务后解析异常提前returnsignal没执行队列明明多了一个空位生产者却永远不知道队列持续为空后消费者也开始在notEmpty.await()上等待。整个系统变成一个看似无事发生、实际上彻底停摆的状态。经典的丢失唤醒问题。修复方案分两层。第一层是把signal放进finally保证无论业务成功还是抛异常都会唤醒等待的生产者public String take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } String task queue.poll(); return task; } finally { notFull.signal(); // 无论什么路径退出都尝试唤醒生产者 lock.unlock(); } }第二层是防御性兜底给await加上超时时间while (queue.size() capacity) { if (!notFull.await(5, TimeUnit.SECONDS)) { // 超时仍未等到空位记录告警继续循环判断 } }用await超时替代无期限await即使极端情况下再次出现signal丢失也不会无限挂死只会产生告警并继续循环。这里注意await返回false只代表等待超时不代表条件不成立所以要在循环里重新判断条件不能直接break。那次事故之后我们把所有Condition.await()的调用点都做了一遍审计凡是等待条件可能受外部业务异常路径影响的一律加上超时和告警。这个习惯后来也用在了所有ReentrantLock相关的代码上——显式锁的可靠性本质上是靠调用方的纪律兜底的。最后补一句实在话排查锁问题最有效的工具永远是jstack和及时的线程快照遇到线程全部挂起的现象别慌先留一份现场再操作同时在设计阶段就养成lock紧贴try、signal进finally、await必带超时这三个习惯大部分让人头疼的锁故障其实都不会发生。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑