Java的volatile

简介: volatile 关键字可以说是 Java 虚拟机提供的最轻量级的同步机制,但是它并不容易被正确、完整地理解,以至于许多程序员都习惯去避免使用它,遇到需要处理多线程数据竞争问题的时候一律使用 synchronized 来进行同步。了解 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 型变量的特殊规则

相关文章
|
4月前
|
存储 缓存 Java
【高薪程序员必看】万字长文拆解Java并发编程!(5):深入理解JMM:Java内存模型的三大特性与volatile底层原理
JMM,Java Memory Model,Java内存模型,定义了主内存,工作内存,确保Java在不同平台上的正确运行主内存Main Memory:所有线程共享的内存区域,所有的变量都存储在主存中工作内存Working Memory:每个线程拥有自己的工作内存,用于保存变量的副本.线程执行过程中先将主内存中的变量读到工作内存中,对变量进行操作之后再将变量写入主内存,jvm概念说明主内存所有线程共享的内存区域,存储原始变量(堆内存中的对象实例和静态变量)工作内存。
155 0
|
6月前
|
设计模式 存储 SQL
【Java并发】【volatile】适合初学者体质的volatile
当你阅读dalao的框架源码的时候,你是否会见到这样一个关键字 - - - volatie,诶,你是否会好奇,为什么要加它?加了它有什么作用?
191 14
【Java并发】【volatile】适合初学者体质的volatile
|
6月前
|
存储 缓存 安全
【原理】【Java并发】【volatile】适合初学者体质的volatile原理
欢迎来到我的技术博客!我是一名热爱编程的开发者,梦想是写出高端的CRUD应用。2025年,我正在沉淀自己,博客更新速度也在加快。在这里,我会分享关于Java并发编程的深入理解,尤其是volatile关键字的底层原理。 本文将带你深入了解Java内存模型(JMM),解释volatile如何通过内存屏障和缓存一致性协议确保可见性和有序性,同时探讨其局限性及优化方案。欢迎订阅专栏《在2B工作中寻求并发是否搞错了什么》,一起探索并发编程的奥秘! 关注我,点赞、收藏、评论,跟上更新节奏,让我们共同进步!
311 8
【原理】【Java并发】【volatile】适合初学者体质的volatile原理
|
7月前
|
缓存 安全 Java
Volatile关键字与Java原子性的迷宫之旅
通过合理使用 `volatile`和原子操作,可以在提升程序性能的同时,确保程序的正确性和线程安全性。希望本文能帮助您更好地理解和应用这些并发编程中的关键概念。
174 21
|
9月前
|
存储 缓存 Java
Java 并发编程——volatile 关键字解析
本文介绍了Java线程中的`volatile`关键字及其与`synchronized`锁的区别。`volatile`保证了变量的可见性和一定的有序性,但不能保证原子性。它通过内存屏障实现,避免指令重排序,确保线程间数据一致。相比`synchronized`,`volatile`性能更优,适用于简单状态标记和某些特定场景,如单例模式中的双重检查锁定。文中还解释了Java内存模型的基本概念,包括主内存、工作内存及并发编程中的原子性、可见性和有序性。
253 5
Java 并发编程——volatile 关键字解析
|
存储 SQL 缓存
揭秘Java并发核心:深度剖析Java内存模型(JMM)与Volatile关键字的魔法底层,让你的多线程应用无懈可击
【8月更文挑战第4天】Java内存模型(JMM)是Java并发的核心,定义了多线程环境中变量的访问规则,确保原子性、可见性和有序性。JMM区分了主内存与工作内存,以提高性能但可能引入可见性问题。Volatile关键字确保变量的可见性和有序性,其作用于读写操作中插入内存屏障,避免缓存一致性问题。例如,在DCL单例模式中使用Volatile确保实例化过程的可见性。Volatile依赖内存屏障和缓存一致性协议,但不保证原子性,需与其他同步机制配合使用以构建安全的并发程序。
203 0
|
缓存 安全 算法
Java面试题:如何通过JVM参数调整GC行为以优化应用性能?如何使用synchronized和volatile关键字解决并发问题?如何使用ConcurrentHashMap实现线程安全的缓存?
Java面试题:如何通过JVM参数调整GC行为以优化应用性能?如何使用synchronized和volatile关键字解决并发问题?如何使用ConcurrentHashMap实现线程安全的缓存?
152 0
|
9月前
|
安全 Java Kotlin
Java多线程——synchronized、volatile 保障可见性
Java多线程中,`synchronized` 和 `volatile` 关键字用于保障可见性。`synchronized` 保证原子性、可见性和有序性,通过锁机制确保线程安全;`volatile` 仅保证可见性和有序性,不保证原子性。代码示例展示了如何使用 `synchronized` 和 `volatile` 解决主线程无法感知子线程修改共享变量的问题。总结:`volatile` 确保不同线程对共享变量操作的可见性,使一个线程修改后,其他线程能立即看到最新值。
209 7
|
9月前
|
缓存 安全 Java
Java volatile关键字:你真的懂了吗?
`volatile` 是 Java 中的轻量级同步机制,主要用于保证多线程环境下共享变量的可见性和防止指令重排。它确保一个线程对 `volatile` 变量的修改能立即被其他线程看到,但不能保证原子性。典型应用场景包括状态标记、双重检查锁定和安全发布对象等。`volatile` 适用于布尔型、字节型等简单类型及引用类型,不适用于 `long` 和 `double` 类型。与 `synchronized` 不同,`volatile` 不提供互斥性,因此在需要互斥的场景下不能替代 `synchronized`。
3154 3
|
缓存 Java 编译器
JAVA并发编程volatile核心原理
volatile是轻量级的并发解决方案,volatile修饰的变量,在多线程并发读写场景下,可以保证变量的可见性和有序性,具体是如何实现可见性和有序性。以及volatile缺点是什么?