先说一个我前两天在线上排查的问题背景:一个订单服务在高峰期出现大量请求超时,CPU 不高、内存也不紧张,但接口 RT 掉到了 3 秒以上。查到最后,问题出在一把锁上——某位同学用 synchronized 锁了一个方法,方法内部还调了远程服务。这个例子很典型:synchronized 和 ReentrantLock 用好了是并发利器,用错了就是事故策源地。
这篇文章我不打算把 API 文档念一遍,而是从实际开发的角度,把这两把锁的核心机制、底层原理、应用场景和常见坑讲透。如果你正准备用 Java 写并发代码,或者已经在项目里被锁的问题折磨过,这篇文章应该能帮你少走不少弯路。
1. 为什么并发编程绕不开这两把锁
1.1 一个抢票场景引发的思考
先看一段很常见的代码:
java复制public class TicketCounter {
private int tickets = 100;
public void sale() {
if (tickets > 0) {
tickets--;
}
}
}
这段代码在单线程下没有任何问题,但放到多个线程里同时跑,tickets-- 就不是安全的了。原因很简单:tickets-- 看起来是一行代码,但它在 JVM 里至少会被拆成三步——读取当前值、计算减一、写回内存。两个线程如果同时读到 tickets = 10,各自算出 9 再写回去,那最终结果就是 9,而不是 8。
这种问题就是典型的“临界区竞争”。解决思路也很直接:保证同一时刻只有一个线程能进入这段代码。Java 里最常用的两个手段就是 synchronized 和 ReentrantLock。
1.2 锁的核心价值:保证临界区互斥
锁的本质是让多个线程在访问共享资源时串行化。你可以把它理解成厕所门口的那把钥匙:谁拿到钥匙谁进,其他人只能在门口等着,用完把钥匙还回去,下一个人才能进。
但这背后不只是“互斥”这么简单,还牵扯到内存可见性。synchronized 和 ReentrantLock 在进入临界区时会从主内存重新读取共享变量,退出临界区时会把修改刷新回主内存。也就是说,用锁保护共享变量后,线程之间看到的变量状态是一致的,不会出现“改了但别人看不到”的情况。
现在很多年轻人喜欢用 AtomicInteger 或者 LongAdder 来替代锁,这在单纯计数场景下确实没问题。但在要做“先检查后执行”“多步复合操作”这类场景里,CAS 解决不了的问题,还是得回到锁上来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. synchronized:JVM 亲儿子,简单可靠的默认选择
2.1 synchronized 的三种用法和锁对象绑定关系
synchronized 是 JVM 内置的关键字,用法虽然简单,但很多人其实搞不清楚它到底锁的是谁。这里直接说结论:
| 用法 | 写法 | 锁的对象 |
|---|---|---|
| 同步实例方法 | public synchronized void method() |
当前实例对象 this |
| 同步静态方法 | public static synchronized void method() |
当前类的 Class 对象 |
| 同步代码块 | synchronized (obj) { ... } |
指定的对象 obj |
这三个用法对应三种不同粒度,最容易踩坑的就是前两个。假如你有一个实例方法加了 synchronized,只有当两个线程访问的是同一个实例时才会互斥。如果它们操作的是不同实例,锁完全不起作用。这个坑在 Spring 默认单例模式下不明显,但如果你自己 new 对象,或者在原型模式下使用 bean,就很容易中招。
同步代码块的好处是粒度更灵活,你可以在方法内部只锁真正需要保护的那几行代码,而不是把整个方法都锁住。下面这段代码就比直接锁方法体优雅得多:
java复制public void updatePrice(String skuId, BigDecimal price) {
// 这里可以做耗时操作,比如远程调用、复杂计算
String cacheKey = "price:" + skuId;
synchronized (cacheKey.intern()) {
// 只对短小的写缓存操作加锁
cacheMap.put(cacheKey, price);
}
}
但注意,这段代码我用了 cacheKey.intern(),这里也有坑:intern() 拿到的字符串对象可能来自常量池,如果并发量特别大,可能导致常量池膨胀。实际项目里我更喜欢用一个固定的锁数组去做分段锁,这个后面在场景部分再展开。
2.2 偏向锁、轻量级锁、重量级锁,一次锁升级全讲明白
提到 synchronized,就绕不开锁升级。很多老文章说 synchronized 性能差,那是因为说的是 JDK 1.6 以前的老黄历。从 JDK 1.6 开始,HotSpot 对 synchronized 做了一整套优化,引入了偏向锁、轻量级锁、重量级锁的升级机制。
简单理解:
- 偏向锁:同一个线程反复进入同步块时,不需要每次都做 CAS,直接在对象头里记录线程 ID,性能几乎是无锁状态。
- 轻量级锁:一旦出现两个线程竞争,偏向锁升级为轻量级锁,通过 CAS 抢锁,抢不到的线程自旋等待,避免立刻切换到内核态。
- 重量级锁:如果自旋也解决不了,锁就升级为重量级锁,也就是依赖操作系统的互斥量,未获取锁的线程会进入阻塞状态。
这个升级过程是单向的,锁只能升级不能降级。理想情况下,大部分同步代码在低竞争环境下都停留在偏向锁或轻量级锁阶段,性能并不比 ReentrantLock 差。
不过我在实际项目中注意到,从 JDK 15 开始偏向锁已经被标记为废弃,JDK 20 默认禁用了偏向锁。原因是偏向锁的撤销逻辑本身有额外开销,在高竞争场景反而拖后腿。所以新版 JDK 里,synchronized 的默认路径会更偏向轻量级锁。这个变化对大多数业务代码影响不大,但如果你对极致的低延迟有要求,还是需要关注一下 JVM 版本和参数。
2.3 synchronized 使用的心得与限制
用过一段时间后,我对 synchronized 的整体评价是:简单、可靠,但不够灵活。它最大的优点就是不用手动释放锁,JVM 会在代码块执行完成后自动释放,哪怕中间抛了异常也不会死锁。这一点对新手非常友好,因为手动 lock() 之后忘记 unlock() 的坑,在 ReentrantLock 里太常见了。
但它的限制也很明显:
- 不支持中断,线程抢不到锁就只能一直等。
- 不支持超时机制,想“最多等 500 毫秒”是不可能的。
- 只有单一条件队列,线程被唤醒后要自己再循环检查条件。
- 无法实现公平锁,线程唤醒顺序和抢占行为有关。
如果你只是要保证互斥,synchronized 永远是第一选择。但如果有上面这些特殊需求,就得考虑 ReentrantLock 了。
3. ReentrantLock:灵活可控的显式锁
3.1 基础用法和 API 速览
ReentrantLock 位于 java.util.concurrent.locks 包下,基于 AQS 实现,功能比 synchronized 丰富得多。基础用法如下:
java复制private final ReentrantLock lock = new ReentrantLock();
public void doSomething() {
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock();
}
}
注意看,我把 unlock() 放在 finally 里,这一步是必须的。因为如果临界区代码抛了异常,锁不会被自动释放,其他线程就会全部卡死。这个是我见过次数最多的低级错误。
ReentrantLock 的可重入性也能验证一下:
java复制lock.lock();
try {
lock.lock();
try {
// 同一线程可以多次加锁
} finally {
lock.unlock();
}
} finally {
lock.unlock();
}
可重入的意思是同一个线程可以重复获取同一把锁,lock() 一次就对应 unlock() 一次,必须成对出现。synchronized 也有这个能力,所以可重入并不是 ReentrantLock 特有的,只是它在 API 上体现得更明显。
3.2 可中断、公平锁、超时获取这些特性到底有什么用
ReentrantLock 最值钱的三个能力是:可中断、可超时、可公平。
可中断对应的是 lockInterruptibly() 方法。如果一个线程正在等待锁,其他线程可以调用它的 interrupt() 方法打断这个等待。这在写复杂任务调度时很有用,比如某线程正在等锁,外界想取消它的任务,直接中断就能让它从锁等待中醒过来。
超时获取对应 tryLock(timeout, unit) 方法。举个例子,两个服务都需要抢占同一个资源,如果抢不到就一直等,就可能导致整个线程池被占满。用 tryLock 之后,最多等 500 毫秒,拿不到就直接走失败分支,或者做降级处理。
公平锁对应构造参数:new ReentrantLock(true)。公平模式下,锁会按照线程请求的顺序分配,避免“饿死”现象。但公平锁会牺牲一定吞吐量,因为需要维护一个有序队列,而在大多数场景下,非公平锁的性能更好。我一般情况下默认用非公平锁,只有遇到强顺序要求的场景才开公平。
3.3 Condition:精细线程协作的正确姿势
Condition 是 ReentrantLock 的杀手锏,它可以让你在一个锁上创建多个等待队列。这正好补齐了 synchronized 只有单一 wait/notify 队列的短板。
看一个最经典的生产者消费者例子:
java复制class MessageQueue {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final Queue<String> queue = new LinkedList<>();
private final int capacity = 10;
public void put(String message) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) {
notFull.await();
}
queue.offer(message);
notEmpty.signal();
} finally {
lock.unlock();
}
}
public String take() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
String message = queue.poll();
notFull.signal();
return message;
} finally {
lock.unlock();
}
}
}
这里有两个条件:notFull 表示“队列没满”,notEmpty 表示“队列没空”。生产者只需要等 notFull,消费者只需要等 notEmpty。如果用 synchronized 配合 wait/notify,一旦 notifyAll 把所有线程都叫醒,大量线程又要空转检查条件,性能和正确性都不好控制。
所以,在需要多个等待条件、精细控制线程协作时,ReentrantLock + Condition 基本是标配。
4. synchronized 与 ReentrantLock 全方位对比
4.1 特性对比表
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 获取释放方式 | 关键字,自动释放 | 显式 lock/unlock,必须 finally 释放 |
| 可重入性 | 支持 | 支持 |
| 公平性 | 不支持 | 支持,构造时指定 |
| 可中断等待 | 不支持 | 支持 lockInterruptibly |
| 超时等待 | 不支持 | 支持 tryLock(timeout, unit) |
| 多条件队列 | 只支持 wait/notify 单一队列 | 支持多个 Condition |
| 底层原理 | Monitor / 锁升级 | AQS + CLH 队列 |
| 使用复杂度 | 低 | 中高 |
| 排错友好度 | 自动释放,不容易死锁 | 忘解锁容易死锁 |
4.2 性能对比与选择建议
在 JDK 1.6 以后,synchronized 经过锁升级优化,和 ReentrantLock 在大多数低竞争场景下性能已经非常接近了。高竞争场景下,两种锁都会面临线程阻塞和唤醒的开销,真正的性能差距并不大。所以做技术选型时,我建议先看功能需求,不要上来就看性能。
我的选择逻辑通常是这样:
- 只需要保证互斥,没有超时和中断需求,首选
synchronized。代码简短,不容易出错。 - 需要超时抢锁、可中断等待、公平锁、多个 Condition 时,用
ReentrantLock。 - 两者都能满足的情况下,优先维护成本低的
synchronized。 - 如果读多写少,那
synchronized和ReentrantLock都不是最优解,应该考虑ReentrantReadWriteLock或StampedLock。
5. 实战:如何根据业务场景选锁
5.1 普通接口计数、缓存更新:用 synchronized 就够了
很多场景其实根本轮不到 ReentrantLock 上场。比如一个本地缓存更新:
java复制private final Map<String, LocalCacheEntry> cache = new HashMap<>();
public Object get(String key) {
Object value = cache.get(key);
if (value == null) {
synchronized (key.intern()) {
value = cache.get(key);
if (value == null) {
value = loadFromDB(key);
cache.put(key, value);
}
}
}
return value;
}
这种双检锁写法在本地缓存的场景下很常用。竞争窗口极小,用 synchronized 足够。如果你硬要用 ReentrantLock,代码会变成:
java复制lock.lock();
try {
...
} finally {
lock.unlock();
}
功能上是等价的,但直观感受就是比 synchronized 啰嗦不少,而且还要小心 unlock 忘写。杀鸡用牛刀不一定是坏事,但容易让后来维护的人误以为这里锁竞争很大。
5.2 复杂交易并发控制:用 ReentrantLock 更稳
如果是高价值、强一致的交易场景,比如优惠券发放、库存预占,这时候往往需要超时机制。原因很简单:如果两个线程在激烈竞争一把锁,后面排队的线程可能因为锁等待时间过长拖垮线程池。用 tryLock 可以快速失败,把压力转移到友好的降级逻辑上。
java复制public boolean tryDeductStock(String skuId, int count) {
ReentrantLock lock = stockLockManager.getLock(skuId);
boolean acquired = false;
try {
acquired = lock.tryLock(200, TimeUnit.MILLISECONDS);
if (!acquired) {
return Result.error(ErrorCode.BUSY);
}
return doDeductStock(skuId, count);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Result.error(ErrorCode.INTERRUPTED);
} finally {
if (acquired) {
lock.unlock();
}
}
}
这里还要注意一点:如果给每个 skuId 都创建一个 ReentrantLock,内存和锁管理成本会很高。我会用一个固定大小的锁数组,按 skuId.hashCode() 做分段,这样可以控制锁数量,也能在一定范围内减少竞争。这种思路其实就是锁分段,跟 ConcurrentHashMap 的早期实现思路一致。
5.3 生产者消费者、多队列等待:ReentrantLock + Condition 是答案
业务里除了互斥,还经常需要“等待某个条件满足再继续”。比如一个批处理任务,生产者不断把任务塞进队列,消费者在队列空的时候要等,队列满的时候生产者要等。
这种场景如果用 synchronized,你只有 wait() 和 notifyAll()。问题是 notifyAll() 会把所有等待线程都唤醒,它们醒来后发现条件不满足,又继续睡,白白增加竞争。用两个 Condition 分别管理“不为空”和“不为满”,生产者只唤醒生产者,消费者只唤醒消费者,通信效率会高很多。
5.4 读多写少场景:为什么不建议直接用这两把锁
如果你发现一个共享数据是“读操作占 90%,写操作只占 10%”,这时候不管用 synchronized 还是 ReentrantLock,所有读线程都会被锁挡住。读操作本来可以并发,这是极大的浪费。
这种情况下应该考虑 ReentrantReadWriteLock:多线程可以同时持有读锁,但写锁是独占的。如果读多写少真的非常极端,还可以用 StampedLock 的乐观读,读操作无锁,只在写发生时才重新校验。这里也只是顺便提一句,因为它本质上是 ReentrantLock 的思想在读写场景下的扩展。
6. 常见问题与排查技巧实录
6.1 忘掉 unlock,线程池直接瘫痪
我接手过一个线上问题:某个定时任务突然不跑了,jstack 一看,大量线程卡在 LockSupport.park 上。原因就是某次异常路径跳过了 unlock(),导致锁永远被占用。排查过程很痛苦,因为代码里 lock() 和 unlock() 离得太远,中间隔了几十行。
解决办法只有一个纪律性要求:凡是 ReentrantLock,必须把 unlock() 写在 finally 块里,而且要判断是否真正获取到了锁。必要时用带 acquired 标记的方式,像上面库存扣减的例子一样,避免 tryLock 失败后还去释放锁,抛出 IllegalMonitorStateException。
6.2 lock 和 lockInterruptibly 的差别
很多新人分不清 lock() 和 lockInterruptibly()。lock() 优先考虑获取锁,不响应线程中断;lockInterruptibly() 则优先响应中断,也就是说线程在等待锁的过程中,如果被 interrupt(),会立刻抛出 InterruptedException 并退出等待。
举个例子:线程池关闭时,一般会调用 shutdownNow() 来中断工作线程。如果你的业务代码用的是 lock(),那即使线程被标记为中断,它仍然会傻傻地等在锁上,导致关闭流程无法推进。改成 lockInterruptibly() 后,线程可以感知到中断并尽快退出,这在分布式任务调度、消息消费场景非常重要。
6.3 多把锁的获取顺序不一致,死锁就来了
两个线程各自持有一把锁,然后都想获取对方的锁,就会死锁。比如线程 A 持有 lock1 等 lock2,线程 B 持有 lock2 等 lock1。
解决死锁最直接的方法是让所有线程按照同一顺序获取锁。比如一个账户转账场景,需要同时操作两个账户。如果两个线程同时对不同账户转账,就可能互相等待对方账户的锁。我的习惯是先把两个账户的 ID 排序,永远先锁 ID 小的账户,再锁 ID 大的账户,这样就不会形成循环等待。
另外,tryLock 也是破解死锁的利器。获取第一把锁后,如果无法在限定时间内获得第二把锁,就把第一把锁释放掉,并回退重试。这比无脑等锁安全得多。
6.4 可重入性误判:以为自己死锁了,其实没有
有些同事在 synchronized 方法里调用同一个类的另一个 synchronized 方法,看到锁又加锁,担心死锁。其实不会,因为锁是可重入的,同一个线程已经拥有锁时,再次进入同一把锁是允许的。
ReentrantLock 也一样。但要注意,它是有持有计数器的。同一线程每调用一次 lock(),计数器就加一;每调用一次 unlock(),计数器就减一。只有当计数器减到零,锁才算真正释放。所以多次嵌套 lock() 时,必须保证 unlock() 次数对等,否则锁还是不会真正释放。
6.5 如何快速定位锁竞争热点
线上排查锁问题,最常用的工具就是 jstack。遇到线程卡住,先 jstack 抓线程栈,找 java.lang.Thread.State: BLOCKED 或 WAITING 的线程,看它们卡在哪个 lock 上。如果是 synchronized,栈里能看到被锁对象的描述;如果是 ReentrantLock,能看到 park 和对应的 AQS 信息。
还有一种情况是锁竞争太激烈导致线程频繁阻塞,表现是接口 RT 高但 CPU 不高。这时可以用 jps 拿到进程号,再用 jstack 采样多次,统计哪一把锁被阻塞的次数最多,然后考虑减小锁粒度或者用无锁方案替代。
最后再说一个判断技巧:如果锁里面出现了耗时的 IO、远程调用、数据库操作,那不管用什么锁,都会把并发放大成串行。真正常见的性能问题不是锁实现不够快,而是锁临界区太大。先检查临界区里的代码到底做了什么,往往比纠结 synchronized 还是 ReentrantLock 更有意义。
我个人的体会是,锁这个东西,选型很重要,但更重要的是一旦选中了,就把它保护好:缩小临界区、保证释放路径、避免嵌套等待。这两把锁没有绝对的高下,只有适不适合你的业务场景。希望这篇文章能帮你在写下一段并发代码的时候,多一分从容,少一分事故。
