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。
