线程安全
上一章我们学会了”开线程”。这一章要回答一个更尖锐的问题——多个线程一起跑,会出什么事?
如果你写过一个 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 指令——它等价于三步:
- 读
count的值到寄存器 - 加 1
- 写 回
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++;
// ↑
}
保护临界区的手段:
- 加锁(
synchronized、ReentrantLock)——同一时刻只让一个线程进。 - CAS(
AtomicInteger)——把”读改写”变成一条原子指令。 - 避免共享(
ThreadLocal、分离状态)——不共享就不需要保护。
1.3 竞态条件的两种典型形态
-
Check-Then-Act:先检查再执行,但检查和执行之间被打断。
if (map.containsKey(key)) { // 检查 // ← 别的线程在此期间删了 key map.get(key).process(); // NPE! } -
Read-Modify-Write:读出值、修改、写回——典型如
count++。
二、什么是线程安全
Brian Goetz 在《Java Concurrency in Practice》给出了经典定义:
线程安全:当多个线程访问某个类时,不管运行时环境采用何种调度方式或这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协调,这个类的行为仍然是正确的,那么称这个类是线程安全的。
简化成一句话:不管多少线程怎么抢,结果都对。
2.1 线程安全的几个层次
| 层次 | 描述 | 例子 |
|---|---|---|
| 不可变 | 创建后状态不可改,绝对安全 | String、Integer、LocalDate、BigDecimal |
| 绝对线程安全 | 任何调用都不需要外部同步 | ConcurrentHashMap、AtomicInteger |
| 条件线程安全 | 部分操作安全,部分需要外部同步 | Collections.synchronizedList 的迭代需外部同步 |
| 线程兼容 | 不是线程安全,但可外部同步使用 | ArrayList、HashMap |
| 线程对立 | 多线程下不能用 | Thread.stop(已废弃) |
2.2 常见误区
Vector和Hashtable是线程安全的,但很少用——它们只是把每个方法synchronized,迭代时仍可能抛ConcurrentModificationException。Collections.synchronizedXxx也不是绝对安全——迭代必须手动加锁。StringBuilder不是线程安全的——多线程下用StringBuffer(但性能差)或更好的方案:用ThreadLocal<StringBuilder>或不可变拼接。
三、不可变对象的线程安全性
不可变对象(Immutable Object)是最简单的线程安全方案——它根本不能被修改,所以不存在竞态。
3.1 不可变的条件
一个对象要严格不可变,必须满足:
- 所有字段都是
final(JMM 对 final 有特殊保证,第 37 章会讲)。 - 类用
final修饰(防止子类破坏不可变性)。 - 所有字段是引用类型时,被引用的对象也是不可变的(否则可能被间接修改)。
this引用不在构造期间逸出。- 构造完成后状态不再变化。
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 就是这种设计——substring、concat 都返回新 String,原 String 永远不变。所以 String 在多线程下绝对安全。
3.3 不可变的好处
- 天然线程安全——不需要任何同步。
- 可以自由共享——多个线程引用同一个对象,无需复制。
- 可以做 Map 的 key、Set 的元素——hashCode 不会变。
- 没有”无效中间状态”——所有状态都是构造完成后的最终态。
不可变是函数式编程的核心思想,也是现代 Java 推崇的方向(record、LocalDate、Optional 都是不可变的)。
四、并发三大特性:原子性、可见性、有序性
这是并发正确性的三大支柱。任何一个被破坏,程序就可能在多线程下出错。
4.1 原子性(Atomicity)
原子性:一个操作或一组操作,要么全部执行且不被打断,要么都不执行。
count++ 不是原子的——它是”读-改-写”三步。在 Java 里:
- 基本类型赋值是原子的(除了
long和double在 32 位 JVM 上不是,但现代 64 位 JVM 上一般也是原子的)。 volatile long/double保证原子读写——这是volatile的一个用法。++/--不是原子的——即使是int。- 引用赋值是原子的——
Object obj = new Object();的赋值是原子的(但对象的构造过程不是)。
保证原子性的手段:synchronized、Lock、原子类(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,于是死循环。给 stop 加 volatile 就能解决。
保证可见性的手段:synchronized、volatile、final(构造期间的写入对其他线程可见)。
4.3 有序性(Ordering)
有序性:程序执行的顺序符合预期。
这里有个反直觉的事——编译器和 CPU 可能重排指令以提升性能。比如:
int a = 1;
int b = 2;
int c = a + b;
a=1 和 b=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 的原因。
保证有序性的手段:synchronized、volatile(禁止重排)、happens-before 关系(第 37 章详讲)。
4.4 三大特性的对比
| 特性 | 含义 | 破坏后果 | 保证手段 |
|---|---|---|---|
| 原子性 | 操作不被打断 | 算错(如 count 丢失) | synchronized、原子类、Lock |
| 可见性 | 修改被其他线程看到 | 死循环、读到旧值 | synchronized、volatile、final |
| 有序性 | 执行顺序符合预期 | 初始化未完成被使用 | synchronized、volatile、happens-before |
synchronized 是”全能选手”——它能同时保证三大特性。但代价是性能开销大。volatile 只保证可见性和有序性,不保证原子性(第 38 章详解)。原子类靠 CAS 保证原子性,但可见性靠 volatile 字段(AtomicInteger.value 就是 volatile)。
五、实战:演示线程不安全的计数器
下面这个例子把”竞态条件”和”三大特性的修复”串起来——先用不安全的 count++ 演示问题,再用 synchronized、AtomicInteger、LongAdder 三种方案修复。
观察重点:
- 不安全计数器几乎肯定算错——每次运行结果可能不同,且小于期望值。
synchronized正确但最慢——锁开销大,高争用下退化明显。AtomicInteger正确且较快——CAS 无锁,但高争用下仍有重试。LongAdder在高并发下最快——分段累加,第 41 章详解。- 可见性实验:去掉
volatile后 worker 可能无法退出(不保证一定复现,取决于 JVM 优化);加上volatile后稳定退出。- 不可变对象
Point:translate返回新对象,原对象不变——天然线程安全。
六、本章小结
| 概念 | 核心要点 |
|---|---|
| 竞态条件 | 程序正确性依赖线程执行顺序,多线程下偶尔出错 |
| 临界区 | 访问共享资源的代码片段,必须互斥 |
| 线程安全 | 不管多少线程怎么抢,结果都对 |
| 不可变对象 | 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”。