volatile关键字
volatile关键字
概要
volatile修饰符并不是Java语言的首创,早在C和C++当中就已经存在。为了理解volatile关键字的作用和原理,需要先了解一些相关知识。请先参考这一篇文章《什么是Java内存模型(JMM)?》
我们知道,并发编程时,线程安全涉及三个特性:原子性、可见性、有序性。volatile用于保证修饰变量的可见性、有序性,但是不能保证原子性。
volatile有两个主要作用:保证变量在线程之间的可见性和禁止指令重排。
一、volatile的用法
volatile通常被比喻成“轻量级的synchronized”,也是Java并发编程中比较重要的一个关键字。和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。
volatile的用法比较简单,只需要在声明一个可能被多线程同时访问的变量时,使用volatile修饰就可以了。
举个例子:
1 public class Singleton1 { 2 private volatile static Singleton1 singleton1 = null; 3 4 private Singleton1() { 5 6 } 7 8 /** 9 * 双重锁检测-实现单例模式 10 * 11 * @return 执行结果 12 */ 13 public static Singleton1 getInstance() { 14 //先判断对象是否已经实例过,没有实例化过才进入加锁代码 15 if (singleton1 == null) { 16 //类对象加锁 17 synchronized (Singleton1.class) { 18 if (singleton1 == null) { 19 singleton1 = new Singleton1(); 20 } 21 } 22 } 23 24 return singleton1; 25 } 26 }
如以上代码,是一个比较典型的使用双重锁校验的形式实现单例的,其中使用volatile关键字修饰可能被多个线程同时访问到的singleton。
二、volatile与可见性
1. 什么是可见性?
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
当访问共享变量的多个线程运行在多核CPU上时,可能会出现可见性问题。synchronized关键字和lock可以解决这个问题,但是会阻塞线程,降低性能,所以java给出了更轻量级关键字volatile,不会阻塞线程。
2. 可见性问题是如何产生的?
为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升。但是由于引入了多级缓存,就存在缓存数据不一致问题。这个缓存不一致的问题其实就是可见性的问题。
3. volatile 如何实现可见性的?
volatile可见性依赖于JMM中的内存屏障和缓存一致性协议。
1)Java 内存模型(JMM)
JMM 定义了主内存和工作内存之间的交互规则,包括如何处理变量的读取和写入,以及在并发环境中保证可见性、原子性和有序性。happens-before原则是JMM中非常重要的原则,它是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性。
2)内存屏障
写操作:在对 volatile 变量进行写操作时,JVM 会插入一个 StoreBarrier,这条屏障确保所有在该写操作之前的写入操作都已完成并刷新到主内存中。这意味着,写入 volatile 变量之前的所有操作对其他线程是可见的。
读操作:在读取 volatile 变量时,JVM 会插入一个 LoadBarrier,这条屏障确保读取的 volatile 变量的值是从主内存中获取的,而不是从线程的局部缓存。这使得在读取 volatile 变量后,所有的后续读操作都能看到该变量的最新值。
因此,可以这么说:volatile实现内存可见性是通过store和load指令完成的;也就是对volatile变量执行写操作时,会在写操作后加入一条store指令,即强迫线程将最新的值刷新到主内存中;而在读操作时,会加入一条load指令,即强迫从主内存中读入变量的值。
3)缓存一致性
在多处理器系统中,CPU 缓存可能会导致不同线程看到的变量值不一致。为了解决这个问题,CPU 实现了缓存一致性协议,确保所有处理器的缓存能及时反映主内存的变化。当一个线程修改了 volatile 变量的值后,缓存一致性协议会使得其他处理器在下次访问该变量时从主内存中读取最新值。
总结:volatile 的写操作确保了所有写入的可见性,而读操作则确保线程能获取到最新的值。这种机制保证了在多线程环境中,volatile 变量的值能够被所有线程及时看到,从而避免了数据不一致的问题。
三、volatile 有序性
volatile的另一个重要作用是可以禁止指令重排,这就保证了代码的程序会严格按照代码的先后顺序执行(指令执行顺序),这就保证了有序性。
1. 什么是有序性?
有序性即程序执行的顺序按照代码的先后顺序执行。
2. 有序性问题是怎么产生的?
除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是可能存在有序性问题。
3. 什么是指令重排 ?
指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。简单来说就是系统在执行代码的时候并不一定是按照代码顺序依次执行。
1 比如java中简单的一句 instance = new Singleton,会被编译器编译成如下JVM指令: 2 3 memory =allocate(); //1:分配对象的内存空间 4 ctorInstance(memory); //2:初始化对象 5 instance =memory; //3:设置instance指向刚分配的内存地址 6 7 8 9 但是这些指令顺序并非一成不变,有可能会经过JVM和CPU的优化,指令重排成下面的顺序: 10 11 memory =allocate(); //1:分配对象的内存空间 12 instance =memory; //3:设置instance指向刚分配的内存地址 13 ctorInstance(memory); //2:初始化对象
当线程A执行完1,3,时,instance对象还未完成初始化,但已经不再指向null。此时如果线程B抢占到CPU资源,执行 if(instance == null)的结果会是false,从而返回一个没有初始化完成的instance对象。
如下图所示:
2. 为什么会进行指令重排?
指令重排的目的是为了在不改变程序执行结果的前提下,优化程序的运行效率。需要注意的是,这里所说的不改变执行结果,指的是不改变单线程下的程序执行结果。
如下图:
说明:
1)Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。
2)指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
3. 如何禁止指令重排?
通过内存屏障来实现禁止指令重排的目的。
四、内存屏障
1. 什么是内存屏障?
内存屏障(Memory barrier),也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,它使得 CPU 或编译器在对内存进行操作的时候, 严格按照一定的顺序来执行, 也就是说在内存屏障之前的指令和内存屏障之后的指令不会由于系统优化等原因而导致乱序。
这通常意味着在屏障之前发布的操作被保证在屏障之后发布的操作之前执行。
2. JVM提供的四类内存屏障指令
1)LoadLoad 读读
在每个volatile读操作前插入此屏障,确保在此屏障之前的所有加载操作(读)都在该volatile读操作之前完成。这确保了在读取volatile变量之前,其他线程的所有读取操作都已完成。
该屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。
2)LoadStore 读写
在每个volatile读操作后插入此屏障,确保该volatile读操作在屏障之后的写操作之前完成。这防止了对volatile变量的读取被重排序,从而保证读取结果的一致性。
该屏障用来禁止处理器把上面的volatile读与下面的普通写重排序
3)StoreStore 写写
在每个volatile写操作前插入此屏障,确保在此屏障之前的所有存储操作(写)都在该volatile写操作之前完成。这可以防止处理器将写入顺序重排序,确保其他线程在看到volatile变量的写入时,先看到之前的所有写入。
该屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。
4)StoreLoad 写读 (开销最大)
在每个volatile写操作后插入此屏障,确保该volatile写操作在屏障之后的所有存储操作之前完成。这保证了在写入volatile变量后,随后的存储操作不会被提前执行,从而保证数据的一致性。
写读,该屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序
3. 变量被volatile修饰后
在一个变量被volatile修饰后,JVM会为我们做两件事:
1. 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。
2. 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。
下面通过举例说明:
1 public class VolatileTest { 2 3 int i = 0; 4 volatile boolean flag = false; 5 6 public void write() { 7 i = 1; 8 flag = true; 9 } 10 11 public void read() { 12 if (flag) { 13 System.out.println("i=" + i); 14 } 15 } 16 17 }
1)写操作
2)读操作
五、volatile与原子性
volatile是不能保证原子性的
举个例子:
1 public class VolatileTest { 2 3 4 public volatile static int count = 0; 5 6 public static void main(String[] args) { 7 //开启10个线程 8 for (int i = 0; i < 10; i++) { 9 10 new Thread( 11 new Runnable() { 12 @Override 13 public void run() { 14 try { 15 Thread.sleep(1); 16 } catch (InterruptedException e) { 17 e.printStackTrace(); 18 } 19 20 //每小线程当中让count值自增100次 21 for (int j = 0; j < 100; j++) { 22 count++; 23 } 24 } 25 } 26 ).start(); 27 } 28 29 try { 30 Thread.sleep(2000); 31 } catch (InterruptedException e) { 32 e.printStackTrace(); 33 } 34 System.out.print("count=" + count); 35 } 36 37 }
这段代码的逻辑为:开启10个线程,每个线程当中让静态变量count自增100次。执行之后会发现,最终count的结果值未必是1000,有可能小于1000。
使用volatile修饰的变量,为什么并发自增的时候会出现这样的问题呢?这是因为count++这一行代码本身并不是原子性操作,在字节码层面可以拆分成如下指令:
1 getstatic //读取静态变量(count) 2 3 iconst_1 //定义常量1 4 5 iadd //count增加1 6 7 putstatic //把count结果同步到主内存
执行过程如下图:
虽然每一次执行 getstatic 的时候,获取到的都是主内存的最新变量值,但是进行iadd的时候,由于并不是原子性操作,其他线程在这过程中很可能让count自增了很多次。这样一来本线程所计算更新的是一个陈旧的count值,自然无法做到线程安全。
六、volatile使用场景
1. 运行结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
2. 变量不需要与其他的状态变量共同参与不变约束。
七、volatile的相关总结
1. volatile是轻量级同步机制
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,是一种比synchronized关键字更轻量级的同步机制。
2. 不能替代synchronized和加锁机制
volatile只能保证内存可见性,不能保证原子性,所以不能替代synchronized和加锁机制。后两种机制既可以确保可见性又可以确保原子性。
3. 效率比较低
volatile频繁从内存中读写,且屏蔽掉了JVM中必要的代码优化,和普通变量比较,效率上比较低,因此一定在必要时才使用此关键字。
参考链接:
https://mp.weixin.qq.com/s/DZkGRTan2qSzJoDAx7QJag
https://juejin.cn/post/6992115728702767112