线程安全

上一章我们学会了”开线程”。这一章要回答一个更尖锐的问题——多个线程一起跑,会出什么事?

如果你写过一个 count++,让 10 个线程各加 1000 次,最后期望 count == 10000,结果却看到 8734 或 9521——恭喜,你已经踩到了并发的第一个坑:竞态条件。这一章我们把”为什么多线程会算错”讲透,并给出”什么是线程安全”的精确定义。

一、竞态条件与临界区

1.1 一个会算错的程序

先看一段”看起来没问题”的代码:

class Counter {
    int count = 0;
    public void increment() {
        count++;   // 看似一行,其实不是原子的
    }
}

让 10 个线程各调 1000 次 increment(),最后 count 期望是 10000,但实际可能只有 8000+。为什么?

因为 count++ 不是一条 CPU 指令——它等价于三步:

  1. count 的值到寄存器
  2. 1
  3. count

这三步之间可能被打断。想象一下:

时刻  线程 A                      线程 B
T1    读 count = 5
T2                                读 count = 5
T3    加 1 → 6
T4                                加 1 → 6
T5    写 count = 6
T6                                写 count = 6     ← 两次自增,count 只涨了 1

这就是竞态条件(Race Condition)——程序的正确性取决于多个线程访问共享变量的执行顺序。在单线程里永远正确,在多线程里偶尔出 bug,而且这种 bug 难以复现、难以调试——你可能跑一万次才出现一次,但生产环境一秒就跑一万次。

1.2 临界区(Critical Section)

临界区 是指访问共享资源的代码片段——它必须互斥执行,否则就会出竞态。

public void increment() {
    // ↓ 这里是临界区
    count++;
    // ↑
}

保护临界区的手段:

  • 加锁synchronizedReentrantLock)——同一时刻只让一个线程进。
  • CASAtomicInteger)——把”读改写”变成一条原子指令。
  • 避免共享ThreadLocal、分离状态)——不共享就不需要保护。

1.3 竞态条件的两种典型形态

  1. Check-Then-Act:先检查再执行,但检查和执行之间被打断。

    if (map.containsKey(key)) {     // 检查
        // ← 别的线程在此期间删了 key
        map.get(key).process();     // NPE!
    }
    
  2. Read-Modify-Write:读出值、修改、写回——典型如 count++

二、什么是线程安全

Brian Goetz 在《Java Concurrency in Practice》给出了经典定义:

线程安全:当多个线程访问某个类时,不管运行时环境采用何种调度方式或这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协调,这个类的行为仍然是正确的,那么称这个类是线程安全的。

简化成一句话:不管多少线程怎么抢,结果都对

2.1 线程安全的几个层次

层次描述例子
不可变创建后状态不可改,绝对安全StringIntegerLocalDateBigDecimal
绝对线程安全任何调用都不需要外部同步ConcurrentHashMapAtomicInteger
条件线程安全部分操作安全,部分需要外部同步Collections.synchronizedList 的迭代需外部同步
线程兼容不是线程安全,但可外部同步使用ArrayListHashMap
线程对立多线程下不能用Thread.stop(已废弃)

2.2 常见误区

  • VectorHashtable 是线程安全的,但很少用——它们只是把每个方法 synchronized,迭代时仍可能抛 ConcurrentModificationException
  • Collections.synchronizedXxx 也不是绝对安全——迭代必须手动加锁。
  • StringBuilder 不是线程安全的——多线程下用 StringBuffer(但性能差)或更好的方案:用 ThreadLocal<StringBuilder> 或不可变拼接。

三、不可变对象的线程安全性

不可变对象(Immutable Object)是最简单的线程安全方案——它根本不能被修改,所以不存在竞态。

3.1 不可变的条件

一个对象要严格不可变,必须满足:

  1. 所有字段都是 final(JMM 对 final 有特殊保证,第 37 章会讲)。
  2. 类用 final 修饰(防止子类破坏不可变性)。
  3. 所有字段是引用类型时,被引用的对象也是不可变的(否则可能被间接修改)。
  4. this 引用不在构造期间逸出
  5. 构造完成后状态不再变化

3.2 经典不可变类

public final class Point {       // final 类
    private final int x;          // final 字段
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() { return x; }
    public int getY() { return y; }

    // "修改"操作返回新对象,不改原对象
    public Point translate(int dx, int dy) {
        return new Point(x + dx, y + dy);
    }
}

String 就是这种设计——substringconcat 都返回新 String,原 String 永远不变。所以 String 在多线程下绝对安全。

3.3 不可变的好处

  • 天然线程安全——不需要任何同步。
  • 可以自由共享——多个线程引用同一个对象,无需复制。
  • 可以做 Map 的 key、Set 的元素——hashCode 不会变。
  • 没有”无效中间状态”——所有状态都是构造完成后的最终态。

不可变是函数式编程的核心思想,也是现代 Java 推崇的方向(recordLocalDateOptional 都是不可变的)。

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

这是并发正确性的三大支柱。任何一个被破坏,程序就可能在多线程下出错。

4.1 原子性(Atomicity)

原子性:一个操作或一组操作,要么全部执行且不被打断,要么都不执行。

count++ 不是原子的——它是”读-改-写”三步。在 Java 里:

  • 基本类型赋值是原子的(除了 longdouble 在 32 位 JVM 上不是,但现代 64 位 JVM 上一般也是原子的)。
  • volatile long/double 保证原子读写——这是 volatile 的一个用法。
  • ++/-- 不是原子的——即使是 int
  • 引用赋值是原子的——Object obj = new Object(); 的赋值是原子的(但对象的构造过程不是)。

保证原子性的手段:synchronizedLock、原子类(AtomicInteger)、volatile(仅限单变量读写)。

4.2 可见性(Visibility)

可见性:一个线程对共享变量的修改,能被其他线程及时看到。

这听起来理所当然,但其实不一定是真的。每个 CPU 有自己的缓存(L1/L2/L3),JVM 也允许线程把变量读到工作内存里改——别的 CPU 上的线程可能看不到这次修改。

class FlagHolder {
    boolean stop = false;   // 没有 volatile

    // 线程 A
    void stopThread() { stop = true; }

    // 线程 B
    void worker() {
        while (!stop) {
            // 可能永远停不下来!
        }
    }
}

这是经典”可见性”问题——线程 A 改了 stop,但线程 B 在自己的 CPU 缓存里读到的还是 false,于是死循环。给 stopvolatile 就能解决。

保证可见性的手段:synchronizedvolatilefinal(构造期间的写入对其他线程可见)。

4.3 有序性(Ordering)

有序性:程序执行的顺序符合预期。

这里有个反直觉的事——编译器和 CPU 可能重排指令以提升性能。比如:

int a = 1;
int b = 2;
int c = a + b;

a=1b=2 之间没有依赖,CPU 可能让 b=2 先执行。单线程下这种重排不影响结果(as-if-serial 语义)。但多线程下,重排会破坏正确性

class InitExample {
    int value = 0;
    boolean ready = false;

    // 线程 A
    void init() {
        value = 42;          // (1)
        ready = true;        // (2)
    }

    // 线程 B
    void use() {
        if (ready) {         // (3)
            System.out.println(value);   // (4) 可能打印 0!
        }
    }
}

如果 (1) 和 (2) 被重排成 ready=true; value=42;,线程 B 看到 ready=true 时 value 可能还是 0。这就是臭名昭著的”双重检查锁单例”曾经出 bug 的原因。

保证有序性的手段:synchronizedvolatile(禁止重排)、happens-before 关系(第 37 章详讲)。

4.4 三大特性的对比

特性含义破坏后果保证手段
原子性操作不被打断算错(如 count 丢失)synchronized、原子类、Lock
可见性修改被其他线程看到死循环、读到旧值synchronizedvolatilefinal
有序性执行顺序符合预期初始化未完成被使用synchronizedvolatilehappens-before

synchronized 是”全能选手”——它能同时保证三大特性。但代价是性能开销大。volatile 只保证可见性和有序性,不保证原子性(第 38 章详解)。原子类靠 CAS 保证原子性,但可见性靠 volatile 字段(AtomicInteger.value 就是 volatile)。

五、实战:演示线程不安全的计数器

下面这个例子把”竞态条件”和”三大特性的修复”串起来——先用不安全的 count++ 演示问题,再用 synchronizedAtomicIntegerLongAdder 三种方案修复。

Java · 在线运行

观察重点

  • 不安全计数器几乎肯定算错——每次运行结果可能不同,且小于期望值。
  • synchronized 正确但最慢——锁开销大,高争用下退化明显。
  • AtomicInteger 正确且较快——CAS 无锁,但高争用下仍有重试。
  • LongAdder 在高并发下最快——分段累加,第 41 章详解。
  • 可见性实验:去掉 volatile 后 worker 可能无法退出(不保证一定复现,取决于 JVM 优化);加上 volatile 后稳定退出。
  • 不可变对象 Pointtranslate 返回新对象,原对象不变——天然线程安全。

六、本章小结

概念核心要点
竞态条件程序正确性依赖线程执行顺序,多线程下偶尔出错
临界区访问共享资源的代码片段,必须互斥
线程安全不管多少线程怎么抢,结果都对
不可变对象final 字段+final 类+不暴露可变状态,天然安全
原子性操作不被打断——count++ 不是原子的
可见性修改能被其他线程看到——CPU 缓存导致问题
有序性执行顺序符合预期——指令重排导致问题
synchronized同时保证三大特性,但慢
volatile保证可见性+有序性,不保证原子性
原子类CAS 保证原子性 + volatile 字段保证可见性

记忆口诀

  • count++ 不是原子的——它是”读-改-写”三步,多线程会丢更新。
  • 不可变就是免锁——能不可变就不可变,最简单的安全方案。
  • 三大特性:原子、可见、有序——synchronized 全包,volatile 只管可见和有序。
  • 不安全可复现的 bug 都是好 bug——最可怕的是百万分之一概率的 race condition。

结语:从”出问题”到”理解问题”

这一章我们看到了并发为什么会出错——竞态条件、可见性、有序性,三大问题的根源都在”共享可变状态”。我们也看到了几种解决思路:锁、CAS、不可变、分离状态。

但还有几个问题没回答:

  • 为什么 synchronized 能同时保证三大特性?它底层到底做了什么?
  • volatile 到底怎么”禁止指令重排”的?什么是内存屏障?
  • 为什么”final 字段”对其他线程一定可见?JMM 是怎么保证的?
  • 什么是 happens-before?为什么它能让多线程代码”可推理”?

这些问题的答案都在Java 内存模型(JMM)——下一章我们就来啃这块并发最难也最关键的理论基石。理解了 JMM,你才能从”知道用 volatile”升级到”知道为什么用 volatile”。