Java的volatile

介绍 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 型变量的特殊规则

posted @ 2023-05-31 13:06  真正的飞鱼  阅读(49)  评论(0编辑  收藏  举报