1. 从一次线上事故说起:谁动了我的订单号
去年年底,我们负责的电商订单系统上线大促预案,压测到第二阶段时,监控面板突然飘红:同一秒内生成了两个一模一样的订单号。我当时第一反应是"主键冲突只是偶发",继续压测,结果不到半小时,数据库主键直接胀爆,整个订单写入链路瘫痪。事后排查代码,罪魁祸首是一段我至今看到都脸红的代码——一个简易的订单号生成器,里头用了静态的 SimpleDateFormat 和一段没有加锁的自增逻辑。
这一巴掌打得很疼。它让我彻底明白一件事:线程安全问题从来不是"教科书里的概念",而是线上事故的真正根源。 很多人觉得"我没写多线程代码,线程安全跟我没关系",但你的框架、你的容器、你的中间件,全都跑在多个线程之上。只要你用了共享的、可变的、跨线程访问的变量,你就已经在和线程安全打交道了。
这篇文章我想沉下心来,把线程安全问题彻底讲透。不光是定义,更重要的是:它到底怎么发生的、底层原理是什么、实战里最常见的坑有哪些、以及一套可落地的排查和防护思路。内容比较多,但我尽量用说人话的方式,把每个环节都掰开揉碎。
下面是基于我多年一线经验整理的完整内容,既有原理拆解,也有踩坑复盘,希望能帮你在面试、写代码、排查线上问题三个场景里,都真正做到"心里有数"。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线程安全问题的本质:三个底层条件,缺一不可
既然要说线程安全,我们得先回到最底层的"事故现场"——多线程到底为什么会把数据搞乱?如果你研究过足够多的并发 Bug,你会发现所有问题都逃不开三个东西:
2.1 竞态条件(Race Condition):代码的执行顺序"失控"了
所谓竞态条件,是指多个线程同时访问一份共享数据,并且至少有一个线程在"写"这份数据,而最终结果取决于线程调度器的"心情"——谁先执行、谁后执行、谁执行到一半被打断,结果就完全不同。
我拿一个最简单也最经典的例子说明:i++。
你没看错,就是这一行代码。在单线程世界里,它什么毛病都没有;但在多线程世界里,它其实是三条 CPU 指令:
- 把
i从内存读到寄存器(读) - 在寄存器里执行 +1(改)
- 把结果写回内存(写)
假设 i 初始值是 5,线程 A 执行完"读"之后,寄存器里是 5;这时候线程 B 也来执行"读",拿到的还是 5。然后 A 把 6 写回去,B 也把 6 写回去。好了,两次自增,结果从 5 变成 6,而不是 7。这就是典型的"读改写"操作非原子导致的竞态条件。
生活中的类比就是:两个人同时打开同一个记账本,看到余额是 100 块,各自在脑中算出 100+50=150,然后都往账本上写 150。实际应该记为 200,但账本上只有 150,丢了 50 块。
2.2 可见性问题:线程之间"看不见"对方的修改
竞态条件讲的是"操作被打断",可见性是另一个独立的维度:即使线程 A 修改了共享变量,线程 B 也不一定立刻能看到这个修改。
这是为什么?因为现代 CPU 都有多级缓存。线程 A 修改 flag = true 时,可能只写入了 A 所在 CPU 核心的缓存,还没有同步到主内存;线程 B 读取 flag 时,读到的还是自己核心缓存里的旧值 false。这就出现了"你改了,但我看不见"的诡异情况。
有人说"那我不用缓存不就行了",实际上 JVM 有 JMM(Java 内存模型)规范,不同语言也有对应的内存一致性模型——光靠"感觉"来决定变量要不要刷主内存,是不靠谱的。这也是为什么 volatile 关键字会存在:它的核心语义就是保证可见性,强制对变量的读写操作直接作用于主内存(或者说,建立 happens-before 关系)。
2.3 原子性问题:操作"不可分割"才安全
原子性要求一个操作要么全部执行成功,要么全部不执行,不存在中间状态。i++ 之所以不安全,本质就是因为"读-改-写"这三个步骤没有绑定成一个不可分割的整体。
很多人会把"原子性"和"线程安全"划等号,这是不准确的。原子性是线程安全的一个必要条件,但不是充分条件——你还需要可见性,还需要考虑有序性(指令重排问题)。这三个条件互相独立,又经常叠加出现。
我在排查问题的时候有个习惯:不管 Bug 表现得多诡异,先拿这三个条件去套。只要识别出代码里存在"共享 + 可变 + 无同步"的组合,基本上就锁定问题了。
3. 一个经典变量,三类典型问题
理论讲完了,我们落到实际代码。下面这个场景可能是你项目里最常见的,也是我见过最多的线上事故现场:多线程修改同一个 HashMap。
3.1 问题一:HashMap 的链表环,死循环卡死 CPU
这是早期的经典事故。JDK 1.7 的 HashMap 在扩容时采用头插法,多线程并发触发扩容时,链表可能形成环。一旦环形成,下次 get 这个 key 时,线程会在链表里死循环,CPU 占用率直接飙到 100%。
我记得很多年前做支付网关时,线上一个节点 CPU 突然被打满,用 jstack 一看,线程栈全部卡在 HashMap.get() 里。杀进程重启恢复了,但过两天又复现。后来把代码里的 HashMap 改成 ConcurrentHashMap,问题彻底消失。
这个地方值得多说一句:如果你还在用 JDK 8 之前的版本,一定要检查 map 的使用场景。 JDK 8 把 HashMap 的头插法改成了尾插法,链表环的问题得到缓解,但并发下数据丢失、覆盖的问题依然存在。所以无论哪个版本,都不建议在多线程环境直接使用 HashMap。
3.2 问题二:size 和 put 的可见性
就算没有扩容,HashMap 在多线程下的问题也很多。比如线程 A put 一个 key,线程 B 在另一个线程里读取,B 可能读不到(可见性问题);两个线程同时 put 不同 key,但它们的 hash 计算落到了同一个数组槽位,后写的可能覆盖前写的(数据丢失)。
还有更隐蔽的:map.size() 在并发 put 时可能不准确。size() 没有加锁,它只是在某个时刻读取 modCount 和 size 字段,这两个字段在并发写入时可能处于"你写一半我读一半"的状态,导致返回值偏小或偏大。
3.3 问题三:你以为安全的 ConcurrentHashMap
很多同学一看,那我就用 ConcurrentHashMap 呗,安全了吧?
ConcurrentHashMap 确实提供了更强的线程安全保证——JDK 8 里它放弃了分段锁,改用 CAS + synchronized 锁住数组桶位,并发度大幅提升。但它只能保证"单个操作"的原子性和可见性,不能保证"复合操作"的原子性。
什么意思?看这段代码:
java复制if (!map.containsKey(key)) {
map.put(key, value);
}
这就是经典的"先检查后执行"复合操作。就算 map 是 ConcurrentHashMap,containsKey 和 put 之间也可能被其他线程插一脚,导致两个线程都执行了 put,后写的覆盖先写的。正确做法是使用 putIfAbsent 这类自带原子语义的方法,或者自己加锁控制复合逻辑的原子性。
这里提供一个简单的小结论:
- 单个 put / get / remove → ConcurrentHashMap 安全
- containsKey + put → 有竞态,需用 putIfAbsent 或加锁
- 遍历 + 修改 → 一致性快照(如使用迭代器的弱一致性特性),但需要避免在遍历中修改结构
4. 线程安全的核心手段:锁、CAS 与线程封闭
概念和问题都讲得差不多了,下面聊怎么解决。我把解决方案分成三大流派,各有适用场景,用法和坑也不一样。
4.1 锁(Lock):最朴素也最可靠的手段
锁的本质是"互斥"——同一时刻只允许一个线程进入临界区,其他线程在外面等。Java 里用的最多的是 synchronized 和 ReentrantLock,两者各有特点:
synchronized:语法简单,写好就自动加锁/释放锁;JVM 层面做了大量优化(偏向锁、轻量级锁、重量级锁升级),大多数场景够用。ReentrantLock:功能更丰富,支持可中断、超时、公平/非公平锁、多个条件变量(Condition)。适合需要更精细控制的场景。
用锁的时候,最容易踩的坑是什么?死锁。
死锁的基本模型是:线程 A 持有锁 1,等待锁 2;线程 B 持有锁 2,等待锁 1。两边都等对方释放,谁也不退让,程序就卡死了。
避免死锁的通用策略有几个:
- 锁顺序一致:如果必须获取多个锁,所有线程都按同一顺序获取。
- 锁超时:使用
tryLock(timeout),拿不到就放弃,避免无限阻塞。 - 缩小锁范围:只锁真正需要保护的代码,不要一个大 synchronized 包住整个方法。
我实际项目里处理过一个死锁案例:两个账户转账,A 给 B 转,B 给 A 转,两个线程分别 lock(a)、lock(b),交叉持有对方需要的锁,直接卡死。后来统一改成先锁账户 ID 较小的一方,问题迎刃而解。这种"排序加锁"的思路,在分布式系统里也很常用。
4.2 CAS(Compare And Swap):无锁并发的主力
锁有它的代价——上下文切换、阻塞唤醒,在多核高并发下"锁竞争"本身就是性能瓶颈。CAS 的思路不同:先比较,如果变量值和我期望的一致,就更新;不一致就重试。整个过程不需要进入内核态,所以叫"无锁并发"。
Java 里的 AtomicInteger、AtomicLong、AtomicReference 底层都是 CAS。
但 CAS 有一个非常经典的坑:ABA 问题。 也就是说,变量从 A 变为 B,又变回 A。CAS 只关心"当前值是否为 A",不关心中间是否被改过。对于某些场景(比如链表节点回收再复用),这会导致错误的判断。
解决方案是加版本号:AtomicStampedReference 就是干这个的。每次修改不仅检查值,还检查版本号,杜绝 ABA。
还有一点要注意:CAS 在竞争激烈时会大量自旋重试,CPU 消耗反而更高。 所以高并发下 CAS 不一定比锁快。我见过一些团队为了优化性能,把 HashMap 换成 AtomicInteger 做计数器,结果压测时 CPU 狂飙,就是没有考虑自旋代价。判断用锁还是 CAS,不能看"谁听起来高级",要看实际的竞争烈度和操作频率。
4.3 线程封闭:最"笨"但最有效的方案
最后这个思路很多人会忽略:干脆不共享。
如果数据不跨线程共享,就不存在线程安全问题。具体做法有两种:
- 栈封闭:只使用局部变量。局部变量在线程自己的栈上,天然隔离,不存在竞争。
- ThreadLocal:每个线程维护自己的变量副本。最典型的使用场景是
SimpleDateFormat——这个类不是线程安全的,多线程共用会出各种诡异问题(比如时间解析错乱、抛 NumberFormatException)。用 ThreadLocal 给每个线程一个自己的SimpleDateFormat实例,问题直接消失。
不过 ThreadLocal 也有内存泄漏的坑:如果线程池里的线程存活时间很长,ThreadLocal 里的对象一直不被清理,就可能 OOM。正确做法是在 finally 里调 remove()。
java复制private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
try {
String time = DATE_FORMAT.get().format(new Date());
} finally {
DATE_FORMAT.remove();
}
5. 线程安全性的最强后盾:不可变对象与并发容器
前面讲的都是"怎么让共享可变数据的访问变安全",还有一种更优雅的思路:让数据根本不可变。 这是我在偏好函数式编程的项目里学到的,实践下来,代码的并发 Bug 率直接降了一个量级。
5.1 不可变对象为什么天生线程安全
不可变对象指的是创建之后其内部状态就不能再被修改的对象。Java 里最常见的例子是 String,还有基础类型包装类、BigDecimal、LocalDate 等。
不可变对象的好处:
- 不存在"写"操作,自然就没有"读改写"竞争;
- 可以被安全地缓存、共享、发布,不需要加锁;
- 对它的任何"修改"都会产生新对象,旧对象不受影响。
如果想要自定义一个不可变类,需要遵守几条规则:
- 类声明为 final;
- 所有字段都是 private final;
- 不提供修改字段状态的 setter 方法;
- 构造器里完成所有字段的初始化,并防止 this 逸出;
- 如果字段是可变对象(比如数组、List),不要直接暴露引用,而是返回拷贝或使用不可变封装。
这段代码是一个比较标准的不可变类示例:
java复制public final class UserInfo {
private final String name;
private final List<String> roles;
public UserInfo(String name, List<String> roles) {
this.name = name;
this.roles = Collections.unmodifiableList(new ArrayList<>(roles));
}
public String getName() {
return name;
}
public List<String> getRoles() {
return roles;
}
}
注意这里的 roles 即使被 unmodifiableList 包装,也只是"视图不可变",如果原始 List 还在外部被修改,这个对象仍然不是线程安全的。所以构造器里拷贝一份再包装,才真正安全。
5.2 并发容器怎么选:一张表说清
除了不可变对象,工程里更常用的是并发容器。选错容器导致的线上事故我也见过不少。这里整理一下我用过的容器选择和避坑建议:
| 容器 | 适用场景 | 注意事项 |
|---|---|---|
ConcurrentHashMap |
高并发读写 Map | 复合操作要额外保证原子性 |
CopyOnWriteArrayList |
读多写极少(如黑白名单配置) | 写操作代价高,每次写都复制数组 |
BlockingQueue |
生产者消费者模型 | 注意队列容量与阻塞策略 |
ConcurrentLinkedQueue |
高并发队列,无界 | 注意内存无限增长的隐患 |
LinkedBlockingQueue |
有界队列,支持阻塞 | 适合做任务队列 |
以 CopyOnWriteArrayList 为例,它读的时候不加锁,写的时候先复制一份新数组,写完后用 volatile 数组引用替换旧数组。非常适合"读频率远高于写频率"的场景,比如动态配置项、路由表之类的数据。
但它的代价是:每次 add / remove 都全量复制数组,如果列表很大(比如几万条)且写频繁,性能会很糟糕。这个"写复制"逻辑是所有 COW 类容器的核心机制,理解它之后,你选型就不会只看名字了。
6. 一次完整的线上线程安全问题排查复盘
前面虽然讲了理论也给了方案,但我知道很多人更想看到一个"真实的排查链路"。这里我把之前遇到的完整排查过程还原出来,希望你下次遇到类似问题时能照着这个思路走一遍。
6.1 事故现象:日志里出现"不该出现"的数据
某天下午,我们一个数据同步服务开始异常:同一批消息被消费了两次,而且重复消息里有一个字段的值对不上,导致下游数据错乱。初步排查时,我怀疑是消费者组 rebalance 导致的重复消费,但奇怪的是,只有这个服务有这个现象,其他服务同样配置却没有。
6.2 排查第一步:看线程栈,找"卡住的线程"
我用 jstack 抓了一下线程快照,发现有一批线程停留在同一个位置上——在 ArrayList 的 get 方法上。再细看,这个 ArrayList 是服务内部维护的一个"最近处理消息 ID 列表",用来做去重。
当时我就警惕了。ArrayList 本身不是线程安全的,如果多个线程同时读写它,轻则数据错乱,重则抛 ConcurrentModificationException 或 IndexOutOfBoundsException。
6.3 排查第二步:看代码,确认共享可变对象
翻开代码一看,果然,这个 ArrayList 是一个 private static 字段,多个消费者线程直接往里面 add,另一个定时任务线程会遍历它做统计。没有任何锁,也没有用并发容器。
这其实是个典型的复合问题:写线程在 add 时,ArrayList 可能正在扩容(底层数组拷贝);读线程正在 get 时,可能读到一半的数组转态。两个问题叠加,就会出现"读到脏数据""数组越界"等随机现象。
6.4 排查第三步:复现测试,验证猜测
我在本地写了一个小 Demo,模拟 10 个线程并发 add,1 个线程持续遍历。跑了几百万次之后,果然出现了 IndexOutOfBoundsException 和元素丢失。这个复现过程很关键——它确认了问题根因,也避免了"怪罪到 MQ 或消费者组"的错误方向。
6.5 修复方案与最终验证
修复方案其实很简单:把 ArrayList 换成 CopyOnWriteArrayList。因为这个列表的读频率远高于写频率(遍历做统计每秒 100 次,写入每秒只 5 次),COW 容器的成本可以接受。
还有一个隐患是:add 之后的"去重检查"是 contains —— COW 容器的 contains 也是遍历,O(n) 性能,但量级不大时没问题。如果量大了,可以考虑用 ConcurrentHashMap.newKeySet() 来代替,它的 contains 是 O(1) 的。
改完代码后,我又跑了一轮压测,线程栈不再卡在 ArrayList 上,重复消费也消失了。
6.6 排查经验小结
这次事故虽然不复杂,但能说明几个关键点:
- 线程安全问题的表现往往不是"必现"的,而是偶发的、随机的、跟环境有关的,单靠"看起来没报错"是发现不了的;
- 排查时要先抓线程栈,再看代码里的共享可变对象,最后用复现 Demo 佐证,不要凭感觉去改;
- 线上偶发数据错乱,优先怀疑线程安全,而不是先怀疑中间件。
7. 日常编码中最容易埋雷的几个习惯
最后聊几个代码习惯。这些不是我编的,是我在 Code Review 里反复见到、在事故报告里反复看到的问题。如果你能把这些习惯改掉,线程安全问题的发生率能下降一大半。
7.1 习惯一:把 SimpleDateFormat 定义为 static 字段
SimpleDateFormat 的内部使用了一个 Calendar 实例,parse 和 format 操作都会修改它的状态。多个线程共享同一个实例,轻则时间解析错误,重则抛异常。前面提到过,可以用 ThreadLocal 或改用 DateTimeFormatter(它是不可变且线程安全的)。
7.2 习惯二:单例里的非线程安全成员变量
单例模式在任何多线程框架里都意味着"所有线程都走同一个实例",如果你在这个单例里放了 HashMap、ArrayList、可变集合,且某条路径会写这些集合,那基本就是定时炸弹。建议:
- 能用局部变量就用局部变量;
- 必须共享的变量,明确它的并发访问策略;
- 集合容器优先用并发容器。
7.3 习惯三:在 getter 里返回可变对象的引用
很多实体类有 List 或 Map 类型的字段,getter 直接返回内部引用。调用方拿到引用后可以随意修改,从而破坏对象的线程安全性。正确的做法是返回只读视图或副本:
java复制public List<String> getRoles() {
return Collections.unmodifiableList(roles);
}
7.4 习惯四:乐观地使用"原子类=安全"
AtomicInteger 能保证单次 incrementAndGet 原子性,但如果你的业务逻辑是"读一个值 -> 计算 -> 更新",即便每一步都是原子的,三步组合起来也不是原子的。该类操作还是需要加锁,或者使用 AtomicInteger 的 updateAndGet 这类方法。
7.5 习惯五:忽略弱一致性问题
并发容器大多提供的是"弱一致性"迭代器,比如 ConcurrentHashMap 的迭代器不会抛 ConcurrentModificationException,但它不保证能拿到迭代过程中新增的数据,也不保证返回的是最新快照。如果你的业务对一致性要求很高,要考虑加锁或使用全局快照,而不是依赖并发容器的迭代器。
8. 最后再分享一个不太起眼的小技巧
排查线程安全问题时,我经常用 -XX:+PrintConcurrentLocks 配合 jstack 查看 JVM 内的锁信息,这个参数在 JDK 8 及以后版本都支持。它能显示线程当前持有的锁、等待的锁,定位死锁特别快。很多同学不到线上事故不会去看 jstack,但我觉得在压测时就顺手抓一份线程快照,能发现很多静态检查发现不了的问题。
还有一个小技巧是:在代码里写注释,标明每个共享变量的并发访问策略。比如:
java复制// guarded by this
private final Map<String, String> cache = new HashMap<>();
这行注释在团队协作里价值巨大。后来者改代码时看到"guarded by this",就知道访问这个 map 时必须持有锁,不会随便加一个不加锁的读方法。
线程安全并不是高深莫测的玄学,它更像是一门需要时刻保持敬畏的工程纪律。你不需要把所有并发方案都背下来,但必须能判断出"这里存在共享可变状态,并且有可能被并发访问"。有了这个意识,再配合锁、CAS、不可变对象、并发容器这几个工具箱,大部分线程安全问题都能在设计和实现阶段被消灭,而不是等到线上事故爆发的那一天。
