并发编程三大特性:原子性、可见性、有序性
并发编程的这三大特性,说白了就是多线程世界的「交通规则」。我做了十几年高并发系统,见过太多线上事故,追根溯源,十有八九都是这三个特性没处理好。今天咱们就掰开揉碎了聊聊,它们到底是什么,以及在实际项目中是怎么「搞事情」的。
核心结论:原子性是底线,可见性是基础,有序性是保障。三者缺一不可,否则你的并发代码就是一颗定时炸弹。
一、原子性:要么全做,要么全不做
原子性,就是操作不可分割。一个线程在执行时,其他线程不能插进来看到中间状态。嗯,这就像你去银行转账,要么钱扣了对方收到,要么钱没扣对方也没收到——绝不允许出现「钱扣了但对方没收到」这种中间态。
我在项目中遇到过最经典的原子性问题,就是 i++。你看着是一行代码,实际上它分三步:读取 i、计算 i+1、写回 i。两个线程同时执行,结果可能比预期少一次。说白了,这就是典型的「非原子操作」陷阱。
避坑指南:我曾经在一个支付系统中,用 count++ 来统计并发请求数。上线后数据总是对不上。排查了一整天,才发现是原子性问题。后来改成 AtomicInteger,问题立刻消失。
保证原子性的常用手段:
- synchronized:重量级,但可靠。适合代码块或方法级别。
- Lock / ReentrantLock:更灵活,支持超时、可中断。
- Atomic 类:基于 CAS(Compare-And-Swap),无锁并发,性能好。
- 数据库事务:跨进程的原子性保障。
// 错误示例:非原子操作
private int count = 0;
public void increment() {
count++; // 不是原子的!
}
// 正确示例:使用 AtomicInteger
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 原子操作
}
二、可见性:一个线程改了,另一个能看见吗?
可见性问题,说白了就是「你改了,但我不知道」。每个线程都有自己的工作内存(CPU 缓存),它修改了变量,可能不会立刻写回主内存。另一个线程读到的还是旧值。
你想想看,这有多坑?一个线程把标志位设成 true,另一个线程还在死循环里等着 false 呢。我见过一个线上事故,就是因为这个原因导致服务线程无法正常关闭,最后只能重启机器。
核心机制:volatile 关键字保证了可见性。它告诉 JVM,这个变量每次读取都必须从主内存拿,每次修改都必须立刻写回主内存。说白了,就是禁止线程缓存这个变量。
保证可见性的方式:
- volatile:轻量级,只保证可见性和有序性,不保证原子性。
- synchronized:进入同步块时刷新工作内存,退出时写回主内存。
- Lock:底层同样依赖内存屏障,保证可见性。
- final:构造器完成后,其他线程看到 final 字段一定是初始化后的值。
// 可见性问题示例
private static boolean running = true;
// 线程1
new Thread(() -> {
while (running) {
// 死循环,因为 running 可能被缓存
}
}).start();
// 线程2
running = false; // 线程1可能永远看不到这个修改
// 解决方案:加 volatile
private volatile static boolean running = true;
个人经验:我习惯在「状态标志位」和「一次性发布」的场景下优先使用 volatile。比如服务启动完成标志、配置更新标志。但注意,volatile 不能替代 synchronized,它解决不了复合操作的原子性问题。
三、有序性:代码顺序 ≠ 执行顺序
有序性,就是指令重排的问题。CPU 和编译器为了优化性能,可能会打乱代码的执行顺序。单线程下没问题,因为最终结果一致。但多线程下,这就出大事了。
我记得最经典的例子就是双重检查锁定(DCL)单例模式。没加 volatile 之前,instance = new Singleton() 这行代码可能被重排成:先分配内存、再赋值给引用、最后初始化对象。另一个线程拿到的是「半初始化」的对象,一调用就崩。
// 经典 DCL 单例 - 正确写法
public class Singleton {
private volatile static Singleton instance; // volatile 禁止重排
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 防止指令重排
}
}
}
return instance;
}
}
Java 内存模型(JMM)提供了 happens-before 规则,用来约束有序性:
| 规则 | 说明 |
|---|---|
| 程序次序规则 | 单线程内,代码顺序就是执行顺序 |
| volatile 规则 | 对 volatile 变量的写,happens-before 于后续的读 |
| 锁规则 | 解锁 happens-before 于加锁 |
| 传递性 | A happens-before B,B happens-before C,则 A happens-before C |
避坑指南:我曾经在重构一个缓存框架时,为了「性能优化」去掉了 volatile。结果线上出现随机性的空指针异常。查了三天,最后发现是对象发布时发生了指令重排。从那以后,我对 volatile 再也不敢掉以轻心。
四、三大特性在实际项目中的体现
说了这么多理论,咱们看看实际项目中怎么用。我挑几个典型场景:
- 计数器场景:用 AtomicLong 保证原子性,用 volatile 保证可见性。比如统计 QPS、请求总数。
- 状态标志位:用 volatile 修饰的 boolean 变量,控制线程的启停。轻量又高效。
- 发布对象:用 volatile 或 final 保证安全发布,防止其他线程看到「半成品」对象。
- 复合操作:比如「检查再更新」,必须用 synchronized 或 Lock 保证原子性。
- 缓存一致性:分布式缓存 + 本地缓存时,用 volatile 保证本地缓存标志位的可见性。
// 实际项目中的组合使用
public class RequestCounter {
private volatile boolean active = true; // 可见性
private AtomicLong count = new AtomicLong(0); // 原子性
public void process() {
if (!active) return;
count.incrementAndGet(); // 原子操作
// 处理请求...
}
public void shutdown() {
active = false; // volatile 保证其他线程立刻看到
}
public long getCount() {
return count.get();
}
}
总结一下:原子性解决「线程安全」的底线问题,可见性解决「数据一致」的通信问题,有序性解决「指令重排」的意外问题。三者相辅相成,缺一不可。你在写并发代码时,每写一行都要问自己:这个操作原子吗?这个变量可见吗?这个顺序可靠吗?
嗯,三大特性就聊到这儿。记住一句话:并发编程没有银弹,但理解这三个特性,你就掌握了最基本的武器。剩下的,就是在实战中不断踩坑、填坑、再踩坑——这就是成长的代价。