线程安全问题:从一次线上事故说起

我记得刚带团队那会儿,有个同事写了个计数器模块。单测全过,压测也没问题。结果上线第二天,用户反馈数据对不上——明明只下了3单,系统却记了5次。排查了一整天,最后发现是多个线程同时修改同一个变量导致的。嗯,这就是典型的线程安全问题。

说白了,线程安全就是:多个线程同时访问共享数据时,程序还能正确运行。今天我们就来拆解这个问题背后的三个核心概念:原子性、可见性、有序性。理解了它们,你就能看懂JMM(Java内存模型)到底在干什么。

核心定义:当多个线程访问某个类时,不管运行时环境采用何种调度方式,这个类始终都能表现出正确的行为,那么这个类就是线程安全的。

原子性问题:你以为的"一步"其实是三步

先看个最简单的例子:count++。你觉得这是一条语句,对吧?但在CPU眼里,它分三步走:

  1. 从内存读取 count 的值到寄存器
  2. 在寄存器里执行 +1 操作
  3. 把结果写回内存

两个线程同时执行 count++,就可能出现这种情况:线程A读到count=5,线程B也读到count=5。然后A加1写回6,B加1也写回6。明明加了两次,结果只加了1。这就是原子性问题——操作被中途打断了。

我的经验:我在做支付系统时,账户扣款必须保证原子性。用 synchronized 或 AtomicInteger 都能解决。我个人习惯用 AtomicLong,性能更好,代码也更简洁。

Java里保证原子性的方式有:

  • 锁机制:synchronized、ReentrantLock
  • 原子类:AtomicInteger、AtomicLong、AtomicReference
  • CAS操作:Compare And Swap,无锁编程的基础
// 非线程安全
private int count = 0;
public void increment() {
    count++;  // 不是原子操作!
}

// 线程安全方案1:使用原子类
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
    count.incrementAndGet();  // 原子操作
}

// 线程安全方案2:使用synchronized
private int count = 0;
public synchronized void increment() {
    count++;
}

可见性问题:线程之间的"信息孤岛"

你想想看,每个线程都有自己的工作内存(CPU缓存)。线程A改了变量,线程B可能根本看不到。这就是可见性问题。

我曾经遇到过一个诡异的bug:一个标志位 boolean stop = false;,主线程把它改成 true,但工作线程就是停不下来。为什么?因为工作线程一直在自己的缓存里读 stop=false,根本不知道主线程已经改了。

避坑指南:我曾经在写消息队列消费者时,忘了给共享标志位加 volatile。结果线上跑了三天,消费者线程一直"看不见"停止信号,导致无法优雅关闭。排查了整整一个下午才找到原因。

解决可见性的方式:

  • volatile关键字:保证变量的修改能立即被其他线程看到
  • synchronized:进入同步块时会刷新工作内存
  • final关键字:不可变对象天然线程安全
// 错误示范:没有可见性保证
private boolean stop = false;
public void run() {
    while (!stop) {
        // 可能永远停不下来
    }
}

// 正确做法:使用volatile
private volatile boolean stop = false;
public void run() {
    while (!stop) {
        // 能正确感知stop的变化
    }
}

有序性问题:编译器偷偷帮你"重排"了

这个最坑。为了优化性能,编译器和CPU可能会调整指令的执行顺序。单线程下没问题,多线程下就出事了。

举个经典例子:双重检查锁定的单例模式。你可能会写成这样:

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() 这行代码,在CPU眼里其实是三步:

  1. 分配内存空间
  2. 初始化对象
  3. 将引用指向内存地址

如果步骤2和3被重排了,线程A先执行了3(引用指向了未初始化的内存),线程B进来发现 instance != null,直接拿去用——结果拿到一个半成品对象。这就是有序性问题。

解决方案:给 instance 加上 volatile 关键字。volatile 能禁止指令重排序,保证"先初始化再赋值"。

// 正确的双重检查锁定
private static volatile Singleton instance;  // volatile禁止重排序

public static Singleton getInstance() {
    if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
    }
    return instance;
}

JMM内存模型:理解这一切的"宪法"

JMM(Java Memory Model)说白了就是一套规则。它规定了:

  • 变量怎么在线程间共享
  • 什么时候一个线程的修改对另一个线程可见
  • 哪些重排序是允许的,哪些是不允许的

JMM的核心概念就两个:

概念 说明
主内存 所有线程共享的内存区域,存储所有变量
工作内存 每个线程私有的内存区域,存储主内存的副本

线程不能直接操作主内存。它得先把变量从主内存拷贝到自己的工作内存,改完了再写回去。这个"拷贝-修改-写回"的过程,就是原子性、可见性、有序性问题产生的根源。

我的理解:JMM就像交通规则。它不规定你具体怎么开车(那是CPU和编译器的事),但它规定了红灯停绿灯行、不能逆行这些基本准则。理解了JMM,你就能预判多线程程序可能出什么问题。

JMM提供的保证:

  • 原子性保证:通过 lock/unlock 操作(synchronized底层就是它)
  • 可见性保证:volatile 变量的读写直接操作主内存
  • 有序性保证:happens-before 规则,定义了哪些操作不能重排

知识体系总览

下面这张图,把线程安全的三个核心问题串起来了。我建议你多看几遍,理解它们之间的关联:

线程安全问题核心知识体系 线程安全问题 原子性问题 可见性问题 有序性问题 synchronized / Lock Atomic 原子类 volatile 关键字 happens-before 规则 JMM(Java内存模型)—— 底层规范 原子性 + 可见性 + 有序性 = 线程安全

这张图把整个知识体系串起来了。你看,三个核心问题各自有对应的解决方案,而这些方案最终都依赖于JMM这个底层规范。理解了这张图,你就掌握了多线程编程的"内功心法"。

一句话总结:线程安全 = 原子性 + 可见性 + 有序性。JMM就是保证这三者的"宪法"。写多线程代码时,时刻问自己三个问题——我的操作是原子的吗?其他线程能看见吗?指令重排会不会搞乱我的逻辑?

可可精品资料库