Java的volatile

volatile是Java的轻量级同步机制,提供可见性和禁止指令重排序。它确保了变量对所有线程的可见性,但不保证原子性。volatile写操作可能比普通变量写操作慢,因为它需要插入内存屏障以防止乱序执行。在某些场景下,volatile的性能可能优于锁。然而,如果需要原子性操作,仍需使用锁或其他同步工具。volatile通过内存屏障来禁止指令重排序,保证特定的执行顺序。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

介绍 volatile

volatile 关键字可以说是 Java 虚拟机提供的最轻量级的同步机制,但是它并不容易被正确、完整地理解,以至于许多程序员都习惯去避免使用它,遇到需要处理多线程数据竞争问题的时候一律使用 synchronized 来进行同步。了解 volatile 变量的语义对理解多线程操作的其他特性很有意义。

在众多保障并发安全的工具中选用 volatile 的意义:它能让我们的代码比使用其他的同步工具更快吗?在某些情况下, volatile 的同步机制的性能确实要优于锁(使用 synchronized 关键字或 java.util.concurrent 包里面的锁),但是由于虚拟机对锁实行的许多消除和优化,使得我们很难确切地说 volatile 就会比 synchronized 快上多少。如果让 volatile 自己与自己比较,那可以确定一个原则:volatile 变量读操作的性能消耗与普通变量读操作的性能消耗几乎没有什么差别,但是volatile 变量的写操作则可能会比普通变量的写操作慢上一些,因为 volatile 变量的写操作需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过即便如此,大多数场景下 volatile 的总开销仍然要比锁来得更低。我们在 volatile 与锁中选择的唯一判断依据仅仅是 volatile 的语义能否满足使用场景的需求。

volatile 的语义

Java 内存模型为 volatile 专门定义了一些特殊的访问规则。当一个变量被 volatile 修饰时,这个变量将具备两项特性:

  • **第一项特性是:保证此变量对所有线程的可见性,而普通变量则不能保证这一点。**这里的“可见性”指的是:一个线程修改了共享变量的值,其他的线程能够立即得知这个修改。Java 内存模型通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性。当一个变量被 volatile 修饰,线程在读取和写入这个变量的值时,在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值。
  • 第二项特性是:volatile 关键字禁止指令重排序。“volatile 变量的赋值操作” 之前的指令无法重排序到 “赋值操作” 之后的位置,“volatile 变量的赋值操作” 之后的指令无法重排序到 “赋值操作” 之前的位置。volatile 变量的赋值操作在本地代码中插入 lock 前缀的指令(内存屏障指令,重排序时不能把内存屏障后面的指令重排序到内存屏障之前的位置),lock 前缀的指令的作用是将本处理器的缓存写入内存,lock 前缀的指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了 “指令重排序无法越过内存屏障” 的效果。普通的变量仅会保证在方法的执行过程中,所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

volatile 变量只能保证可见性,因此在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用 synchronized、java.util.concurrent 中的锁或原子类)来保证原子性:

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束。

指令重排序

指令重排序是指处理器为了提高指令的执行效率,对指令序列进行重新排序的一种优化技术。但并不是说指令任意重排,处理器必须能正确处理指令依赖情况,保障程序能得出正确的执行结果。譬如指令 1 把地址 A 中的值加 10,指令 2 把地址 A 中的值乘以 2,指令 3 把地址 B 中的值减去 3,这时指令 1 和指令 2 是有依赖的,它们之间的顺序不能重排,(A+10) * 2 与 A * 2+10 显然不相等,但指令 3 可以重排到指令 1、2 之前或者中间,只要保证处理器执行后面依赖到 A、 B 值的操作时能获取正确的 A 和 B 值即可。所以在同一个处理器中,重排序过的代码看起来依然是有序的。

例子

通过一个例子来看看为何指令重排序会干扰程序的并发执行。演示程序如代码清单12-4所示。代码清单12-4中所示的程序是一段伪代码,其中描述的场景是开发中常见的配置读取过程,只是我们在处理配置文件时一般不会出现并发,所以没有察觉这会有问题。读者试想一下,如果定义 initialized 变量时没有使用 volatile 修饰,就可能会由于指令重排序的优化,导致位于线程 A 中最后一条代码 “initialized=true” 被提前执行(这里虽然使用 Java 作为伪代码,但所指的重排序优化是机器级的优化操作,提前执行是指这条语句对应的汇编代码被提前执行),这样在线程 B 中使用配置信息的代码就可能出现错误,而 volatile 关键字则可以避免此类情况的发生。

// 代码清单12-4 指令重排序

Map configOptions;
char[] configText;
// 此变量必须定义为volatile
volatile boolean initialized = false;

// 假设以下代码在线程 A 中执行
// 模拟读取配置信息, 当读取完成后
// 将initialized设置为true,通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

// 假设以下代码在线程 B 中执行
// 等待initialized为true,代表线程A已经把配置信息初始化完成
while (!initialized) {
    sleep();
}
// 使用线程A中初始化好的配置信息
doSomethingWithConfig();

内存屏障

再举一个可以实际操作运行的例子来分析 volatile 关键字是如何禁止指令重排序的。代码清单12-5所示是一段标准的双重检测(Double Check Lock,DCL)单例代码,可以观察加入 volatile 和未加入 volatile 关键字时所生成的汇编代码的差别。

// 代码清单12-5 DCL单例模式
public class Singleton {
    private volatile static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        Singleton.getInstance();
    }
}

编译后,这段代码对 instance 变量赋值的部分如代码清单12-6所示。

image-20230515174320144.png

通过对比发现,关键变化在于有 volatile 修饰的变量,赋值后(前面 mov %eax,0x150(%esi) 这句便是赋值操作)多执行了一个 “lock addl $0x0,(%esp)” 操作,这个操作的作用相当于一个内存屏障(Memory Barrier 或 Memory Fence,指重排序时不能把内存屏障后面的指令重排序到内存屏障之前的位置)。

  • 只有一个处理器访问内存时,并不需要内存屏障。因为在同一个处理器中,处理器能正确处理指令依赖情况,保障程序能得出正确的执行结果,重排序过的代码看起来依然是有序的;
  • 但如果有两个或更多处理器访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。

这句指令中的 “addl $0x0,(%esp)”(把 ESP 寄存器的值加 0)显然是一个空操作,之所以用这个空操作而不是空操作专用指令 nop,是因为 IA32 手册规定 lock 前缀不允许配合 nop 指令使用。这里的关键在于 lock 前缀,查询 IA32 手册可知,lock 前缀的作用是将本处理器的缓存写入了内存,该写入动作也会引起别的处理器或者别的内核无效化(Invalidate) 其缓存,这种操作相当于对缓存中的变量做了一次 “store 和 write” 操作。所以通过这样一个空操作,可让前面 volatile 变量的修改对其他处理器立即可见。


那为何说 volatile 禁止指令重排序呢?从硬件架构上讲,指令重排序是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理。但并不是说指令任意重排,处理器必须能正确处理指令依赖情况,保障程序能得出正确的执行结果。譬如指令 1 把地址 A 中的值加 10,指令 2 把地址 A 中的值乘以 2,指令 3 把地址 B 中的值减去 3,这时指令 1 和指令 2 是有依赖的,它们之间的顺序不能重排,(A+10) * 2 与 A * 2+10 显然不相等,但指令 3 可以重排到指令 1、2 之前或者中间,只要保证处理器执行后面依赖到 A、 B 值的操作时能获取正确的 A 和 B 值即可。所以在同一个处理器中,重排序过的代码看起来依然是有序的。因此,lock addl $0x0,(%esp) 指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了 “指令重排序无法越过内存屏障” 的效果。

Doug Lea 列出了各种处理器架构下的内存屏障指令:http://gee.cs.oswego.edu/dl/jmm/cookbook.html。

参考资料

《深入理解Java虚拟机》第 12 章 Java 内存模型与线程 12.3.3 对于 volatile 型变量的特殊规则

### Java 中 `volatile` 关键字的用法与行为 在 Java 编程语言中,`volatile` 是一种用于修饰字段的关键字。它确保了被修饰字段的可见性和有序性,但不提供原子性[^1]。具体来说,当一个字段被声明为 `volatile` 后,线程对该字段的写操作会立即刷新到主内存中,而其他线程对该字段的读操作也会直接从主内存中加载最新值,而不是依赖于本地缓存。 #### 主要特性 - **可见性**:一旦一个线程修改了一个 `volatile` 字段的值,新值将立刻对所有其他线程可见。 - **禁止指令重排序**:为了提升性能,JVM 和 CPU 都允许一定程度上的指令重排。然而,对于 `volatile` 字段而言,任何对其的读/写操作都不会与其他相关联的操作发生重排序[^2]。 下面是一个简单的例子展示如何使用 `volatile` 来避免线程间的可见性问题: ```java public class VolatileExample { private static volatile boolean flag = true; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { int i = 0; while (flag) { i++; } System.out.println("Loop terminated."); }); t1.start(); // Simulate some delay before stopping the loop Thread.sleep(1000); flag = false; // This change must be visible to thread t1 immediately due to 'volatile' System.out.println("Flag set to false."); } } ``` 在这个例子中,如果没有把 `flag` 定义为 `volatile` 类型的话,那么运行在线程 `t1` 上面的那个循环有可能永远不会结束,因为 JVM 可能会选择把这个布尔变量保存在一个寄存器里面,并且不再次去检查堆栈里的原始值是否有变化[^2]。 --- #### 局限性 虽然 `volatile` 提供了一种轻量级的方式来保证共享数据之间的可见性,但它并不能替代锁机制(如 synchronized)。特别是涉及到复合赋值语句时(比如自增运算 ++i),即使单个操作本身可能是原子性的,但由于整个表达式的完成需要多次独立的动作组合而成,因此仍然可能出现竞态条件[^1]。 例如以下代码片段就存在问题: ```java private volatile int count = 0; public void increment() { count++; // Not atomic operation despite being declared as volatile } ``` 这里调用方法 `increment()` 不具备原子性质,因为它实际上包含了三个步骤:读取当前值、增加数值以及存储回原位置。如果两个线程几乎同时执行这段代码,则可能导致最终的结果小于预期次数总和[^1]。 --- ### 总结 综上所述,在适当的情况下合理运用 `volatile` 能够有效减少不必要的同步开销并简化程序设计过程。但是也要清楚认识到它的局限所在,尤其是在面对复杂业务逻辑的时候更应当谨慎对待是否采用这种方式来进行并发控制[^2]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

真正的飞鱼

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值