上周我们秒杀系统出了次线上事故,库存明明还剩十件,页面却一直提示“已售罄”。查了半天数据都对得上,最后用jstack抓线程堆栈才定位到问题:多个线程同时扣减库存时,stock--这个看似简单的操作,硬是把库存从 10 扣成了负数,超卖了。这个事故的根因,就是标题里这两个词——线程安全和线程不安全。
很多 Java 初学者看到“线程安全”四个字就头疼,觉得是面试八股文、理论名词。但说白了,它描述的就是一个非常现实的问题:多个线程同时访问同一份可变数据时,会不会把数据搞坏。这篇文章我就从一次真实事故讲起,把线程安全的核心原理、典型故障样例、Java 类库里的安全与不安全名单,以及面试里最常被追问的AtomicInteger连环问,一次性讲透。
1. 一场线上事故:库存超卖背后的“线程不安全”真相
1.1 事故现象与排查思路
先说事故现场。我们的秒杀服务用了 Redis 做库存预扣,但最终扣减走的是 MySQL,代码大概是这样的:
public void deductStock(Long productId, int count) { ProductStock stock = stockMapper.selectByProductId(productId); if (stock.getStock() >= count) { stock.setStock(stock.getStock() - count); stockMapper.updateById(stock); } }单看这段代码,逻辑没有任何问题:先查库存,库存够就扣减,不够就报“已售罄”。但一旦放到高并发环境下,问题就来了。假设库存是 5,同时来了 6 个请求:
- 线程 A 查出来库存是 5;
- 线程 B 也查出来库存是 5;
- A 扣减成 4,写回数据库;
- B 也按自己读到的 5 扣减成 4,写回数据库。
两个请求一共扣了两次,库存却只减了 1。如果并发再高一点,库存直接变成负数,超卖就发生了。
这种问题不是偶尔出现,而是并发量越高越明显。原因就在于这两个线程之间的操作互相干扰了,而我们的代码没有做任何保护措施。这就是线程不安全的典型表现。
1.2 一句话定义线程安全与不安全
那用一句人话讲,什么是线程安全?
线程安全:多个线程同时操作同一个共享数据,不需要额外同步,最终结果跟单线程依次执行的结果完全一致,这个类或方法是线程安全的。
线程不安全就是反过来:不加保护地并发访问共享数据,结果不可预期,可能出现值覆盖、漏更新、数据错乱,甚至程序卡死或异常崩溃。
这里面有三个关键词值得注意:多线程、共享数据、结果一致。
如果一个方法内部只有局部变量,不读不写任何共享字段,那它天然就是线程安全的,因为每个线程操作的是自己栈里的副本,互不干扰。如果类里的字段是final且初始化后不再变化,也是线程安全的,比如String、Integer。真正危险的是**“共享且可变”**的数据——这六个字,是绝大多数并发 Bug 的温床。
2. 揭开线程不安全的三层根源:原子性、可见性、有序性
2.1 先从 JMM 说起:每个线程都有自己的“小本本”
要把线程安全的原理讲明白,绕不开 Java 内存模型(JMM)。这个概念被很多教材讲得很玄,我用大白话拆一下。
JMM 规定:所有共享变量都存放在主内存里,每个线程运行时会有一块工作内存(可以理解成 CPU 缓存和寄存器的抽象)。线程读一个变量,要先从主内存复制到自己的工作内存,改完之后再写回主内存。
问题就出在这个“先复制、后写回”的机制上。线程 A 和线程 B 各自的工作内存里都有同一个变量的副本,A 改了副本,B 还在用自己那个旧副本,两边都不知会对方。等 A 写回主内存,B 又把自己的旧值写回去,A 的修改就被覆盖了。
长得像什么?就像办公室共用一块白板记录进度,每个人却都只抄了一份到自己笔记本上修改。A 改完舔了舔笔头觉得自己记好了,B 压根没抬头看白板,还在按自己的旧笔记做事。
2.2 原子性、可见性、有序性
围绕 JMM,线程不安全归根结底是三个性质被破坏了。
原子性:一个操作要么全部执行完,要么全部不执行,不能被其他线程打断。但 Java 里很多看起来“一步到位”的操作,底层其实是多步的。最典型的例子就是i++。它不是一条语句,而是三步:
- 从主内存读取
i到工作内存; - 在工作内存中把
i + 1; - 把新值写回主内存。
线程 A 执行到第 2 步时,线程 B 也把同一个i读走了,两边都加 1,最后写回去,值只增加了 1。这不是段子,是无数线上事故的本质。
可见性:一个线程修改了共享变量后,其他线程不能立刻看到这个修改。即使 A 已经写回主内存,B 的工作内存里缓存的可能还是旧值。volatile关键字就是专门解决这个问题的,它强制每次读取都从主内存拿,每次写入都立即刷回主内存。
有序性:编译器和 CPU 为了优化性能,会重排指令的执行顺序。在单线程下,重排不影响结果;但多线程下,一个线程看到另一个线程“乱序执行”的结果,就可能出问题。经典的懒加载单例双重检查锁,就吃过这个亏,后面我会专门讲。
2.3 三个性质不是独立存在的,要一起看
很多初学者会问一个问题:我用volatile修饰了变量,是不是就线程安全了?
不是,这里有个非常容易混淆的点。volatile只保证可见性和有序性,不保证原子性。它适合“一个线程写、多个线程读”的场景,比如开关标志位:
private volatile boolean running = true;但如果多个线程同时执行running = !running,或者执行count++,volatile救不了你。因为count++的三步操作仍然可能被打断,写回时照样会覆盖别人的修改。所以解决线程安全,通常需要同时考虑这三个性质,不能只盯一个。
3. 用代码“显微镜”看崩溃现场:五种典型并发故障
3.1 最经典的计数器实验
先上最常见的例子,一个线程不安全的计数器:
public class UnsafeCounter { private int count = 0; public void increment() { count++; } public int getCount() { return count; } public static void main(String[] args) throws InterruptedException { UnsafeCounter counter = new UnsafeCounter(); int threadCount = 10; int loopCount = 10000; ExecutorService pool = Executors.newFixedThreadPool(threadCount); CountDownLatch latch = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { pool.execute(() -> { for (int j = 0; j < loopCount; j++) { counter.increment(); } latch.countDown(); }); } latch.await(); pool.shutdown(); System.out.println("最终结果: " + counter.getCount()); // 理想情况下应该是 100000,实际运行通常小于这个数 } } }10 个线程各执行 1 万次自增,理想结果是 10 万。跑一次你会发现,结果往往是 9 万出头,每次还不一样。原因就是我上面说的:count++不是原子操作,多个线程的“读-改-写”互相覆盖。
这个实验是我给团队新人培训时的保留项目,配合 3.1 的原理讲一遍,比任何定义都直观。
3.2SimpleDateFormat隐性的时间炸弹
另一个高频故障,是SimpleDateFormat。很多人知道它线程不安全,但不知道为什么,也不清楚后果有多严重。
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); public static Date parseDate(String dateStr) throws ParseException { return sdf.parse(dateStr); }多线程同时调用parseDate(),轻则解析出错误日期,重则抛NumberFormatException或ArrayIndexOutOfBoundsException。原因是SimpleDateFormat内部维护了一个Calendar对象,parse过程会反复修改Calendar的字段,而多个线程共用同一个SimpleDateFormat实例,就相当于多个厨师共用同一口锅,炒着各自的菜,全串味了。
解决方案也很简单,三种任选:
- 每次调用时
new SimpleDateFormat(...),代价是频繁创建对象,性能有损耗; - 用
ThreadLocal<SimpleDateFormat>让每个线程持有一份独立副本; - 直接用 Java 8 的
DateTimeFormatter,它是线程安全的。
3.3 HashMap 在并发扩容时的“致命环形链表”
HashMap线程不安全这个说法,很多人都背过,但具体怎么个不安全法,值得细说。
JDK 1.7 及更早版本,HashMap在扩容(resize)时,会把旧桶里的链表元素用头插法迁移到新桶。并发场景下,两个线程同时触发扩容,都来迁移同一个链表,就可能把节点的next指针指成环形结构。之后只要在这个哈希桶上执行get,就会陷入死循环,CPU 直接飙到 100%,服务彻底卡死。
网上流传很广的那张“并发扩容死循环”的示意图,说的就是这个事。JDK 1.8 改成了尾插法,环形链表问题不再出现,但并发 put 仍然会丢数据,比如两个键哈希碰撞后一个覆盖另一个的更新,所以依然不安全。
3.4 双重检查锁单例的“半初始化对象”
最后说一个面试杀手锏级的问题:下面这段单例代码,到底安不安全?
public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }不安全。问题出在new Singleton()不是一步完成的。它背后有三件事:
- 分配一块内存;
- 在内存上初始化
Singleton对象,执行构造函数; - 把
instance引用指向这块内存。
如果 CPU 或 JIT 编译器把第 3 步重排到第 2 步之前,那么另一个线程在第一次判空之后、进入synchronized之前,可能会看到instance != null,于是直接返回这个引用。但这个引用指向的对象还没初始化完,内部字段还是默认值,调用方法就出各种诡异问题。
解决办法是在instance上加上volatile,禁止重排序:
private static volatile Singleton instance;这是我修过的最隐蔽的并发 Bug 之一,因为问题只在特定 JIT 优化和并发窗口下偶现,压测几十万次才能复现一次。
4. Java 类库里的“安全名单”与“雷区名单”
4.1 为什么有的类是“天生安全”
先给一份实用的清单,大家平时写代码可以直接对照。
线程安全的常用类:
String、包装类型(Integer、Long等)——不可变类,所有状态在构造后不可改;StringBuffer——方法用synchronized修饰;Hashtable——几乎所有方法同步;ConcurrentHashMap——分段锁/CAS + 细粒度同步;CopyOnWriteArrayList、CopyOnWriteArraySet——写时复制;Vector——方法同步(但用好的人越来越少);AtomicInteger、AtomicLong、AtomicReference等原子类——CAS 实现;java.util.concurrent包下的绝大部分并发容器。
线程不安全的常用类:
StringBuilder——StringBuffer的无同步兄弟;HashMap、LinkedHashMap、TreeMap;ArrayList、LinkedList、HashSet、LinkedHashSet、TreeSet;SimpleDateFormat;PriorityQueue;- 所有自己写的含“共享且可变字段”的自定义类。
4.2 面试喜欢问的 StringBuffer 和 StringBuilder 差异
很多面试官会问StringBuffer和StringBuilder的区别,标准答案是:StringBuffer的大部分方法都加了synchronized,是线程安全的;StringBuilder不做同步,是单线程下性能更高的选择。
但我想多说一句:加synchronized是有代价的,即使只有一个线程访问,也要走锁的获取和释放流程。所以日常单线程拼接字符串,用StringBuilder没问题;多线程共享同一个拼接对象时,再从StringBuffer、加锁或改用局部变量里选方案。
4.3 一张表看同名容器怎么选
| 场景 | 推荐容器 | 原因 |
|---|---|---|
| 单线程 HashMap | HashMap | 性能最好 |
| 多线程读多写少 | ConcurrentHashMap | 读不加锁,写分段处理 |
| 多线程读写都有 | ConcurrentHashMap或加锁的HashMap | 避免全局竞争 |
| 多线程遍历多 | CopyOnWriteArrayList | 遍历基于快照,无并发修改异常 |
| 需要线程安全但要求低 | Hashtable/Collections.synchronizedMap | 简单但全局锁,并发高时性能差 |
表格里最后一行的Collections.synchronizedMap是很容易被忽视的选项,它内部其实就是给每个方法加了synchronized。好处是简单,坏处是锁粒度太大,并发量上来后吞吐量很难看。
有一类“虚假的安全感”也要警惕:线程安全的容器,只保证单个操作安全。两个线程分别执行map.put(a, 1)和map.put(a, 2),不会坏;但如果你执行“先判断 key 是否存在,再 put”,这两个步骤中间被另一个线程插一脚,逻辑就错了。这时候要用容器提供的复合原子方法,比如putIfAbsent、computeIfAbsent。
5. 四把武器:synchronized、Lock、volatile、AtomicInteger 怎么选
5.1 synchronized:从“重量级”到“没那么重”
synchronized是 Java 并发里最经典的锁。它可以加在方法上,也可以加在代码块上,锁的对象可以是实例、Class 对象或任意对象。
public synchronized void safeIncrement() { count++; }早期 Java 版本里,synchronized依赖底层操作系统的互斥锁,重量级,性能不好。但 JDK 1.6 之后引入了锁升级机制:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。大部分场景下竞争不激烈时,锁开销已经很小了。
synchronized最大的优点是用起来简单,而且锁的释放由 JVM 保证,不会出现忘解锁的问题。缺点是锁的获取和释放不可控,比如没法设置等待超时时间,没法响应中断。
5.2 ReentrantLock:功能更全的进阶锁
ReentrantLock是java.util.concurrent.locks包下的显式锁。它和synchronized一样支持可重入,但多了几个能力:
- 可以设置公平锁,按请求顺序排队拿锁;
- 支持超时获取锁,
tryLock(timeout, unit)等不到就放弃,避免线程被无限阻塞; - 支持响应中断,某些场景下很关键;
- 可以关联多个
Condition,实现更精细的等待/通知机制。
private final ReentrantLock lock = new ReentrantLock(); public void safeIncrement() { lock.lock(); try { count++; } finally { lock.unlock(); } }注意lock()之后必须放在try里,unlock()放在finally里,否则中途抛异常锁就永远不释放了。这是用Lock最容易踩的坑。
5.3 volatile:别求它做做不到的事
volatile能保证可见性、禁止指令重排,但不能保证原子性。它适合的状态是:一个线程写、其他线程只读;或者多个线程写同一个独立的状态变量,这种表达式本身没有依赖关系。
比如:
private volatile boolean initialized = false;线程 A 完成初始化后设成true,线程 B 读到true就开始干活。这个场景用volatile非常合适,既轻量又有效。但你要是学它去保护count++,那就错了。
5.4 AtomicInteger:CAS 到底靠不靠谱
原子类用了一套叫CAS的机制,全称是 Compare And Swap。它做的事是:先看看当前值是不是期望的那个值,如果是,就替换成新值;如果不是,就说明别的线程改了,继续重试。这个比较和替换是一条 CPU 原子指令完成的。
AtomicInteger count = new AtomicInteger(0); public void safeIncrement() { count.incrementAndGet(); }AtomicInteger的性能在低竞争场景下比synchronized好,因为它不用阻塞线程,失败就原地重试。但在高竞争场景下会大量自旋,CPU 开销反而可能变大。这也是面试官可能追问的点:CAS 的自旋空转怎么解决?常见的思路有:使用LongAdder(分段累加,适合统计计数场景)、引入随机退避、或者干脆改成锁。
5.5 选型决策表
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 简单同步方法/代码块 | synchronized | 简单、可靠、锁自动释放 |
| 需要超时、中断、公平锁 | ReentrantLock | 功能完整可控制 |
| 一个写多个读的标志位 | volatile | 零锁开销、可见性最好 |
| 高并发计数器、序列号 | AtomicLong/LongAdder | 无锁 CAS,吞吐量高 |
| 复杂业务需要原子性地更新对象引用 | AtomicReference+ CAS | 避免大段同步代码 |
选型时别迷信某一种技术。核心原则是:锁越少越好,但该加锁时不能省。能用volatile解决的就别上锁,能用原子类解决的就别 blocking,真的需要复合操作场景才考虑锁。
6. 面试高频连环问:AtomicInteger 到底线程安全吗
6.1 单操作层面:确实是线程安全的
“AtomicInteger 线程安全吗”是面试里出现频率极高的一个问题,答案要先分清楚讨论层面。
从单个方法调用来看,AtomicInteger是线程安全的。incrementAndGet()、decrementAndGet()、addAndGet()、compareAndSet()这些方法内部都利用了 CAS 原子指令,不会被多个线程的中间状态干扰。10 个线程各执行 1 万次incrementAndGet(),结果一定是 10 万,不会像普通int count++那样丢数据。
6.2 复合操作层面:可能不安全
但面试官乐此不疲追问的是下一层:你用AtomicInteger写出来的业务流程,未必是线程安全的。
看个例子:
AtomicInteger balance = new AtomicInteger(100); // 判断余额足够后扣款 if (balance.get() >= 50) { balance.addAndGet(-50); }这里balance.get()和balance.addAndGet(-50)是两个独立操作。线程 A 先读到余额 100,线程 B 也读到余额 100,然后两边都执行扣款,余额变成 0。两个请求都认为“余额足够并成功扣款”,业务上只有 50 块余额却被消费了两次——这跟文章开头超卖事故的逻辑是完全一样的。
所以,正确的做法是用compareAndSet把“判断”和“更新”合并成一个原子操作:
while (true) { int current = balance.get(); if (current < 50) { return; // 余额不足 } if (balance.compareAndSet(current, current - 50)) { return; // 扣款成功 } // 说明值被其他线程改了,重试 }6.3 面试官真正想听什么
这个连环问的考察点不只是背 API,而是看你有没有真正理解原子性到底粒度在哪里。一句话总结:
单次 CAS 操作是原子的,但你基于多个操作拼出来的业务流程,如果没把“读-判断-写”合成一个原子步骤,照样会出问题。
聊到这里可以顺便提一句ABA问题:CAS 只关心“值是不是期望值”,如果一个值从 A 变成 B 又变回 A,CAS 会认为没变过。大部分业务场景无碍,但若要严格处理版本变更,可以用AtomicStampedReference加版本号。
7. 生产环境排查线程安全问题的实操清单
7.1 第一步:想办法稳定复现
线程安全问题最大的难点不是修,而是没办法稳定复现。并发 Bug 往往是概率性的,可能跑一万次错一次。我的做法是:
- 把并发数调大,比如用 1000 个线程同时触发;
- 把循环次数调大,尽可能提高碰撞概率;
- 加
Thread.sleep(0)或Thread.yield()扩大线程切换的窗口; - 压测时打上方差较明显的随机延迟,模拟真实场景。
如果压测几十万次都稳定复现了,恭喜,问题已经从“幽灵”变成了“实证”。
7.2 第二步:jstack 抓线程堆栈
线上出现线程安全相关的异常时,第一件事是保留现场,然后用jstack抓堆栈:
jstack -l <进程ID> > thread_dump.txt重点看这几个信号:
java.lang.Thread.State: BLOCKED大量线程阻塞在同一个锁上,说明锁竞争激烈或死锁;deadlock关键字,说明存在多个锁循环等待;RUNNABLE状态但 CPU 占用极高的线程,结合jstat或jmap看是不是 GC 压力或死循环。
7.3 第三步:从三个性质逐项审查
如果堆栈看不出明显异常,就回到代码本身,把可疑的共享字段逐个过一遍:
- 是不是
static或实例字段,有没有被多个线程读写? - 读写过程有没有原子性风险?是不是存在“读-改-写”三步?
- 有没有做可见性处理?字段有没有
volatile? - 有没有指令重排隐患?单例、懒加载、状态切换时特别容易踩。
一个小技巧:把设计给单线程用的一套代码,强制在脑内模拟成两个线程交替执行,逐行走一遍,很多问题自己就暴露了。
7.4 日常预防的几个习惯
- 尽量避免“共享可变状态”,多用局部变量、一次性对象、不可变对象;
- 用
ConcurrentHashMap代替需要加锁的HashMap; - 格式化时间用
DateTimeFormatter,别再用共享的SimpleDateFormat; - 计数器优先想原子类,而不是上手就
synchronized一个大方法; - 如果用了
ReentrantLock,务必要在finally里释放锁; - 代码评审时专门有一项:这个共享字段在多线程下安全吗?
结尾
这篇文章从一次超卖事故讲起,把线程安全和线程不安全这件事拆到了 JMM 层面,又落回了具体的类和工具选型。我个人这几年写并发代码最大的体会是:线程安全问题很少发生在教科书式的“高并发炫技代码”里,而是发生在看起来再简单不过的count++、SimpleDateFormat、HashMap和双重检查锁里。写的代码越“普通”,越要提醒自己检查共享可变状态。
最后再分享一个实战技巧:排查并发问题时,别一头扎进代码里死磕,先确认是不是真的并发场景。很多时候线上服务虽然多线程,但某个字段实际上只有单线程访问,那所谓“线程安全问题”可能只是表象。先加日志确认访问模式,再用压测复现,最后才动代码,排查效率会高很多。