volatile 关键字
Java 提供了一种稍弱的同步机制(相比于 synchronized),即 volatile
变量,用来确保将变量的更新操作通知到其他线程。
在访问 volatile 变量时不会执行加锁操作,不会使执行线程阻塞,因此 volatile 变量是一种比 sychronized 关键字更轻量级的同步机制。
volatile 的作用
volatile
修饰的变量具有两层语义:
-
线程可见性。
对普通变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中,如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的CPU缓存中。
而对volatile
变量是进行读写时,JVM保证了每次读变量都从内存中读,跳过CPU缓存。写变量时会刷新到内存,新值对其他线程来说是立即可见的。 -
禁止指令重排序。 一般来说,处理器为了提高程序运行效率,会对输入代码进行优化,不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是会保证程序最终执行结果和代码顺序执行的结果是一致的。
这种重排序,对某些代码会有影响。JMM 具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。volatile
关键字具有此作用。
使用注意
volatile
变量不保证原子性。如自增操作,不可以随意使用 volatile
,可以使用 AtomicXXX 类(java.util.concurrent.atomic
)或锁来代替。
public class Example {
public static volatile int count = 0;
private static void add() {
count++;
}
}
如果并发执行上面的 add()
方法,count
的最终结果很可能不是期望值。
执行 conut++
时需要三个步骤:第一步是取出当前内存 count
值,这时 count
值是最新的,接下来两步操作,分别是 +1 和重新写回主存。假设有两个线程同时在执行 count++
,都执行了第一步,取到最新值(取到的值相同),然后分别执行了 +1,并写回主存,这样实际上只进行了一次 +1 操作。
volatile 实现方式
加入 volatile
关键字和没有加入 volatile
关键字时所生成的汇编代码发现,加入 volatile
关键字时,会多出一个 lock
前缀指令。(《深入理解Java虚拟机》)
lock
前缀相当于一个内存屏障,内存屏障会提供3个功能:
- 确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 强制将对缓存的修改操作立即写入主存;
- 如果是写操作,会导致其他CPU中对应的缓存行无效。
volatile 写
在对变量进行写操作时,会在写操作后加一条store指令,防止重排序,并刷新到内存:
| ...
| 普通读
| 普通写
| StoreStore屏障 -> 禁止上面的普通写和下面的volatile写重排序
| volatile写
| StoreLoad屏障 -> 防止上面的volatile写和下面可能有的volatile写/读重排序
| ...
V
volatile 读
在对变量进行读操作时,会在读操作前加一条load指令,从内存中读取共享变量:
| ...
| volatile读
| LoadLoad屏障 -> 禁止下面的普通读和上面的volatile读重排序
| LoadStore屏障 -> 禁止下面的写操作和上面的volatile读重排序
| 普通读
| 普通写
| ...
V
内存屏障
简单介绍以下内存屏障的种类:
LoadLoad 屏障
序列:Load1,Loadload,Load2
确保 Load1 所要读入的数据能够在被 Load2 和后续的 load 指令访问前读入。
通常能执行预加载指令或/和支持乱序处理的处理器中需要显式声明 Loadload 屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。而对于总是能保证处理顺序的处理器上,设置该屏障相当于无操作。
StoreStore 屏障
序列:Store1,StoreStore,Store2
确保 Store1 的数据在 Store2 以及后续 Store 指令操作相关数据之前对其它处理器可见(例如向主存刷新数据)。
通常情况下,如果处理器不能保证从写缓冲或/和缓存向其它处理器和主存中按顺序刷新数据,那么需要使用 StoreStore 屏障。
LoadStore 屏障
序列: Load1,LoadStore,Store2
确保 Load1 的数据在 Store2 和后续 Store 指令被刷新之前读取。
在等待 Store 指令可以越过 loads 指令的乱序处理器上需要使用 LoadStore 屏障。
StoreLoad 屏障
序列: Store1,StoreLoad,Load2
确保 Store1 的数据在被 Load2 和后续的 Load 指令读取之前对其他处理器可见。
StoreLoad 屏障可以防止一个后续的 load 指令不正确的使用了 Store1 的数据,而不是另一个处理器在相同内存位置写入一个新数据。正因为如此,处理器为了在屏障前读取同样内存位置存过的数据,必须使用一个 StoreLoad 屏障将存储指令和后续的加载指令分开。
单例模式中的 volatile
一段常见的单例模式代码:
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;
}
}
为什么要加 volatile?
在执行 instance=new Singleton();
时,并不是原子语句,实际是包括了三个步骤:
- 为对象分配内存
- 初始化实例对象
-
instance
引用指向分配的内存空间
然而这个三个步骤并不能保证按序执行,处理器会进行指令重排序优化,可能出现的情况:为对象分配内存后,还没有初始化实例对象,就已经将引用指向了内存空间。
所以在另一个线程 instance==null
判断时,不会进入代码段,而直接使用会造成错误。
而 volatile
的一个作用就是防止指令重排序。
这里推荐另外一种懒汉单例模式模式,使用静态内部类
public class Singleton{
private Singleton(){
}
public static Singleton getInstance() {
return InstanceHolder.instance;
}
static class InstanceHolder {
private static Singleton instance = new Singleton();
}
}
静态内部类只有在调用的时候(InstanceHolder.instance
)才会初始化,虚拟机会保证一个类的类构造器<clinit>()在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的类构造器<clinit>(),其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。
其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出后,其他线程在唤醒之后不会再次进入/执行<clinit>()方法,因为在同一个类加载器下,一个类型只会被初始化一次。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的。
volatile 适用场景
基于 volatile
的可见性和不支持原子性的特性,通常来说,使用 volatile
必须具备以下2个条件:
- 对变量的写操作不依赖于当前值
- 该变量没有包含在具有其他变量的不变式中
因此,volatile
适用于状态标记量:
volatile boolean flag = false;
while(!flag) {
doSomething();
}
public void setFlag() {
flag = true;
}
References:
https://www.cnblogs.com/dolphin0520/p/3920373.html
https://www.cnblogs.com/zhengbin/p/5654805.html
https://blog.csdn.net/weixin_40459875/article/details/80290875
http://ifeve.com/jmm-cookbook-mb/
《深入理解Java虚拟机》
网友评论