☰
并发编程锁的10个高频陷阱:从加锁失效到死锁与性能优化
2026/10/16 4:36:16 网站建设 项目流程

并发编程里的锁,从来不是“加了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 自动释放锁
需要超时获取锁ReentrantLocktryLock 带 TimeUnit
需要响应中断ReentrantLocklockInterruptibly
需要公平锁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,而是没有在落笔瞬间意识到“锁的关键是锁谁”。

如果你能把“锁的是谁、锁的范围、锁的顺序”这三点内化成习惯,并发代码的质量会有明显提升。把这篇文章收藏起来,写完锁相关代码后对着速查表过一遍,比翻教科书高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询