并发编程三大特性:原子性、可见性、有序性

并发编程的这三大特性,说白了就是多线程世界的「交通规则」。我做了十几年高并发系统,见过太多线上事故,追根溯源,十有八九都是这三个特性没处理好。今天咱们就掰开揉碎了聊聊,它们到底是什么,以及在实际项目中是怎么「搞事情」的。

核心结论:原子性是底线,可见性是基础,有序性是保障。三者缺一不可,否则你的并发代码就是一颗定时炸弹。

并发编程三大特性 原子性 不可分割的操作 synchronized Lock / ReentrantLock AtomicInteger / CAS 数据库事务 典型问题:i++ 非原子 可见性 线程间数据可见 volatile 关键字 synchronized (内存屏障) Lock (happens-before) final 字段 典型问题:死循环标志位 有序性 指令重排的约束 happens-before 规则 volatile 禁止重排 synchronized 块 final 语义 典型问题:DCL 单例 三者相互关联:原子性依赖可见性,可见性依赖有序性

一、原子性:要么全做,要么全不做

原子性,就是操作不可分割。一个线程在执行时,其他线程不能插进来看到中间状态。嗯,这就像你去银行转账,要么钱扣了对方收到,要么钱没扣对方也没收到——绝不允许出现「钱扣了但对方没收到」这种中间态。

我在项目中遇到过最经典的原子性问题,就是 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,这个变量每次读取都必须从主内存拿,每次修改都必须立刻写回主内存。说白了,就是禁止线程缓存这个变量。

保证可见性的方式:

  1. volatile:轻量级,只保证可见性和有序性,不保证原子性。
  2. synchronized:进入同步块时刷新工作内存,退出时写回主内存。
  3. Lock:底层同样依赖内存屏障,保证可见性。
  4. 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();
    }
}

总结一下:原子性解决「线程安全」的底线问题,可见性解决「数据一致」的通信问题,有序性解决「指令重排」的意外问题。三者相辅相成,缺一不可。你在写并发代码时,每写一行都要问自己:这个操作原子吗?这个变量可见吗?这个顺序可靠吗?

嗯,三大特性就聊到这儿。记住一句话:并发编程没有银弹,但理解这三个特性,你就掌握了最基本的武器。剩下的,就是在实战中不断踩坑、填坑、再踩坑——这就是成长的代价。

可可精品资料库