并发编程里的锁,从来不是“加了synchronized就万事大吉”这么简单。这些年我做代码评审和线上问题排查,见过太多锁相关的离奇事故:有人明明加了锁,数据照样错乱;有人用Integer做锁,锁到最后发现每个线程拿的居然是不同对象;还有人把锁嵌套得挺漂亮,结果服务毫无征兆地“卡死”,一查线程dump,全是DeadLock。这篇文章就把我从实际项目中归纳出来的 10 个高频场景一次性讲清楚,按“加锁失效、可见性与原子性、死锁与活锁、性能陷阱”四个方向展开,每个案例都给出错误写法、错误原因和修正方案。适合正在写业务并发代码的后端研发,也适合准备面试时系统盘点锁机制的 Java 工程师。
1. 加锁失效:为什么你的 synchronized 完全没起作用
加锁失效是最隐蔽的一类问题。表面上代码写的是synchronized,语法也完全正确,但运行时锁的根本不是同一个对象,于是多个线程长驱直入,共享数据稀里哗啦地错乱还不容易复现。这类问题通常在低并发下看不出来,一旦压测或线上流量上来,问题就爆了。
1.1 案例 1:用 Integer 当锁,锁对象自己“跑”了
很多代码喜欢用Integer当锁,因为它写起来简洁。比如下面这个计数器:
public class IntegerLockDemo { private static Integer count = 0; public static void increment() { synchronized (count) { count++; } } }这段代码坑就坑在count++这一步。Integer是不可变对象,count++在字节码层面会拆箱、自增、再装箱,装箱过程会调用Integer.valueOf(int),它返回的可能是一个全新对象。也就是说,当前线程在synchronized (count)里修改了count的引用,下一个线程进来的时候,锁的对象已经不是同一个了,两个线程各自锁了不同的对象,互斥自然失效。
这类用法我实际排查过,现象是“偶发少计数”,代码怎么看都对,就是结果不对。修复方案很简单:锁对象永远用独立的、不会被重新赋值的final对象,计数则交给AtomicInteger。
public class IntegerLockDemoFix { private static final Object LOCK = new Object(); private static int count = 0; public static void increment() { synchronized (LOCK) { count++; } } }经验:凡是基本类型包装类、字符串等不可变对象,都不要拿来当锁。锁对象必须是稳定存在的引用,变量本身可以被重新赋值的话,锁就跟着飘走了。
1.2 案例 2:synchronized(this),每个实例一把锁
还有一个高频写法是直接锁this,但因为线程使用的任务对象不一样,锁形同虚设。
public class Task implements Runnable { private int count = 0; @Override public void run() { synchronized (this) { count++; System.out.println(count); } } } // 调用处 for (int i = 0; i < 10; i++) { new Thread(new Task()).start(); }这段代码的问题在于每次new Task(),Runnable实例都不一样,synchronized (this)锁的自然是不同对象。10 个线程同时在各自的this上等待,互不干扰,count照样并发自增,结果毫无秩序。这种错误在“每次都new对象丢给线程池”的代码里特别常见。
修正方向有两个:要么所有线程共享同一个Task实例,要么干脆锁一个全局静态锁对象。
Task task = new Task(); for (int i = 0; i < 10; i++) { new Thread(task).start(); }如果把逻辑改为共享一个任务实例,锁this才有效。更稳妥的做法是直接用synchronized (Task.class)锁类对象,或者定义private static final Object LOCK = new Object()。需要记住的是:锁对象是谁,取决于所有线程访问的是不是同一个引用。
1.3 案例 3:字符串常量池当锁,锁到别人家门口
用字符串当锁是一个更隐蔽的坑。字符串字面量在 JVM 里有常量池复用机制,也就是说你用"lock"这个字符串做锁,哪怕是在完全不相干的类里,只要都写synchronized ("lock"),它们锁的就是同一个对象。
public class LockServiceA { public void methodA() { synchronized ("LOCK") { // 业务逻辑 } } } public class LockServiceB { public void methodB() { synchronized ("LOCK") { // 完全不相关的业务逻辑 } } }表面上看methodA和methodB风马牛不相及,但因为共用同一个字符串对象,这两段互不相干的代码会被迫串行执行。一个线程占住锁,另一个业务模块的线程只能干等,性能莫名其妙地下降。反过来,如果两个方法本应互斥,却因为其中一个用new String("LOCK")、另一个用字面量,它们锁的又不是同一对象,互斥失效。
所以在真实项目里,我从不建议用字符串做锁。锁对象要么是专用的static final Object,要么是业务对象本身,而不是字符串常量这种“全局可见”的东西。
1.4 案例 4:静态锁和实例锁,两把不同的锁
还有一种混合锁错误,常见存在于工具类或者带静态方法的服务类里。比如这样:
public class Counter { private static int count = 0; public synchronized void add() { count++; } public static synchronized void staticAdd() { count++; } }这里add()是实例方法,锁的是this;staticAdd()是静态方法,锁的是Counter.class。两个线程一个调add(),另一个调staticAdd(),它们各自拿的是不同的锁对象,共同操作同一个静态变量count,数据安全照样无法保证。
我见过有人把这种代码混在业务里,本地自测没问题,因为并发量不高,一上生产就出现偶发数据错乱。修复的关键是统一锁对象:如果目标共享数据是静态的,那所有方法都应该用synchronized (Counter.class)或者private static final Object LOCK,不要一半锁类、一半锁实例。
排查加锁失效时,有一个快速判断心法:连续问自己三次“这个锁对象是同一个引用吗”。只要有一个环节是不确定的,就要把锁对象改成显式的
final对象。
2. 可见性与原子性:锁不彻底,等于没写
第二类问题比单纯的加锁失效更上一层:锁确实生效了,但因为用错了同步机制、或者在错误的阶段使用锁,导致数据还是出问题。这一章里的案例集中在volatile误用、双重检查锁和复合操作不整体加锁。
2.1 案例 5:volatile 保证可见性,但不保证原子性
volatile是并发编程里最容易被误解的关键字之一。很多初学者以为变量加了volatile就线程安全了,于是写出这样的计数逻辑:
public class VolatileCounter { private volatile int count = 0; public void increment() { count++; } }count++实际上由三步组成:读取 count、count 加 1、写回 count。volatile只能保证读和写对线程的可见性,不能保证“先读后写”这个整体过程是原子的。两个线程可能同时读到同一个旧值,各自加 1 后写回,最终结果只增加了 1,而不是 2。
我早年压测一个统计接口时遇到过,跑完 100 万请求,统计值只有 80 多万,当时排查了很久才发现是这个原因。要修复计数类需求,用AtomicInteger的incrementAndGet(),或者用synchronized包住整个自增操作。
public class AtomicCounter { private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } }理解volatile的最佳类比是:它像一个公告栏,所有人能看到最新公告,但“看清公告、做出判断、修改公告”这个动作本身没有约束,该打架还是打架。
2.2 案例 6:双重检查锁单例,指令重排的暗坑
双重检查锁是面试高频题,也是实际代码里最容易“看起来对、用起来错”的写法。先看经典的错误版本:
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 层面大致分为三步:分配内存、调用构造器初始化对象、把引用指向内存。Java 允许指令重排序,第三步可能先于第二步执行。这时另一个线程进来,发现instance != null,直接返回了一个“还没初始化完的对象”,后续使用就可能爆炸。
修复方式是在instance字段上加上volatile,从 JDK 5 之后,volatile提供了完整的 happens-before 语义,可以禁止这种重排序:
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; } }如果不想纠结双重检查锁的细节,更省心的方案是使用静态内部类的懒加载方式,或者直接使用枚举单例。它们由 JVM 的类加载机制保证线程安全,代码也更简洁。
2.3 案例 7:复合操作拆开加锁,等于没加
还有一种非常典型的原子性问题:锁加了,但只加在了其中一段上,整体流程还是有缝隙。比如这样一个本地缓存的重复加载问题:
public class CacheDemo { private Map<String, String> cache = new HashMap<>(); public String get(String key) { if (!cache.containsKey(key)) { synchronized (this) { cache.put(key, loadFromDb(key)); } } return cache.get(key); } }这里containsKey判断在锁外面,两个线程可能同时通过判断,然后串行地进入锁,各自执行了一次loadFromDb,把数据库打了两遍。更严重的是,如果锁内没有再次判断,后一个线程可能会覆盖前一个线程写入的数据。
正确做法是整个“检查然后写入”的复合操作必须处在同一把锁的保护下,锁内还要再做一次二次判断:
public String get(String key) { synchronized (this) { if (!cache.containsKey(key)) { cache.put(key, loadFromDb(key)); } return cache.get(key); } }日常开发里这个错误太常见了,因为它不是必现的,只有在两个请求恰好同时到达的时序下才出问题。而“恰好同时到达”在高并发环境下只是时间问题。要规避这类复杂度,现代开发更推荐直接使用ConcurrentHashMap的computeIfAbsent,它把原子性封装好了,不需要自己处理锁粒度。
3. 死锁与活锁:线程之间如何“锁死”
前两章的问题大多是数据错乱,这一章的问题更可怕:线程直接不干活了,服务整体卡死。死锁的经典场景是多个线程互相持有对方需要的锁,形成循环等待。这里我整理三个实际项目中遇到的高频案例。
3.1 案例 8:账户转账嵌套锁,交叉等待直接 DeadLock
转账场景是说明死锁的经典例子。两个账户互相转账时,如果锁嵌套顺序不一致,就会出现僵局:
public class Transfer { public void transfer(Account from, Account to, int amount) { synchronized (from) { synchronized (to) { from.setBalance(from.getBalance() - amount); to.setBalance(to.getBalance() + amount); } } } }线程 1 执行transfer(A, B, 100),先锁住 A;线程 2 执行transfer(B, A, 100),先锁住 B。线程 1 想继续锁 B,但 B 在线程 2 手里;线程 2 想继续锁 A,但 A 在线程 1 手里。两边都在等对方释放锁,于是永久卡死。这不是理论推演,我在一个模拟资金归集系统里压测时真的遇到过,现场表现就是交易量跌到 0,线程池全被占满。
解决死锁的思路很多,最实用的是“全局顺序加锁”。无论转账方向是什么,都先锁 id 小的账户:
public void transfer(Account from, Account to, int amount) { Account first = from.getId() < to.getId() ? from : to; Account second = from.getId() < to.getId() ? to : from; synchronized (first) { synchronized (second) { // 转账逻辑 } } }这样所有线程都按同一个顺序拿锁,就不会形成循环等待。另一种思路是使用tryLock带超时,拿不到锁就释放已持有的锁,避免死等。
3.2 案例 9:ReentrantLock 死等,只会 lock() 不会 tryLock()
很多从synchronized迁移到ReentrantLock的代码,仍然习惯先lock()再干活。这种写法一旦遇到异常或持锁时间过长,就会让其他线程无限期等待:
Lock lock = new ReentrantLock(); public void doSomething() { lock.lock(); try { // 业务逻辑,可能执行很久 } finally { lock.unlock(); } }表面看加了解锁,没问题。但假如持锁线程因为外部接口超时卡住了 5 分钟,其他线程就会在lock()这行排长队,最终线程池耗尽、服务假死。synchronized拿不到锁也会等,但ReentrantLock的优势就是可以带超时主动放弃。
我更推荐在非绝对必要的情况下,使用带超时的获取方式:
if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock.unlock(); } } else { // 拿不到锁的兜底逻辑,比如降级、回报失败 }tryLock起码给你留了一条“不无限等待”的后路。这不是说lock()不能用,而是在不确定持锁时间的外部调用场景里,超时机制能有效避免系统雪崩。
3.3 案例 10:读写锁升级,读锁还没释放就要写锁
ReentrantReadWriteLock是常见的读写分离工具,但它的锁升级是一个雷区。所谓升级,就是线程先持有读锁,然后在持有读锁的情况下尝试获取写锁:
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); public void updateCache() { rwLock.readLock().lock(); try { // 读取数据判断是否需要更新 rwLock.writeLock().lock(); // 这里会死锁 try { // 写入更新 } finally { rwLock.writeLock().unlock(); } } finally { rwLock.readLock().unlock(); } }读锁是共享锁,可能被多个线程同时持有。当一个线程持有读锁想要升级为写锁时,它必须等待所有读锁释放,而它自己又占着一个读锁不放,于是永远等不到自己释放,形成自我死锁。我排查过类似问题,线程dump里显示某一个线程在writeLock处等待,而它就握着读锁,非常典型的自锁。
解决方式有两个方向:如果需要“先读后写”,那就先释放读锁,再获取写锁,但此时要处理中间其他线程抢先写的并发问题;更好的选择是用StampedLock,它支持tryConvertToWriteLock做锁升级的尝试,或者干脆在明确可能要写的情况下直接用写锁。
额外补充一个活锁的概念:活锁并不是死锁,它发生在两个线程在不断谦让、互相重试,导致谁也做不成事。比如两个线程同时检测到冲突,各自释放锁然后重试,结果又同时冲突,循环往复。解决活锁的办法通常是引入随机退避,让两个线程下一次的节奏错开。
4. 性能陷阱:锁太重,并发活活写成串行
锁用对了不代表万事大吉。高并发场景下,锁粒度就是决定系统吞吐量的关键因素。很多服务卡顿的原因并不是死锁,而是锁的范围太大、竞争太激烈,所有并发请求被迫排队,吞吐量降了一个数量级。
4.1 案例 11:粗粒度锁让压测 QPS 断崖下跌
这类问题最经典的表现是:为了一段简单计算,把一个方法的整个逻辑都塞进synchronized方法里。方法内部明明包含很多无共享状态的耗时操作,比如远程调用、文件处理、序列化,结果全部被锁串行化。
public synchronized void process(Order order) { // 准备数据,纯本地运算,无共享变量 prepare(order); // 远程调用,耗时较长 callRemoteService(order); // 更新共享状态,真正需要锁的部分 updateSharedData(order); }process方法体里只有updateSharedData需要保护,但粗粒度锁把callRemoteService这种高耗时、无共享的操作也锁住了。压测表现是 QPS 断崖下跌,线程大部分时间阻塞在等待远程调用上,CPU 利用率却很低。
修正思路是缩小锁的范围,把锁只放在真正需要保护的临界区上。远程调用和本地计算都挪到锁外。优秀实践里还会用“锁内只做必要的共享变量操作,尽量不做 IO”这类原则。
public void process(Order order) { prepare(order); callRemoteService(order); synchronized (this) { updateSharedData(order); } }4.2 什么时候从 synchronized 切换成 ReentrantLock
synchronized在现代 JVM 里已经很优化了,有偏向锁、轻量级锁等演进,绝大多数场景它是首选。但有些场景ReentrantLock的优势非常明显,换锁能直接解决问题:
| 场景 | 推荐选择 | 原因 |
|---|---|---|
| 简单互斥、代码短小 | synchronized | 使用简单,JVM 自动释放锁 |
| 需要超时获取锁 | ReentrantLock | tryLock 带 TimeUnit |
| 需要响应中断 | ReentrantLock | lockInterruptibly |
| 需要公平锁 | ReentrantLock | 构造器传入 true |
| 高并发读远多于写 | ReentrantReadWriteLock / StampedLock | 读锁共享不互斥 |
我在实际项目里的习惯是:能用synchronized就不用ReentrantLock,因为它自带解锁语义,不存在忘记释放的问题。只有出现明确诉求,比如必须避免死等、必须做公平排队,才切换到显式锁。
4.3 用并发容器和无锁方案替代直接加锁
很多时候最有效的“锁优化”是:别用锁。JDK 提供了大量线程安全的容器和无锁工具,覆盖了常见的并发场景。比如:
ConcurrentHashMap:分段加锁,适合高并发读写的 Map。CopyOnWriteArrayList:读多写少场景,写时复制。AtomicInteger、AtomicLong:基于 CAS 的原子计数器。LongAdder:极高并发计数场景,比AtomicLong吞吐更高。BlockingQueue:生产者消费者模型,内部已处理好同步。- 内部使用 ThreadLocal:把共享变量变成线程私有,直接从根源消除竞争。
我改造过一个统计模块,原来用synchronized保护HashMap统计计数,并发高时线程排队严重。后来换成ConcurrentHashMap+LongAdderValue的组合,压测吞吐率直接翻了几倍,而且代码量还更少了。锁并不是越多越好,“要不要锁、锁多大范围、能不能无锁”这三个问题,永远值得在写代码前多想一遍。
5. 避坑速查表与排查实战
前面的案例覆盖了加锁失效、可见性、死锁、性能四类问题。这一章我压缩成可直接查阅的速查表,再分享一些我常用的现场排查方法。
5.1 十类案例速查对照
| 场景关键词 | 错误本质 | 推荐修正 |
|---|---|---|
| Integer/Long 基本类型包装类做锁 | 隐式拆装箱导致锁对象变化 | 使用 final Object 独立锁对象 |
| 每次 new Runnable 再锁 this | 锁的是不同实例 | 共享实例或锁静态对象 |
| 字符串字面量做锁 | 字符串常量池导致全局偶合 | 使用显式专用锁对象 |
| 静态方法锁 Class,实例方法锁 this | 两把锁互不重叠 | 统一锁对象 |
| volatile 修饰自增变量 | 可见性不等于原子性 | 使用 AtomicInteger |
| 双重检查锁缺 volatile | 指令重排导致半初始化对象 | instance 加 volatile |
| 检查与写入拆开加锁 | 复合操作存在窗口期 | 整个复合操作放锁内 |
| 多个锁嵌套且顺序不一致 | 循环等待死锁 | 按固定顺序加锁或 tryLock |
| lock() 后无限等待 | 没有超时机制 | tryLock 带超时兜底 |
| 读写锁升级 | 读锁未释放无法获取写锁 | 释放读锁再拿写锁或 StampedLock |
这张表其实也是我自查清单的来源。遇到并发问题,先对照表格看代码踩中了哪个;如果没有直接命中,再往下走排查流程。
5.2 现场排查锁问题的操作流程
线上出问题时,最有效的第一步不是翻代码,而是把现场保存下来。
当服务卡死或线程池耗尽,先用jstack拿到线程dump:
jstack <pid> > thread_dump.txt打开thread_dump.txt,重点找以下几类关键信息:
Found one Java-level deadlock:JVM 已经明确检测到死锁。waiting for monitor entry:线程在等待 synchronized 锁。parking to wait for <...> (a java.util.concurrent.locks.ReentrantLock...):线程在等待显式锁。BLOCKED状态线程数量是否过多:如果大量线程 BLOCKED,说明锁竞争太激烈。
第二步是分析线程调用栈。假设发现多个线程互相持有不同的锁并等待对方,基本可以确认死锁;如果大量线程卡在同一个锁对象上,则更像锁粒度问题,需要回看代码缩小锁范围。
第三步是用jcmd <pid> Thread.print或者 JDK 自带的jconsole做重复采样。死锁大概率是稳定复现的,多抓几次线程状态,就能确认是偶发还是持续。值得多提一句:线上排查时不要急着重启服务,先把dump和日志留下来,否则问题重启之后就很难定位了。
5.3 写锁之前的“三句自问”
踩过足够多的坑之后,我现在写任何锁代码之前,都会默念一遍这三个问题:
第一问:锁对象是所有线程共享的同一个引用吗?只要不是同一个引用,锁等于没加。
第二问:锁覆盖的范围包含全部共享变量操作了吗?if判断加上后面的写操作,必须整体在锁内,不能留窗口。
第三问:多个锁嵌套时,所有线程的加锁顺序一致吗?顺序不一致就是死锁温床。
这三个问题覆盖了加锁失效、复合操作原子性和死锁三大核心,能过滤掉绝大部分并发 bug。代码写完提交评审前,我还会专门跑一遍高并发自测,用几十个线程同时操作共享数据,观察是否出现数据错乱或线程卡住。并发问题的特点是概率极低但破坏极大,自测阶段多花时间,线上才能少熬夜。
文章写到这里,我再分享一个自己的观察。很多人学并发时盯着原理和 API 记,真正到了项目里,踩坑往往不是因为原理不懂,而是因为写代码时没有形成“锁对象一致性”这类条件反射。比如Integer做锁、字符串做锁、静态实例锁混用,这些错误背后不是不会用 synchronized,而是没有在落笔瞬间意识到“锁的关键是锁谁”。
如果你能把“锁的是谁、锁的范围、锁的顺序”这三点内化成习惯,并发代码的质量会有明显提升。把这篇文章收藏起来,写完锁相关代码后对着速查表过一遍,比翻教科书高效得多。