Java并发编程里,加锁这件事看着简单,不就是 synchronized 包一层、Lock 加一行吗?但真正跑到线上你就会发现,锁这个东西翻车的方式千奇百怪:有的锁加了个寂寞,两个线程各玩各的;有的锁加得太猛,把整个服务堵到超时;还有的死锁死得悄无声息,CPU 和内存看着都正常,就是接口全部卡住。这篇文章是这个常见错误系列的第二篇,我把这些年见过的、自己踩过的锁相关事故整理成10个具体案例,按“加锁失效—等待陷阱—高级API误用—问题排查”这条线来拆,每个案例都给出错误写法、问题分析和可落地的修正方案。不管你是刚学 synchronized 的新人,还是已经用了一年多 ReentrantLock 的中级开发,这10个场景都值得对号入座看一眼。
1. 项目概述:这10个案例到底在解决什么问题
1.1 锁的本质与最常见的两个隐形前提
锁这个东西,本质上干两件事:一是互斥,保证同一时刻只有一个线程进入临界区;二是可见性,保证释放锁时对共享变量的修改,能被后续拿到同一把锁的线程看到。这两点大家背得滚瓜烂熟,真正出问题的地方往往不在“原理”上,而在两个特别容易被忽略的隐形前提上。
第一个前提是“锁对象必须唯一”。synchronized 锁的是对象,不是代码块,也不是方法名。如果两个线程进入同一个方法时拿到的锁对象不是同一个实例,那 synchronized 就跟不存在一样。第二个前提是“加锁顺序必须一致”。当需要同时持有多个锁时,所有线程必须以相同的顺序去获取,否则就会出现 A 等 B、B 等 A 的循环等待,直接死锁。
我见过太多排查事故的场景:线程状态正常、日志打了一堆、代码检查了好几遍,最后发现是锁对象被换掉了。所以这篇不只是给你列代码,更想帮你建立一套“见到加锁代码先问三个问题”的反射——锁对象是谁?顺序固定吗?临界区够小吗?
1.2 10个案例的错误分类地图
10个案例看上去多,实际上可以归成三大类,先看这张总表,后面再逐个拆:
| 错误分类 | 对应案例 | 核心原因 | 一句话防坑 |
|---|---|---|---|
| 加锁失效 | 案例1-4 | 锁对象不唯一、锁对象被换、锁维度不一致 | 锁对象必须固定、唯一、不可变 |
| 等待与死锁 | 案例5-7 | 持锁等待、循环等待、抢锁不公平 | 缩短持锁时间、固定加锁顺序、等待用 await |
| 高级API误用 | 案例8-10 | volatile 缺失、忘了解锁、复合操作非原子 | 注意细节:volatile、finally、原子方法 |
这个分类不是随便画的。加锁失效类的特点是“你以为锁住了,其实没锁住”,后果是数据错乱但不明显,往往要到并发压测才能暴露;等待与死锁类的特点是“锁确实生效了,但线程全堵住了”,后果是接口超时,线上告警立刻响;高级API误用类的特点是“代码写得挺高级,但细节没吃透”,后果既有性能问题也有正确性问题,最坑的是它只在特定时序下才触发。
2. 加锁失效:4个案例看懂锁为什么没锁住
2.1 案例1:每次 new 一把锁,锁了个寂寞
这是新手最容易犯、老手偶尔也会手滑的错误。先看代码:
public class OrderService { public void createOrder(Order order) { Object lock = new Object(); // 每个线程进来都 new 一个对象 synchronized (lock) { // 检查库存、生成订单号、扣减库存 } } }问题分析:synchronized 加锁时,锁对象就是括号里的那个对象。但这里 lock 是方法内的局部变量,每个线程执行到这一行都会 new 一个全新的 Object。线程A的锁是A自己 new 出来的对象,线程B的锁是B自己 new 出来的对象,两个对象毫无关系,互斥效果为零。两个线程可以同时进入临界区,同时扣减库存,超卖就这么产生了。
正确写法是把锁对象提升为多个线程共享的实例成员,并且用 final 保证它不会被替换:
public class OrderService { private final Object orderLock = new Object(); public void createOrder(Order order) { synchronized (orderLock) { // 检查库存、生成订单号、扣减库存 } } }这里有两个细节值得强调:一是锁对象必须是共享的,单例模式下的 OrderService 里,所有线程看到的是同一个 orderLock;二是必须加 final,不加 final 的话,万一有人在某个方法里不小心执行了orderLock = new Object(),锁又会被换掉。如果你用 Spring 管理的默认单例 Bean,用实例成员做锁对象是没问题的,但如果是原型模式或多实例部署在同一 JVM 内,锁就得设计成静态的或者按业务维度单独管理。
2.2 案例2:字符串当锁的连环坑
很多老代码喜欢用字符串做锁,比如这样:
private String lock = "PAY_LOCK"; public void pay() { synchronized (lock) { // 支付逻辑 } }表面看没问题,因为 "PAY_LOCK" 是字符串字面量,会进入字符串常量池,所有线程拿到的引用是同一个对象。但这里至少埋了三颗雷。
第一颗雷:字符串常量池是 JVM 全局共享的。如果系统里另一个无关模块也用了 "PAY_LOCK" 这个字面量做锁,两个八竿子打不着的业务就会莫名其妙互相阻塞。第二颗雷:代码里一旦出现lock = new String("PAY_LOCK")或者字符串拼接,比如lock = "PAY_" + orderId,锁对象就变了——前者每次 new 一个新对象,锁失效;后者如果拿 orderId 拼接,每个订单的锁又不一样,彻底失去互斥。第三颗雷:字符串是不可变对象,但引用本身可以被赋值修改,如果有人把 lock 字段重新指向了另一个字符串,所有加锁的线程将各锁各的。
我见过一个真实事故,就是有人用synchronized ("ORDER_" + orderId)想锁单个订单,结果字符串每次拼接都是新对象,并发下单时同一订单的数据被多个线程同时修改。正确做法还是用专门的锁对象:
private final Object payLock = new Object(); // 如果一定要按订单维度加锁,用 ConcurrentHashMap 维护 private final ConcurrentHashMap<String, Object> orderLocks = new ConcurrentHashMap<>(); public void pay(String orderId) { Object lock = orderLocks.computeIfAbsent(orderId, k -> new Object()); synchronized (lock) { // 支付逻辑 } }用 ConcurrentHashMap 按业务维度维护锁对象,既能锁住同一个订单,又不会让不同订单互相等待。顺便说一句,这是老代码里常见的风格,早年间 C 语言用互斥锁的习惯被带进了 Java,现代 Java 生态里没有特殊理由不要用字符串当锁。
2.3 案例3:锁对象引用被偷偷修改
这个坑比案例1隐蔽,因为代码一眼看去锁对象是成员变量,确实是共享的,但它在运行过程中被改了引用:
public class InventoryService { private Object lock = new Object(); public void deduct() { synchronized (lock) { // 扣减库存 } } public void resetLock() { lock = new Object(); // 看似无害的重置操作 } }问题分析:synchronized 锁的是“当前 lock 引用的那个对象”,不是 lock 这个变量本身。假设线程A先进入 deduct(),正拿着最初的 Object 加锁;此时线程B执行了 resetLock(),把 lock 指向了新的 Object。线程C再进入 deduct() 时,用的是新的锁对象。A 和 C 的锁不是同一个,互斥直接失效。最坑的是 resetLock 可能在系统运行很久之后才被调用一次,问题只在那一瞬间出现,复现极难。
这类问题的本质是“锁对象引用必须稳定”。正确写法就是 final:
public class InventoryService { private final Object lock = new Object(); public void deduct() { synchronized (lock) { // 扣减库存 } } }如果你见过 volatile 修饰锁对象的写法,也不要学。volatile 只解决多线程间的可见性,解决不了锁对象唯一性的问题。锁对象就三个要求:共享、唯一、稳定。final 能帮你锁定后面两个。
2.4 案例4:静态锁和实例锁根本不是同一把锁
这个案例在初学者中很常见,也是代码评审时经常能挑出来的问题:
public class StockService { private static int stock = 100; public static synchronized void deductStatic() { stock--; } public synchronized void deductInstance() { stock--; } }表面上两个方法都加了 synchronized,都在改同一个静态字段,看起来没问题。但实际上这是两把不同的锁:静态方法上的 synchronized 锁的是StockService.class这个 Class 对象;实例方法上的 synchronized 锁的是this,也就是当前调用实例。线程A调用 deductStatic() 时拿的是类对象锁,线程B调用 deductInstance() 时拿的是实例对象锁,两边八竿子打不着,并发修改 stock 照样超卖。
修正方案很简单:统一维度。如果共享资源是静态字段,要么所有方法都写成静态 synchronized,要么所有方法都通过同一个实例调用并使用实例锁。最直接的统一写法:
public class StockService { private static int stock = 100; private static final Object STOCK_LOCK = new Object(); public static void deductStatic() { synchronized (STOCK_LOCK) { stock--; } } public void deductInstance() { synchronized (STOCK_LOCK) { stock--; } } }用一个显式的、私有的、final 的锁对象包住所有访问路径,比靠 synchronized 关键字的位置去猜锁的维度要靠谱得多。判断锁维度是否一致的土办法很直接:把所有访问同一个共享资源的方法列出来,问一句“它们加锁时用的对象是不是同一个”,如果不是,有一个算一个,全是隐患。
3. 等待与死锁:3个案例看穿阻塞陷阱
3.1 案例5:synchronized 里 Thread.sleep,全员陪睡
这个坑我在排查线上故障时遇到过不止一次。代码大概长这样:
public synchronized void deduct() throws InterruptedException { while (stock < 1) { Thread.sleep(3000); // 等仓库补货 } stock--; }问题分析:sleep() 只是让当前线程休眠,并不会释放已经持有的锁。如果一个线程拿着锁睡 3 秒,其他所有需要这把锁的线程就只能干等 3 秒。更要命的是,如果这个类里还有一个 synchronized 的 restock() 方法用来补货,补货线程也拿不到锁,于是形成了一个“持锁等待条件变化,而条件需要拿锁才能变”的僵局。这其实就是一种隐式死锁,线程没有互相等待对方持有的锁,但结果一样是全部卡死。
正确做法是:等待条件时释放锁。在 synchronized 体系里用 wait(),wait 会释放锁,被 notifyAll 唤醒后再重新参与锁竞争:
public synchronized void deduct() throws InterruptedException { while (stock < 1) { wait(); // 释放锁,等补货线程 signal } stock--; } public synchronized void restock(int count) { stock += count; notifyAll(); }如果用 ReentrantLock,对应的是 Condition 的 await/signal。它在语义上和 wait/notify 对齐,但功能更强,比如可以带超时时间:
private final ReentrantLock lock = new ReentrantLock(); private final Condition restocked = lock.newCondition(); public void deduct() { lock.lock(); try { while (stock < 1) { restocked.await(3, TimeUnit.SECONDS); } stock--; } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } }从业务设计角度,比“等待”更好的方案是缩小锁范围,把慢操作全部挪出临界区。锁内只放库存扣减和订单号生成这种纯内存操作,任何 IO、睡眠、远程调用都不应该出现在 synchronized 块里。这是死规矩。
3.2 案例6:锁顺序颠倒,经典转账死锁
这个案例几乎每个讲死锁的地方都会提,因为它最典型。比如下面这个账户转账逻辑:
public void transfer(Account from, Account to, int amount) { synchronized (from) { synchronized (to) { from.balance -= amount; to.balance += amount; } } }假设线程A执行transfer(accountA, accountB, 100),线程B同时执行transfer(accountB, accountA, 100)。线程A拿到了 accountA 的锁,等待 accountB 的锁;线程B拿到了 accountB 的锁,等待 accountA 的锁。两边都在等对方释放锁,谁也不让谁,死锁发生。
死锁的发生需要同时满足四个条件:互斥、持有并等待、不可抢占、循环等待。要破解死锁,只需要打破其中一个条件。这里最简单的突破口就是打破“循环等待”,办法是让所有线程都按固定的顺序加锁:
public void transfer(Account from, Account to, int amount) { Account first = from.id < to.id ? from : to; Account second = from.id < to.id ? to : from; synchronized (first) { synchronized (second) { from.balance -= amount; to.balance += amount; } } }不管from和to怎么传,最终加锁顺序永远是 id 小的先锁、id 大的后锁。线程A先锁 accountA 等 accountB,线程B也一样先锁 accountA 等 accountB,就不会出现环形等待,自然没有死锁。
另一个思路是打破“不可抢占”,也就是用 tryLock 带超时去抢锁:
if (from.lock.tryLock(1, TimeUnit.SECONDS)) { try { if (to.lock.tryLock(1, TimeUnit.SECONDS)) { try { // 执行转账 } finally { to.lock.unlock(); } } } finally { from.lock.unlock(); } }但 tryLock 方案有一个副作用:两个线程抢不到后续锁时可能同时释放已有锁,然后各自重试,形成活锁。所以 tryLock 通常要和随机退避配合,或者干脆用固定顺序。我个人在业务代码里更推荐固定顺序加锁,代码简单、可读性强、Bug 少。
3.3 案例7:活锁与锁饥饿,比死锁更隐蔽的坑
死锁至少特征明显——线程阻塞、jstack 能看到等待关系。活锁和锁饥饿就难查多了,线程状态看起来完全正常,但事务就是推进不下去。
活锁的典型场景:多个线程拿了锁发现搞不定,就主动释放并尝试重新获取,结果谁都做不成。举个例子:
boolean done = false; while (!done) { if (lockA.tryLock()) { if (lockB.tryLock()) { try { // 执行业务 done = true; } finally { lockB.unlock(); } } lockA.unlock(); } }两个线程同时进入循环:线程1拿到 lockA,线程2拿到 lockB。线程1发现拿不到 lockB,释放 lockA;线程2发现拿不到 lockA,释放 lockB。重试之后还是同样的剧本,两个线程都在忙,但业务永远完成不了。这就是活锁——程序没有阻塞,但没有任何进展。
锁饥饿则是另一种情况:synchronized 是非公平锁,在竞争激烈时,某些线程可能长期拿不到锁。极端场景下,一个低优先级或运气差的线程可能一直被其他线程“插队”。解决锁饥饿的方向有两个:一是把临界区做小,减少锁竞争;二是用公平锁,比如new ReentrantLock(true),让等待时间最长的线程优先获得锁。
个人建议:公平锁不是银弹,它会显著降低吞吐量。优先优化业务逻辑,把锁内的耗时操作砍掉,而不是一上来就换公平锁。
4. 工具类与高级API的3个隐蔽陷阱
4.1 案例8:双重检查锁少了 volatile,等于白写
双重检查锁(DCL)是单例模式里的经典写法,但很多人只学了半套:
public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 这里有问题 } } } return instance; } }问题分析:instance = new Singleton()在 JVM 层面并不是一个原子操作。它大致分三步:分配内存空间、调用构造器初始化字段、把引用赋值给 instance。JIT 和 CPU 在执行时可能发生指令重排,把第三步提前。这时候另一个线程执行 getInstance(),发现 instance 不为 null,直接返回——但它拿到的对象还没完成构造,字段可能是默认值,程序就出现了“半初始化对象”的诡异行为。
修正方法就一行:给 instance 加上 volatile。
public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }volatile 在这里的作用有两个:一是禁止instance = new Singleton()的指令重排;二是保证写 volatile 变量之前的操作对后续读该变量的线程可见。如果你不想研究 volatile 的底层细节,也可以直接用静态内部类实现单例,让 JVM 帮你保证初始化安全,代码更简单。
4.2 案例9:ReentrantLock 忘了 finally unlock,锁悄悄泄漏
synchronized 最大的好处之一是 JVM 会自动帮你释放锁,哪怕方法抛异常也会释放。但 ReentrantLock 是手动锁,lock() 之后必须自己 unlock()。很多人第一次从 synchronized 切到 Lock,就忘了这条规矩:
public void process() { lock.lock(); // 业务逻辑,可能抛出异常 lock.unlock(); // 如果中间抛异常,这行永远不会执行 }一旦业务逻辑抛异常,锁不会被释放。线程池里其他任务请求这把锁时全部阻塞,随着异常任务不断累积,最终表现为线程全部卡死、接口超时、甚至整个服务假死。更麻烦的是,这种情况 jstack 里看不到死锁,只是大量线程 BLOCKED。
正确写法是死规矩:lock 之后立刻写 try,finally 里 unlock。
lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }还有一个容易踩的细节是 tryLock() 的返回值。如果 tryLock() 返回 false,说明没拿到锁,绝对不能执行 unlock(),否则会抛 IllegalMonitorStateException:
if (lock.tryLock(1, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock.unlock(); // 只有拿到锁才能走到这里 } } else { // 拿不到锁的兜底逻辑 }从工程习惯上讲,代码里用 Lock 的每一处都要养成“lock 和 try 在同一行”的肌肉记忆,代码评审时看到lock.lock()后面不紧跟 try 的,直接打回。
4.3 案例10:ConcurrentHashMap 的“先判断后操作”不是原子的
很多人以为用了 ConcurrentHashMap 就万事大吉,所有操作都线程安全。其实单个的 get、put 确实是线程安全的,但组合操作不是。最常见的错误版本:
if (!cache.containsKey(key)) { cache.put(key, loadFromDb(key)); }两个线程同时发现 key 不存在,同时执行 loadFromDb,然后后写入的覆盖先写入的。数据不是全错,但会导致重复加载数据库、缓存被旧数据覆盖等问题。正确做法是用 ConcurrentHashMap 提供的原子复合方法:
cache.computeIfAbsent(key, k -> loadFromDb(k));computeIfAbsent 保证只有一个线程执行加载逻辑,其他线程拿到的是已经放入 Map 的结果。类似的情况还有 putIfAbsent、merge 等方法,遇到“先读后写”这种复合操作,优先找容器自带的原子方法,而不是自己拼两个操作。
这个案例的启示可以扩展得更广:AtomicReference 也一样,get()再set()的组合操作不是原子的,需要配合compareAndSet或getAndUpdate才能在无锁场景下保证一致性。凡是“检查之后再操作”的代码,都必须把整个复合过程设计成原子动作。
5. 当锁真的出问题,三步定位法
5.1 先看线程快照:jstack 是锁问题的第一现场
案例讲完,说说排查工具。遇到并发问题,第一反应不是改代码,而是先保留现场。最直接的手段是抓线程快照。在 Linux 上执行jstack <pid>,输出到文件里慢慢看。
拿到线程 dump 之后,重点看三类信息:
- BLOCKED 状态的线程:这类线程正在等待一把锁,栈顶会显示
waiting to lock <锁对象地址>。 - WAITING 状态的线程:这类线程在 wait/await 上挂起,等待被唤醒,栈里会显示导致等待的锁对象。
- 死锁检测信息:jstack 输出最后如果有
Found one Java-level deadlock,后面会直接列出两个线程互相等待的完整链路,这是最省事的死锁证据。
更直观一点可以开 jvisualvm,线程面板里阻塞的线程是红色标出来的,点进去能看到它在等哪把锁、锁被哪个线程持有。线上环境不方便开图形工具的话,可以用命令行的线程分析工具直接定位。
5.2 用现象反推:三类锁故障的排查速查表
根据我的经验,锁问题排查最有效的不是一上来就翻代码,而是先根据“现象”判断大致方向,再精准下手。这张表可以直接收藏:
| 线上现象 | 可能原因 | 排查入手点 |
|---|---|---|
| 并发执行同一逻辑,但数据仍然错乱 | 锁对象不唯一或锁维度不一致 | 检查锁对象是否 final、是否每次 new、静态/实例锁是否混用 |
| 接口 TP99 飙高,偶尔超时 | 持锁时间过长,锁内有 sleep/IO | 看加锁代码块里是否有耗时操作,确认临界区范围 |
| 线程全部卡住,请求堆积不返回 | 死锁、活锁或 Lock 锁泄漏 | jstack 看死锁检测;检查所有 lock 是否紧跟 try/finally unlock |
| 单个线程长期抢不到锁 | 锁饥饿或锁竞争过烈 | 缩小临界区;考虑公平锁;观察锁内代码是否频繁被占用 |
| 拿到“半初始化”对象 | DCL 单例缺少 volatile | 检查单例字段是否有 volatile 修饰 |
最后分享我自己总结的排查三步走:一看锁对象是否唯一且 final,二看锁顺序是否全局一致,三看临界区里有没有 sleep、IO、远程调用。说实话,八成以上的并发事故,跑不出这三个问题。
我个人在实际操作中的体会是:加锁的正确性不在于你记住了多少个 API,而在于写代码前有没有想清楚“锁对象是谁、顺序是什么、临界区多大”。前阵子我帮一个朋友 review 支付模块的代码,看到有人用字符串拼接做订单锁,锁对象随着订单号变化,理论上等于没锁。后来我把“锁对象唯一、锁顺序固定、锁范围最小”这三点写成了团队的代码评审必查项,从那以后并发相关的线上故障明显少了很多。如果你把这10个案例消化掉,尤其是前4个加锁失效的案例,你至少能避开涉及锁的八成低阶事故。如果以后遇到类似的问题,欢迎在评论区交流,这个系列后续我还会接着聊读写锁、StampedLock 和并发容器的更多细节。