Java volatile 关键字详解
概述
volatile 是 Java 提供的一种轻量级的同步机制。相比于传统的 synchronize,虽然 volatile 能实现的同步性要差一些,但开销更低,因为它不会引起频繁的线程上下文切换和调度。
为了更好的理解 volatile 的作用,首先要了解一下 Java 内存模型与并发编程三要素
1. Java 内存模型
计算机执行程序时,每条指令都在 CPU 执行,执行过程中势必涉及数据的读取和写入。程序运行过程中的临时数据存放在主存(物理内存),这就存在一个问题,CPU 执行速度很快,从内存读取数据和向内存写入数据跟 CPU 执行指令的速度相比要慢的多,如果对数据的操作都要和内存交互,会降低指令执行的速度,因此在 CPU 就有了高速缓存。程序在运行过程中,会将需要的数据从主存复制一份到 CPU 的高速缓存,CPU 进行计算时就可以直接从高速缓存读取数据和写入数据,当运算结束后,再将高速缓存的数据刷新到主存
Java 虚拟机规范中定义了 Java 内存模型(Java Memory Model,JMM),用于屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的并发效果。JMM 规定所有的变量都存在于主存(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存进行,不能直接对主存操作。并且每个线程不能访问其他线程的工作内存,如果线程之间需要传递变量的值,必须通过主内存来作为中介进行传递
2. 并发编程三要素
在并发编程中,以下三要素是我们经常需要考虑的,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性,只要有一个没有被保证,就有可能导致程序运行不正确
2.1 原子性
原子是世界上最小的单位,具有不可分割性。同理,将一个操作或多个操作视为一个整体,它们是不可再分的,并且要么全部成功,要么全部失败,不可被中断,那么这个操作就具有原子性。
int a = 10; //1
a++; //2
int b = a; //3
a = a + 1; //4
上面这四个语句中只有第 1 个语句是原子操作,将 10 赋值给线程工作内存的变量 a,而语句2(a++),实际上包含了三步操作:
- 读取变量 a 的值
- 对 a 进行加一的操作
- 将计算后的值再赋值给变量 a,而这三个操作无法构成原子操作
对语句 3,4 的分析同理可得这两条语句不具备原子性
2.2 可见性
指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。举个简单的例子:
// 线程 1 执行的代码
int i = 0;
i = 10;
//线程 2 执行的代码
j = i;
之前在 Java 内存模型已经讲过,线程 1 执行 i = 10 时,会先把 i 的初始值加载到自己的工作内存,然后赋值为 10,却没有立即写入到主存当中。此时线程 2 执行 j = i,它会先去主存读取 i 的值并加载到自己的工作内存中,注意此时内存当中 i 的值还是 0,那么就会使得 j 的值为 0,而不是 10
这就是可见性问题,线程 1 对变量 i 修改了之后,线程 2 没有立即看到线程 1 修改的值
2.3 有序性
一般来说,程序的执行顺序按照代码的先后顺序执行,但处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的
有序性从不同的角度来看是不同的,单纯从单线程的角度来看,所有操作都是有序的,但到了多线程就不一样了。可以这么说:如果在本线程内部观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。
volatile 保证变量可见性
假如有 A、B 两个线程,主内存有变量 i = 0,A 线程将主内存中的 i 拷贝一份到自己的工作内存,并修改为 i = 1,但并没有立即写回到主内存,什么时候写回主存是不确定的。此时 B 线程也将主内存中的 i 拷贝一份到自己的工作内存,而主内存中的 i 还是 0,并不是预想中的 1,这就可能导致一些问题
volatile 的一个重要作用就是实现了变量可见性。当一个共享变量被 volatile 修饰,它会保证修改的值会立即更新到主存,当其他线程需要读取时,它会去内存中读取新值。
volatile 不保证原子性
假如有 A、B 两个线程,同时对初始值为 0 的变量 i 做加 1 操作,我们希望最终的结果是 i = 2,但有可能并非如此,假设:
- 线程 A 将共享内存 i = 0 拷贝到自己的工作内存,此时 A 的本地内存中 i = 1,但共享内存的 i 还是 0
- 线程 B 将共享内存 i = 0 拷贝到自己的工作内存,此时 B 的本地内存中 i = 1,但共享内存的 i 还是 0
- 线程 A 完成加 1 操作,此时 A 的本地内存中 i = 1,但共享内存的 i 还是 0,线程 A 将 i = 1 写回到内存
- 线程 B 完成加 1 操作,此时 B 的本地内存中 i = 1,共享内存的 i 已经是 1,线程 B 将 i = 1 写回到内存
- 最终共享内存中 i = 1,并不是我们预期的 i = 2
出现上述问题的原因是 i++ 并不是一个原子性的操作,Java 内存模型只保证了基本读取和赋值是原子性操作。不同线程之间的操作交互执行,可能会出现漏洞。所以使用 volatile 必须具备以下两个条件:
- 对变量的写操作不依赖于当前值
- 该变量没有包含在具有其他变量的不变式中
上述两个条件其实就是要保证操作是原子性的。如果希望实现更大范围操作的原子性,可以通过 synchronized 和 Lock 来实现。synchronized 和 Lock 能保证任一时刻只有一个线程执行该代码块,自然就不存在原子性问题
volatile 禁止指令重排序
所谓指令重排序,是指计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排。指令重排必须保证最终执行结果和代码顺序执行结果一致
public void mySort() {
int x = 11; // 1
int y = 12; // 2
x = x + 5; // 3
y = x * x; // 4
}
正常的执行顺序是 1、2、3、4,如果发生指令重排,就有可能会是 2、1、3、4,或者是 1、3、2、4 等等,但不会出现 4、3、2、1 这样的情况,因为处理器在进行重排时,必须考虑到指令之间的数据依赖性
在单线程下指令重排是没有问题的,但如果是多线程就不一定了,假设主存中有 a,b,x,y 四个变量(保证了可见性),初始值都是 0,有 A、B 两个线程,它们各自顺序执行时操作如下:
- 线程 A
- x = a
- b = 1
- 线程 B
- y = b
- a = 2
无论两个线程之间的操作如何交错,最终结果都是 x = 0,y = 0(不考虑线程 A 走完再到线程 B 的情况,因为这样就和单线程没有差异了)。可如果发生了指令重排,此时它们各自的操作执行顺序可能变为:
- 线程 A
- b = 1
- x = a
- 线程 B
- a = 2
- y = b
这样造成的结果就是 x = 2,y = 1,和上面的不一致了。因此为了防止这种情况,volatile 规定禁止指令重排,从而保证数据的一致性。
volatile 底层原理
观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入 volatile 关键字时,会多出一个 lock 前缀指令
lock 前缀指令实际上相当于一个内存屏障(内存栅栏),提供三个功能:
- 确保指令重排序时不会把后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面,即在执行到内存屏障这句指令时,在它前面的操作已经全部完成
- 强制将对缓存的修改操作立即写入主存,并导致其他 CPU 对应的缓存行无效,当其他 CPU 需要读取该变量,发现自己缓存该变量的缓存行是无效的,就会从主存重新读取
volatile 应用场景
synchronized 是防止多个线程同时执行一段代码,会影响程序执行效率,volatile 在某些情况下性能要优于synchronized,但要注意 volatile 是无法替代 synchronized 的,因为 volatile 无法保证操作的原子性
下面列举几个 Java 中使用 volatile 的场景
1. 状态标志
用于标记状态发生转换,以此于完成某些操作,如初始化和停机
volatile boolean flag = false;
while(!flag) {
doSomething();
}
public void setFlag() {
flag = true;
}
2. 双重检查锁定
有些时候,我们获取到一个实例引用,而这个引用对应的实例有可能是未完全初始化的,典型例子就是单例模式的双重校验锁
public class LazySingleton {
private volatile static LazySingleton instance = null;
private LazySingleton() {}
public static LazySingleton getInstance() {
if(instance == null) {
synchronized(LazySingleton.class) {
if(instance == null) {
instance = new LazySingleton();
}
}
}
return instance;
}
}
3. 独立观察
定期发布观察结果供程序内部使用,例如:环境传感器能够感觉环境温度,一个后台线程每隔几秒读取一次传感器,并更新 volatile 变量,然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值
4. 开销较低的读写锁策略
如果读操作远远超过写操作,可以使用 volatile 进行读操作,使用锁进行写操作,减少竞争锁的开销
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 一个奇形怪状的面试题:Bean中的CHM要不要加volatile?
· [.NET]调用本地 Deepseek 模型
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战