上周做代码评审,碰到一个挺典型的写法:方法上加了 synchronized 关键字,方法内部又用 ReentrantLock 加了一把锁,两层锁嵌套着用,问这位同学为什么这么写,对方回答得也很直接:“网上说 synchronized 性能差,ReentrantLock 功能强,两个都加肯定没错。” 这个心态其实很有代表性。很多人对这两把锁的认知停留在“一个老一个新、一个新功能多”的层面,但真要落到实际业务里,到底用哪个、为什么用、用的时候有哪些坑,反而说不清楚。
今天就把 synchronized 和 ReentrantLock 从底层设计到应用场景完整拆一遍。这篇文章适合两类读者:一是已经写过一段时间 Java、开始接手并发代码的同学,能把锁的选型思路理清楚;二是准备面试、想深入理解两者差异的人,看完整篇去聊实现原理和场景判断,基本不会被问倒。
1. 为什么需要两把锁:核心设计思路拆解
1.1 synchronized 的底层进化:从重量级 monitor 到三级优化
synchronized 是 JVM 原生支持的同步关键字,它最底层的实现是 monitor(监视器锁)。在 JDK 6 之前,synchronized 确实是一个比较“重量级”的操作,线程竞争锁的时候会直接进入操作系统内核态的阻塞和唤醒,上下文切换成本很高,所以那时候大家普遍认为它性能不行,并发场景下更愿意用 ReentrantLock。
但是 JDK 6 之后, HotSpot 虚拟机对 synchronized 做了一轮非常关键的优化,引入了偏向锁、轻量级锁和重量级锁的三级升级机制。锁对象在对象头的 Mark Word 里记录状态,在没有竞争的时候,偏向锁让同一个线程反复进入同步块几乎零开销;一旦出现另一个线程竞争,偏向锁会撤销,升级为轻量级锁,通过 CAS(Compare And Swap)自旋来尝试获取锁;自旋超过一定次数仍然失败,才会升级为重量级锁,走内核态阻塞。所以现在再拿“性能差”来说事,已经不准确了,大部分高并发但临界区短的场景里,synchronized 的表现并不差。
还有一个容易被忽略的点:synchronized 的锁信息是存放在对象头里的,也就是说它可以锁任意对象。锁普通方法锁的是当前实例,锁静态方法锁的是 Class 对象,锁代码块需要显式指定一个对象。这种设计决定了它使用起来非常灵活,不需要额外创建任何锁对象。
1.2 ReentrantLock 的设计理念:当锁变成一个对象
ReentrantLock 是 java.util.concurrent 包下的显式锁,和 synchronized 最大的区别在于它把“锁”这个概念从 JVM 层面抽离成了 Java 对象。你可以像操作普通对象一样去创建它、获取它、释放它。
底层原理的核心是 AQS(AbstractQueuedSynchronizer)。AQS 内部维护了一个 volatile int state 变量和一个 FIFO 等待队列。加锁的本质就是通过 CAS 把 state 从 0 改成 1,拿到锁的线程将 state 继续累加;拿不到锁的线程会被封装成节点放进等待队列,通过 LockSupport.park/unpark 来阻塞和唤醒。
这个设计带来的直接好处是,锁的行为可以被精细控制。比如 tryLock() 方法可以立即返回获取结果,tryLock(long timeout, TimeUnit unit) 可以等待一段时间,lockInterruptibly() 可以响应中断,这些能力在 synchronized 上都是没有的。另外,ReentrantLock 支持在构造时传 true 开启公平锁,让线程按照先来后到的顺序获取锁,而 synchronized 只能是非公平的。
1.3 两把锁的设计哲学差异
抛开具体 API,两把锁最本质的差异是“要不要给调用方选择权”。
synchronized 是 JVM 层面的关键字,语法上强制你在一个同步块内完成加锁和解锁,获取锁之后要么正常走完,要么抛异常由 JVM 自动释放,调用方不需要也不能干预锁行为。这种设计的好处是简单、安全,坏处是没有弹性可言。
ReentrantLock 则把选择权完全交给了开发者。你可以决定等待多久、能不能被中断、释放前做什么额外操作、甚至同一把锁下建立多少个等待条件队列。这种灵活性在特定场景下是救命的,但也意味着你需要自己保证在 finally 中释放锁,否则就会埋下死锁隐患。我见过不止一次线上故障,都是因为某个分支提前 return 而漏掉了 unlock(),从此我对“手动锁”有了很深的敬畏心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心差异逐项拆解:别再用性能说事了
2.1 使用方式:自动释放与手动释放
synchronized 的写法无非就三种:同步方法、同步代码块、静态同步方法。关键点在于它不需要手动释放锁,方法执行完或者抛出异常,JVM 会自动帮你在 monitor exit 处释放。这一点是 ReentrantLock 永远不具备的“保姆级体验”。
而 ReentrantLock 的标准用法必须长这样:
java复制ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock();
}
很多第一次用的人会忘记在 finally 中释放,或者只在 try 块第一行释放,一旦中间抛异常,锁就永远挂在当前线程身上,其他线程全部阻塞。所以我在团队里定了一条规矩:lock() 必须紧跟着进 try,unlock() 必须放 finally,中间不允许有任何业务逻辑。这条规矩看起来很死板,但能挡住大部分低级事故。
2.2 中断与超时:处理“抢不到锁”的能力
这是两把锁最显著的功能分水岭。
如果线程用 synchronized 去抢锁,抢不到就无限期阻塞在锁上,期间任何中断信号都无效。也就是说,你在一个 synchronized 同步块里被堵住了,外部线程打断你,你的线程仍然会继续等待锁,直到拿到锁执行完才能响应中断。这在某些场景下非常危险,比如线程池里一个任务卡死在锁上,你想通过 shutdownNow() 中断这些线程,结果压根停不下来。
ReentrantLock 解决的就是这个问题。使用 lockInterruptibly() 获取锁时,线程在等待过程中能响应中断;使用 tryLock(timeout, TimeUnit) 时,等待超过指定时间会自动放弃,不再无限期耗下去。下面这段代码是我们在支付服务里比较常用的超时控制模式:
java复制public boolean tryTransfer(String orderId, long timeout, TimeUnit unit) {
if (!lock.tryLock(timeout, unit)) {
log.warn("获取转账锁超时, orderId={}", orderId);
return false;
}
try {
// 校验订单状态、执行资金划转
return doTransfer(orderId);
} finally {
lock.unlock();
}
}
这个能力带来的价值很大。真实线上环境中,“抢不到锁”本身就是一种需要被处理的异常情况,你可能要告诉用户稍后再试,也可能要切换其他资源节点。synchronized 给不了这种弹性,你只能硬等。
2.3 公平性:排队有先来后到吗
先看结论:synchronized 一定是非公平锁,ReentrantLock 默认非公平,但可以通过构造参数指定为公平锁。
非公平锁的含义是线程获取锁的顺序和请求顺序无关,可能后到的线程直接“插队”抢到锁,而先到的线程还在排队。这样做的好处是减少线程上下文切换,吞吐量更高,因为新来的线程如果刚好能用 CAS 抢到锁,就省掉了挂起和唤醒的开销。坏处是极端情况下先发请求的线程可能被一直饿死,虽然实际概率很低。
ReentrantLock 在构造时传入 true,就切换成公平锁,AQS 会严格按照先来后到的顺序从等待队列头部逐个唤醒线程。公平性解决的是“饥饿”问题,适用在对请求顺序有要求的业务里,比如某些单据处理任务,先提交的任务应当先获得执行机会。
不过公平锁不是免费的午餐,它的代价就是频繁的线程唤醒和上下文切换,吞吐量会肉眼可见地下降。我的建议是:默认用非公平锁,除非你真的有顺序要求,否则不要为“看起来合理”的公平性付出性能代价。
2.4 条件变量:从单队列到多队列
synchronized 配合 wait() 和 notify() 只能有一个等待队列,这个队列里混着各种各样的等待原因。比如一个生产者-消费者模型里,有的线程在等“队列满了放不下”,有的线程在等“队列空了没东西可取”,它们被唤醒时无法区分原因,只能被 notifyAll() 全部唤醒再自己判断,判断条件不满足就继续等,效率很低。
ReentrantLock 的 Condition 解决的就是这个问题。一把 ReentrantLock 上可以创建多个 Condition,每个条件对应一个独立的等待队列。生产者等 notFull,消费者等 notEmpty,唤醒时也能精准对位。下面这段有界缓冲区的代码是很有代表性的用法:
java复制public class BoundedBuffer<T> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final Object[] items;
private int putIndex, takeIndex, count;
public BoundedBuffer(int capacity) {
items = new Object[capacity];
}
public void put(T t) throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == items.length) {
notFull.await(); // 队列满则等待
}
items[putIndex] = t;
if (++putIndex == items.length) {
putIndex = 0;
}
count++;
notEmpty.signal();
} finally {
lock.unlock();
}
}
@SuppressWarnings("unchecked")
public T take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == 0) {
notEmpty.await();
}
T t = (T) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length) {
takeIndex = 0;
}
count--;
notFull.signal();
return t;
} finally {
lock.unlock();
}
}
}
这里有个很容易踩的坑:await() 必须在 while 循环里判断条件,不能用 if。因为线程可能被虚假唤醒(spurious wakeup),也可能被其他线程错误地 signal,不重新检查条件就直接消费数据,会造成索引越界或者数据错乱。这是《Java并发编程实战》里反复强调过的经典教训。
2.5 可重入性:两把锁的共同基础
说了这么多差异,有一点两者是一致的:都可重入。同一个线程对同一把锁可以重复获取多次,内部记录一个持有次数。
synchronized 的重入由 JVM 自动处理,同一线程进入一个已经持有的同步块不会阻塞。ReentrantLock 重入时 state 会累加,每次 unlock() 减一,直到减到 0 锁才会真正释放。这个特性在递归调用、嵌套同步时非常重要,如果设计成不可重入,稍微复杂的调用关系就会把自己锁死。
3. 典型应用场景与代码实现
3.1 场景一:并发计数与简单互斥,优先用 synchronized
如果你的需求就是“保证这段逻辑同一时刻只有一个线程在执行”,而且不需要超时、中断、公平性这些高级特性,那么 synchronized 永远是最省心的选择。它代码量最少、出错概率最低、JVM 会自动释放锁,而且现在经过偏向锁和轻量级锁优化后,低竞争场景下性能与 ReentrantLock 几乎无差别。
比如一个简单的计数器:
java复制public class Counter {
private int count;
public synchronized void increment() {
count++;
}
public synchronized int get() {
return count;
}
}
这里如果换成 ReentrantLock,除了多写几行 lock/unlock 之外没有带来任何额外价值。类似的还有单例模式的双重检查锁(DCL),把 synchronized 加在需要保证单例初始化的那一小段代码上,完全够用。
3.2 场景二:超时控制与可中断等待,用 ReentrantLock
再来看真正需要 ReentrantLock 的场景。典型特征是业务里存在“我最多等你多久,等不到你就别等了”这样的需求。
举个例子,一个商户端转账接口,同一个订单账务上不允许并发处理,你给每个订单分配一把分布式锁(或者本地锁)。但网络抖动、上游服务卡顿可能导致线程长时间占用订单号锁,后面的请求如果无脑等下去,线程池会被迅速打满,整个服务雪崩。这种时候用 tryLock 加上超时时间,让抢不到锁的请求快速返回失败,让用户重试,是更稳健的设计。
再比如线程池关闭的场景,你要优雅停止正在执行的任务。如果任务里用 synchronized 等锁,shutdownNow() 发出中断信号也无法让线程从锁等待中返回;改用 lockInterruptibly(),线程就能在 InterruptedException 中及时响应,配合 Thread.currentThread().interrupt() 重新设置中断标记退场。这类“可控性”需求是 ReentrantLock 不可替代的核心价值。
3.3 场景三:生产者消费者与多个等待队列,用 Condition
有界缓冲区的例子在上面已经展示过。如果在生产环境里要自己实现一个连接池、任务队列或限流器,Condition 的多队列设计会极大提升代码可读性和运行效率。
比如实现一个简单的连接池,连接耗尽时获取线程进入 noConnection 条件等待,而归还线程在 release 中通过 noConnection.signal() 唤醒恰好一个等待者,避免 synchronized 时代 notifyAll 造成的惊群效应。
唯一需要注意的仍然是 await 的 while 循环写法。建议团队里做代码规范时明确:Condition.await() 出现的地方,条件判断一律写成 while,禁止 if。
3.4 场景四:读多写少的场景,考虑更上层的工具
聊到应用场景,有个延伸建议:不要把思路锁死在“非 synchronized 即 ReentrantLock”。很多业务场景其实不需要互斥锁,用读写锁 ReentrantReadWriteLock 或 StampedLock 更合适。
比如一个商品缓存,读请求远多于写请求。如果全部用 synchronized 或 ReentrantLock 互斥,读读之间也会互相阻塞,白白浪费并发能力。ReentrantReadWriteLock 允许多个读者共享进入临界区,只有写者才需要独占。这种场景下,先考虑读写锁,再考虑要不要降级到互斥锁。
我给团队定过一个简单的选型决策思路,按照这个顺序基本不会错:
- 没有额外需求,只是需要互斥:用
synchronized - 需要超时获取锁、可中断获取锁、公平策略、多个等待条件:用
ReentrantLock - 读多写少,读操作远多于写操作:优先考虑
ReentrantReadWriteLock或StampedLock - 需要跨 JVM 分布式互斥:本地锁都不行,直接上分布式锁组件
4. 常见问题与排查技巧实录
4.1 常见问题速查表
这里整理一份平时问答中高频出现的锁问题对照表,适合直接收藏备查:
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 线程全部 BLOCKED,服务无响应 | 死锁或长时间持锁 | jstack 抓线程栈,找 Blocked 线程持有锁的堆栈 |
| 某线程永远不释放锁 | 漏了 unlock() |
检查是否有提前 return / 抛异常分支,确认 finally 释放 |
| 线程中断无效 | synchronized 等待锁时不可中断 |
换个思路,用 lockInterruptibly 替代 |
| 公平锁后吞吐量明显下降 | 公平唤醒增加了上下文切换 | 确认是否真的需要公平性,默认用非公平 |
| 生产者消费者偶发死循环 | await 用了 if 而非 while |
改为 while 轮询条件,防止虚假唤醒 |
| 高并发下性能不升反降 | 锁粒度过大或锁竞争过于激烈 | 缩小临界区、减少持锁时间,必要时分段锁 |
4.2 案例复盘:一次锁竞争导致的性能劣化
之前维护过一个库存扣减服务,压测时 TPS 始终上不去,CPU 使用率却很高。用 jstack 抓线程栈,发现大量业务线程阻塞在同一个 synchronized 方法上。看代码才发现,为了扣减一个 SKU 的库存,竟然把整个产品的库存都锁住了。一个产品下几百个 SKU,任何两个不同 SKU 的扣减请求都会互相等待,临界区还包含了库存校验和一系列远程调用,持锁时间特别长,线程自然越积越多。
排查之后做了三件事。第一步,把全局锁改成 ConcurrentHashMap 的分段锁,每个 SKU 一把锁,避免不同 SKU 互相干扰;第二步,把远程调用和耗时的校验逻辑挪到锁外面,锁内只保留纯粹的库存扣减和内存操作;第三步,把 synchronized 换成 ReentrantLock.tryLock,抢不到锁的请求快速返回提示“当前操作人数过多”,而不是无限排队等下去。改造之后 TPS 提升了接近一个量级,线程池也不再堆积了。
这个案例想说明一个点:很多时候不是锁的实现不好,而是临界区设计不合理。锁粒度越大,并发能力越差;锁内做耗时操作,等于把并发退化成串行。这两个问题不解决,换任何锁都救不了。
4.3 关于锁选型和使用的几点心得
最后分享一些实操层面我自己总结的经验。
synchronized 的自动释放能力是最有价值的。它省心,不容易出错,而且在现代 JVM 里性能并不吃亏。绝大多数业务系统中,90% 的加锁场景用 synchronized 就够了。不要为了“炫技”去换成 ReentrantLock,让代码复杂化。
当确定要用 ReentrantLock,优先考虑组合能力。比如 tryLock 配合超时时间,再配合中断处理,可以让系统在异常情况下有更好的自我恢复能力。多条件等待时优先用 Condition,不要退回去用 wait/notifyAll。
再有就是排查问题的基本套路要熟。遇到锁相关问题,第一反应就是用 jstack 抓线程栈。看 BLOCKED 状态的线程,定位它卡在哪个锁上面,再看谁持有这把锁、持有多久。很多并发问题都能在几秒钟内定位到根因。频繁抓几次之后,你会慢慢建立起对锁行为的直觉。
另外,团队协作里建议做代码规范约定。比如 ReentrantLock 的 lock() 必须紧跟 try,unlock() 必须放在 finally;Condition 的 await() 条件判断必须用 while;加锁范围必须显式注释说明。这些规矩看起来死板,但能在团队层面兜住最常见的坑。
