volatile 关键字的工作机制
Author: ACatSmiling
Since: 2024-07-24
volatile 关键字
:是 Java 编程语言中的一个重要工具,用于控制变量在多线程环境中的可见性和有序性。
前置知识
指令重排序
指令重排序(Instruction Reordering)
:是现代处理器和编译器优化技术的一部分,旨在提高程序执行效率。通过改变指令的执行顺序,可以更好地利用处理器流水线和缓存,从而提升性能。然而,在多线程环境中,指令重排序可能引发线程安全问题,因为它可能改变程序的预期执行顺序。
指令重排序的类型:
编译器重排序(Compiler Reordering)
:编译器在生成机器代码时,可能会根据依赖关系和优化策略对代码指令进行重排序。处理器重排序(Processor Reordering)
:处理器在执行指令时,为了提高流水线效率和缓存利用率,可能会对指令进行重排序。处理器有专门的机制,如乱序执行(Out-of-Order Execution)和内存模型(Memory Model),来管理指令的执行顺序。
内存屏障
内存屏障(Memory Barrier)
:有时也称为内存栅栏(Memory Fence),是一种用于控制处理器和编译器在多线程环境中指令执行顺序的机制。内存屏障通过防止某些类型的指令重排序,确保特定的内存操作在程序中按照预期的顺序执行。
内存屏障的作用:
防止指令重排序
:内存屏障强制指令在特定的顺序执行,防止编译器或处理器对其进行重排序。确保内存可见性
:内存屏障确保某些内存操作的结果对其他线程立即可见。特别是在多处理器环境中,内存屏障可以强制处理器将缓存的数据刷新到主内存,或者从主内存重新读取数据。
内存屏障主要有以下几种类型:
Load Barrier(读屏障)
:确保屏障之前的所有读操作在屏障之后的读操作之前完成。Store Barrier(写屏障)
:确保屏障之前的所有写操作在屏障之后的写操作之前完成。Full Barrier(全屏障)
:同时具备读屏障和写屏障的功能,确保屏障之前的所有读写操作在屏障之后的读写操作之前完成。
在 Java 中,内存屏障主要通过 volatile 关键字和 synchronized 关键字得以应用:
- volatile 关键字
- 读 volatile 变量时,会插入读屏障:确保在屏障之后的所有读操作都能看到 volatile 变量的最新值。
- 写 volatile 变量时,会插入写屏障:确保在屏障之前的所有写操作都已经完成并对其他线程可见。
- synchronized 关键字:
- 进入 synchronized 块时,会插入读屏障:确保在进入临界区之前,所有的读操作都能看到最新的值。
- 退出 synchronized 块时,会插入写屏障:确保在退出临界区之前,所有的写操作都已经完成并对其他线程可见。
CPU 缓存一致性协议
CPU 缓存一致性协议(Cache Coherence Protocol)
:是用于确保在多处理器系统中,各个处理器的缓存内容保持一致的机制。当多个处理器共享同一块内存时,它们可能会在各自的缓存中存储该内存的副本。为了确保这些副本始终一致,缓存一致性协议被引入。
缓存一致性问题:在多处理器系统中,如果一个处理器修改了某个内存位置的值,而其他处理器缓存中仍然保留旧值,则会导致数据不一致的问题。这种情况在并发编程中非常常见,解决这一问题需要缓存一致性协议。
常见的缓存一致性协议
缓存一致性协议是多处理器系统中确保数据一致性的关键机制。通过定义缓存行的多种状态和相应的转换规则,缓存一致性协议有效地解决了数据不一致的问题。常见的协议包括 MSI、MESI、MOESI 和 MESIF,它们在不同的应用场景中各有优劣。
MSI 协议
:MSI 是最简单的一种缓存一致性协议,它的名字来源于三种缓存行状态:
- Modified(修改):缓存行的数据已被修改,且数据只在当前缓存中是最新的,主内存中的数据已过期。
- Shared(共享):缓存行的数据可能被多个缓存共享,且数据与主内存中的一致。
- Invalid(无效):缓存行的数据无效,需要从主内存或其他缓存获取最新数据。
MESI 协议
:MESI 协议是 MSI 协议的扩展,多了一种状态:
- Exclusive(独占):缓存行的数据是最新的,且只有当前缓存持有这个数据,主内存中的数据也是最新的。
MOESI 协议
:MOESI 协议是在 MESI 协议基础上再扩展了一种状态:
- Owner(拥有者):缓存行的数据是最新的,且数据可能被其他缓存共享,但当前缓存是数据的拥有者,负责向其他缓存提供数据。
MESIF 协议
:MESIF 协议是英特尔的一种缓存一致性协议,增加了一种状态:
- Forward(转发者):类似于 Shared 状态,但在多个缓存行都处于 Shared 状态时,Forward 状态的缓存行负责向其他请求者提供数据,减少了对主内存的访问。
缓存一致性协议的工作机制
缓存一致性协议通常采用以下两种机制来保证缓存的一致性:
总线嗅探(Bus Snooping)
:每个处理器的缓存控制器都会监听(嗅探)总线上其他处理器的读写操作,如果发现某个缓存行被修改,就会相应地更新或失效本地缓存中的数据。目录协议(Directory Protocol)
:维护一个全局目录,记录每个缓存行在哪些处理器的缓存中。处理器的读写操作需要查询和更新这个全局目录,以确保一致性。
缓存一致性协议的实现示例
以下是一个简化的 MESI 协议工作示例。
假设有两个处理器 P1 和 P2,它们都缓存了内存地址 X 的数据:
- 初始状态:P1 和 P2 的缓存行状态都为 Shared。
- P1 修改 X:P1 将缓存行状态修改为 Modified,同时通过总线嗅探通知 P2 失效其缓存行,P2 的缓存行状态变为 Invalid。
- P2 读取 X:P2 发现其缓存行已失效,从 P1 或主内存获取最新数据,P1 的缓存行状态变为 Owner,P2 的缓存行状态变为 Shared。
volatile 的作用
volatile 主要有两个作用:
保证可见性
:当一个线程修改了 volatile 变量的值,新值会被立即刷新到主内存中,其他线程读取该变量时会直接从主内存中读取最新的值。- 在没有 volatile 的情况下,一个线程对变量的修改可能不会立即被其他线程看到,因为每个线程可能会在自己的工作内存(缓存)中操作变量的副本。
- 使用 volatile 关键字时,任何对该变量的写操作都会立即刷新到主内存,并且任何读操作都会直接从主内存中读取。这样就确保了变量的最新值对所有线程可见。
禁止指令重排序
:对 volatile 变量的读写操作不会被编译器和处理器重排序,这保证了操作的有序性。- volatile 禁止了指令重排序优化。通常,编译器和处理器为了提高性能,可能会对指令进行重排序,但这种重排序会带来线程安全问题。
- 使用 volatile 后,编译器和处理器会在读写 volatile 变量时插入内存屏障(Memory Barrier),确保在内存屏障前的操作不会被重排序到屏障之后,反之亦然。
volatile 无法保证原子性。
volatile 的工作原理
- 内存屏障(Memory Barrier)
- 在写入 volatile 变量时,会插入写屏障(Store Barrier),确保在屏障之前的所有写操作都被刷新到主内存。
- 在读取 volatile 变量时,会插入读屏障(Load Barrier),确保在屏障之后的所有读操作都从主内存中读取最新的值。
- 三句话说明:
- volatile 写之前的操作,都禁止重排序到 volatile 之后。
- volatile 读之后的操作,都禁止重排序到 volatile 之前。
- volatile 写之后的 volatile 读,禁止重排序。
- CPU 缓存一致性协议:volatile 变量的写操作会触发缓存一致性协议,强制其他处理器的缓存行失效,从而确保所有处理器都能看到变量的最新值。
volatile 的应用场景
状态标志:适用于简单的状态标志或开关,例如一个布尔值,用于控制线程是否继续运行。
private volatile boolean running = true;
public void stop() {
running = false;
}
public void run() {
while (running) {
// 执行任务
}
}
双重检查锁定(Double-Checked Locking):用于实现线程安全的单例模式。
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
原文链接
https://github.com/ACatSmiling/zero-to-zero/blob/main/JavaLanguage/java-util-concurrent.md