4. 线程安全问题
我记得刚带团队那会儿,有个同事写了个计数器模块。单测全过,压测也没问题。结果上线第二天,用户反馈数据对不上——明明只下了3单,系统却记了5次。排查了一整天,最后发现是多个线程同时修改同一个变量导致的。嗯,这就是典型的线程安全问题。
说白了,线程安全就是:多个线程同时访问共享数据时,程序还能正确运行。今天我们就来拆解这个问题背后的三个核心概念:原子性、可见性、有序性。理解了它们,你就能看懂JMM(Java内存模型)到底在干什么。
核心定义:当多个线程访问某个类时,不管运行时环境采用何种调度方式,这个类始终都能表现出正确的行为,那么这个类就是线程安全的。
原子性问题:你以为的"一步"其实是三步
先看个最简单的例子:count++。你觉得这是一条语句,对吧?但在CPU眼里,它分三步走:
- 从内存读取 count 的值到寄存器
- 在寄存器里执行 +1 操作
- 把结果写回内存
两个线程同时执行 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眼里其实是三步:
- 分配内存空间
- 初始化对象
- 将引用指向内存地址
如果步骤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 规则,定义了哪些操作不能重排
知识体系总览
下面这张图,把线程安全的三个核心问题串起来了。我建议你多看几遍,理解它们之间的关联:
这张图把整个知识体系串起来了。你看,三个核心问题各自有对应的解决方案,而这些方案最终都依赖于JMM这个底层规范。理解了这张图,你就掌握了多线程编程的"内功心法"。
一句话总结:线程安全 = 原子性 + 可见性 + 有序性。JMM就是保证这三者的"宪法"。写多线程代码时,时刻问自己三个问题——我的操作是原子的吗?其他线程能看见吗?指令重排会不会搞乱我的逻辑?
可可精品资料库