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     private 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.  可见性问题是如何产生的?

   为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升。但是由于引入了多级缓存,就存在缓存数据不一致问题。这个缓存不一致的问题其实就是可见性的问题。

   2. volatile 可见性实现原理

   可见性的保证是基于CPU的内存屏障指令,被JSR-133抽象为happens-before (先行发生)原则。happens-before原则是JMM中非常重要的原则,它是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性。

   当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。

   但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。

   缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

   所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。

   总结来说:Java中的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.  如何禁止指令重排?

   通过内存屏障来实现禁止指令重排的目的。

   4. 什么是内存屏障?

    内存屏障(Memory barrier),也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,它使得 CPU 或编译器在对内存进行操作的时候, 严格按照一定的顺序来执行, 也就是说在内存屏障之前的指令和内存屏障之后的指令不会由于系统优化等原因而导致乱序。

    这通常意味着在屏障之前发布的操作被保证在屏障之后发布的操作之前执行。

  4. JVM提供的四类内存屏障指令

   1)LoadLoad

   抽象场景:Load1; LoadLoad; Load2

   Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

   说明:读读,该屏障用来禁止处理器把上面的volatile读与下面的普通读重排序

   2)StoreStore

   抽象场景:Store1; StoreStore; Store2

   Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见

   说明:该屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中

   3)LoadStore

   抽象场景:Load1; LoadStore; Store2

   在Store2被写入前,保证Load1要读取的数据被读取完毕。

   说明:读写,该屏障用来禁止处理器把上面的volatile读与下面的普通写重排序

   4)StoreLoad屏障:

   抽象场景:Store1; StoreLoad; Load2

   在Load2读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad屏障的开销是四种屏障中最大的。

   说明:写读,该屏障的作用是避免volatile写与后面可能有的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值,自然无法做到线程安全。

    四、共享的long类型和double类型变量赋值问题

    1.  在Java中,对long和double类型的变量赋值是不是原子操作?

    在Java中,long类型和double类型的赋值不是原子操作。

    1)对于32位操作系统来说,单次次操作能处理的最长长度为32bit,而long/double类型8字节64bit,所以对long/double的读写都要两条指令才能完成(即每次读写64bit中的32bit)。它们的赋值过程需要分两步完成:先将高32位写入内存,再将低32位写入内存。

    2)这种情况下,如果一个线程在赋值的过程中被另一个线程打断,可能会导致某个线程读取到了部分更新后的值,而另一部分还是旧值,从而出现数据不一致的情况。

    因此,鼓励大家将共享的long和double变量设置为volatile类型,这样能保证任何情况下对long和double的单次读/写操作都具有原子性

    3. 在硬件,操作系统,JVM都是64位的情况下呢?

    对于64bit的环境来说,单次操作可以操作64bit的数据,即可以一次性读写long或double的整个64bit。因此,在64位的环境下,long和double的读写是原子操作。

   说明:目前各种平台下的商用虚拟机都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不把long 和 double 变量专门声明为 volatile多数情况下也是不会错的。 

   四、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

posted @ 2024-05-07 23:11  欢乐豆123  阅读(5)  评论(0编辑  收藏  举报