
上周我们秒杀系统出了次线上事故库存明明还剩十件页面却一直提示“已售罄”。查了半天数据都对得上最后用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 也查出来库存是 5A 扣减成 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或者执行countvolatile救不了你。因为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(...)代价是频繁创建对象性能有损耗用ThreadLocalSimpleDateFormat让每个线程持有一份独立副本直接用 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、TreeMapArrayList、LinkedList、HashSet、LinkedHashSet、TreeSetSimpleDateFormatPriorityQueue所有自己写的含“共享且可变字段”的自定义类。4.2 面试喜欢问的 StringBuffer 和 StringBuilder 差异很多面试官会问StringBuffer和StringBuilder的区别标准答案是StringBuffer的大部分方法都加了synchronized是线程安全的StringBuilder不做同步是单线程下性能更高的选择。但我想多说一句加synchronized是有代价的即使只有一个线程访问也要走锁的获取和释放流程。所以日常单线程拼接字符串用StringBuilder没问题多线程共享同一个拼接对象时再从StringBuffer、加锁或改用局部变量里选方案。4.3 一张表看同名容器怎么选场景推荐容器原因单线程 HashMapHashMap性能最好多线程读多写少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 AtomicIntegerCAS 到底靠不靠谱原子类用了一套叫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 又变回 ACAS 会认为没变过。大部分业务场景无碍但若要严格处理版本变更可以用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和双重检查锁里。写的代码越“普通”越要提醒自己检查共享可变状态。最后再分享一个实战技巧排查并发问题时别一头扎进代码里死磕先确认是不是真的并发场景。很多时候线上服务虽然多线程但某个字段实际上只有单线程访问那所谓“线程安全问题”可能只是表象。先加日志确认访问模式再用压测复现最后才动代码排查效率会高很多。