线程安全
原子性(Atomicity)、可见性(Visibility)、有序性(Ordering) 是保证线程安全的三大核心要素 —— 线程安全问题的本质,几乎都是这三个特性中的一个或多个被破坏导致的。
- 操作不会被 “中途打断”(原子性);
- 操作结果能被其他线程 “及时看见”(可见性);
- 操作顺序符合 “语义逻辑”(有序性)。
可见性:
可见性问题指:一个线程对共享变量的修改,其他线程可能无法立即看到,甚至永远看不到。
为什么影响线程安全?
若可见性被破坏,线程会基于 “旧值” 做决策或修改,导致逻辑错误。
现代 CPU 为提升效率,引入了多级缓存(L1、L2、L3),线程的 “工作内存” 本质是 CPU 缓存的抽象。当线程修改变量时:
- 步骤 1:修改 CPU 缓存中的副本(工作内存)。
- 步骤 2:CPU 会在 “合适的时机”(而非立即)将缓存中的新值刷新到主内存(如缓存满了、发生缓存一致性协议触发时)。
这就导致:若线程 A 刚修改了变量但未刷新到主内存,线程 B 从主内存读取的仍是旧值,出现可见性问题。
可见性问题的产生:
当线程 A 修改了共享变量 A
时,会先更新自己的工作内存,再异步刷新到主内存(这个过程有延迟)。若此时线程 B 读取变量 A
,可能直接从自己的工作内存中获取未被更新的老值(因为线程 A 的修改还未同步到主内存,或线程 B 未从主内存重新加载),导致两个线程看到的变量值不一致。
// 共享变量
private static boolean flag = false;// 线程1:修改flag
new Thread(() -> {flag = true; // 修改工作内存中的flag,尚未同步到主内存System.out.println("线程1已修改flag为true");
}).start();// 线程2:读取flag
new Thread(() -> {while (!flag) { // 可能一直读取自己工作内存中的老值(false),陷入死循环// 等待flag变为true}System.out.println("线程2检测到flag为true");
}).start();
线程 2 可能永远看不到线程 1 对 flag
的修改,因为 flag
的更新未及时同步到主内存,或线程 2 未重新从主内存加载。
volatile解决:
volatile
保证可见性的核心逻辑是:强制线程对变量的读写操作直接与主内存交互,跳过工作内存(CPU 缓存)的缓存优化,具体通过以下两步实现:
写操作时:立即刷新到主内存,并使其他线程的缓存失效
当线程修改一个volatile
变量时,JVM 会触发两个动作:- 强制将工作内存中该变量的新值立即刷新到主内存(不等待 CPU 缓存的 “合适时机”)。
- 通过 CPU 的缓存一致性协议(如 MESI 协议),通知其他线程中该变量的缓存副本失效(其他线程再读取时必须从主内存重新加载)。
类比:
volatile
变量的写操作相当于 “写完立即把笔记本内容抄回公共白板,并擦掉其他人笔记本上的旧内容”。读操作时:必须从主内存重新加载
当线程读取volatile
变量时,JVM 会强制线程放弃工作内存中的缓存副本,直接从主内存加载最新值。类比:
volatile
变量的读操作相当于 “每次看内容前,都先扔掉自己的笔记本,重新从公共白板抄最新内容”。
private static volatile boolean flag = false; // 用volatile修饰// 线程1修改后,会立即刷新到主内存,并使线程2的缓存失效
// 线程2读取时,会从主内存重新加载,感知到flag的最新值
synchronized解决
核心机制是加锁和解锁时的内存同步操作
加锁时(进入同步块):
线程会清空自己的工作内存,并从主内存重新加载共享变量的最新值到工作内存。
(类比:线程进入同步块前,先把自己的 “笔记本” 清空,重新从 “公共白板” 抄最新内容。)解锁时(退出同步块):
线程会将工作内存中修改后的共享变量值强制刷新到主内存。
(类比:线程退出同步块时,必须把 “笔记本” 的修改立即抄回 “公共白板”。)happens-before 原则:
对同一个锁,解锁操作 happens-before 后续的加锁操作。即:前一个线程解锁时刷新到主内存的变量值,后一个线程加锁时必然能从主内存读到这个最新值。
// 共享变量(无volatile)
private static boolean flag = false;public static void main(String[] args) {// 线程1:修改flagnew Thread(() -> {synchronized (Test.class) { // 加锁:从主内存加载flag(初始false)flag = true; // 修改工作内存中的flag} // 解锁:将flag=true刷新到主内存}).start();// 线程2:读取flagnew Thread(() -> {while (true) {synchronized (Test.class) { // 加锁:从主内存加载flag的最新值if (flag) {System.out.println("线程2读取到flag=true");break;}} // 解锁:无修改,不影响}}).start();
}
- 线程 1 解锁时,
flag=true
被强制刷新到主内存。 - 线程 2 每次加锁时,都会从主内存重新加载
flag
,因此必然能感知到flag
的修改,最终退出循环。
有序性
有序性指程序执行顺序符合代码的 “语义逻辑顺序”,避免编译器 / CPU 的指令重排序破坏线程间的依赖关系。
为什么影响线程安全?
重排序可能打破线程间的 “操作先后依赖”,导致基于顺序的逻辑判断失效。
// 共享变量
private static int a = 0;
private static boolean flag = false;// 线程1:先初始化a,再标记flag
new Thread(() -> {a = 1; // 操作1:初始化aflag = true; // 操作2:标记a已初始化
}).start();// 线程2:基于flag判断a是否可用
new Thread(() -> {if (flag) { // 若flag=true,认为a已初始化System.out.println(a); // 可能输出0(因重排序)}
}).start();
若线程 1 的操作 1 和操作 2 被重排序(先执行 flag=true
,再执行 a=1
),线程 2 会在 a
未初始化时读取,输出 0(不符合预期),破坏线程安全。
volatile解决有序性:
volatile
通过在变量的读写操作前后插入内存屏障(Memory Barrier) 来禁止特定类型的重排序,从而保证有序性。内存屏障是一种特殊的指令,它会阻止编译器和 CPU 对屏障两侧的指令进行重排序。
volatile 内存屏障的具体规则
操作类型 | 内存屏障插入位置 | 作用 |
---|---|---|
写操作(v = x) | 写操作前插入 StoreStore 屏障 | 禁止当前写操作与之前的其他写操作重排序(确保之前的写操作先于当前写操作执行)。 |
写操作后插入 StoreLoad 屏障 | 禁止当前写操作与之后的读 / 写操作重排序(确保当前写操作完成后,再执行后续操作)。 | |
读操作(x = v) | 读操作前插入 LoadLoad 屏障 | 禁止当前读操作与之前的其他读操作重排序(确保之前的读操作先于当前读操作执行)。 |
读操作后插入 LoadStore 屏障 | 禁止当前读操作与之后的写操作重排序(确保当前读操作完成后,再执行后续写操作)。 |
这些屏障的核心作用是 “隔离屏障两侧的指令”,确保 volatile
变量的读写操作不会与其他指令 “交叉执行”。
synchronized解决有序性:
synchronized
通过 “happens-before 原则” 和 “同步块的边界约束” 保证有序性。其核心逻辑是:同步块内的操作会被视为一个 “不可分割的整体”,不会与同步块外的操作重排序,且后续线程进入同步块时,能看到之前同步块内的所有操作结果。
同步块内的操作不会被重排序到块外
JMM 规定:编译器和 CPU 不得将同步块内的指令重排序到同步块外部(无论是进入块前还是退出块后)。例如:synchronized (lock) { // 加锁a = 1; // 同步块内操作1flag = true; // 同步块内操作2 } // 解锁
编译器和 CPU 不能将
a=1
或flag=true
重排序到synchronized
块外部,确保同步块内的操作顺序严格按代码执行。happens-before 关系保证跨线程可见性与顺序性
JMM 的 happens-before 原则规定:对同一个锁的解锁操作 happens-before 后续的加锁操作。即:- 线程 A 退出同步块(解锁)时,其在同步块内的所有操作(如
a=1
、flag=true
)都会被刷新到主内存。 - 线程 B 进入同步块(加锁)时,会从主内存加载所有变量的最新值,因此能看到线程 A 在同步块内的所有操作结果。
这种关系确保了:线程 A 的操作 “先行发生于” 线程 B 的操作,两者的执行顺序在逻辑上是有序的。
- 线程 A 退出同步块(解锁)时,其在同步块内的所有操作(如
原子性:
原子性指一个操作(或多个操作的组合)要么全部执行,要么全部不执行,不会被其他线程 “打断”,中间状态不会被暴露
典型案例:非原子操作的风险
以 count++
为例,这是一个看似简单的操作,但在底层会被拆分为 3 个步骤:
- 读取:从主内存读取
count
的当前值到线程的工作内存。 - 修改:在工作内存中对
count
加 1。 - 写入:将修改后的值刷新回主内存。
当两个线程同时执行 count++
时,可能出现以下交叉执行的情况:
- 线程 A 读取
count=0
→ 线程 B 读取count=0
(此时两者都在步骤 1)。 - 线程 A 加 1 后
count=1
→ 线程 B 加 1 后count=1
(步骤 2)。 - 线程 A 写入主内存
count=1
→ 线程 B 写入主内存count=1
(步骤 3)。
基于锁机制解决:
private int count = 0;// 用synchronized修饰方法,保证count++的原子性
public synchronized void increment() {count++; // 复合操作被synchronized保护,不会被其他线程中断
}
线程进入 synchronized
方法 / 块时必须获取锁,执行完成后释放锁。同一时间只有一个线程能持有锁,确保临界区内的操作不会被其他线程打断。
使用原子类:
CAS 机制(Atomic
系列类):通过硬件指令实现无锁原子操作,适合简单场景,性能更优。