Java并发分析—volatile

  在https://www.cnblogs.com/xyzyj/p/11148497.html中已经说明了在多线程并发的情况下,会出现数据的不一致问题,但归根结底就是一个原因,在宏观上就是线程的执行顺序导致的,上文中是通过synchronized解决了线程对共享变量的互斥操作。而在微观上,有个指令重排也会导致数据不一致问题。指令重排是一个比较复杂的概念,这里先从内存模型说起。

1 、内存模型

  内存模型是java虚拟机规范的一种用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果的模型。

  为什么要有这种规范?先看两个概念:

   (1)、计算机在执行程序的时候是在cpu中执行的,而cpu执行程序的数据是要通过内存这种读写速度比较高的硬件与磁盘交互,以达到更高的效率和节省成本。这种形式就叫内存模型。比如C和C++程序的执行就是这样的,但是,由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,因此在某些场景就必须针对不同的平台来编写程序。

       (2)、但是随着CPU的发展,内存的读写速度也远远跟不上CPU的读写速度。

       基于以上原因,JAVA在设计之初的目标就是成为一门平台无关性的语言,即Write once, run anywhere它是通过jvm(虚拟机)实现的。

   jvm是一个程序。它有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。它是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。从而实现了Write once, run anywhere,并且它设计了虚拟的主存和高速缓存来提高效率。模型如下图:

                                               

   这种模型是怎么工作的?

1.1 内存之间交互的过程

  主内存:是虚拟机内存的一部分,

  工作内存:是每个线程自己工作时的内存,线程的工作内存中保存了被该线程使用的变量的主内存副本的拷贝,线程对变量所有的操作都必须在工作内存中进行,而不能直接对主内存中的变量进行读写。不同的线程中间也无法访问对方工作内存中的变量。

  线程之间变量值的传递需要通过主内存来完成。

  关于主内存与工作内存之间的交互协议,即一个变量如何从主内存拷贝到工作内存。如何从工作内存同步到主内存中的实现细节。java内存模型定义了8种操作来完成。这8种操作每一种都是原子操作。8种操作如下:

  • lock(锁定):作用于主内存,它把一个变量标记为一条线程独占状态;
  • unlock(解锁):作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定;
  • read(读取):作用于主内存,它把变量值从主内存传送到线程的工作内存中,以便随后的load动作使用;
  • load(载入):作用于工作内存,它把read操作的值放入工作内存中的变量副本中;
  • use(使用):作用于工作内存,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时候,将会执行这个动作;
  • assign(赋值):作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作;
  • store(存储):作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用;
  • write(写入):作用于主内存,它把store传送值放到主内存中的变量中。

  Java内存模型还规定了执行上述8种基本操作时必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现,以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
  • 如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)。

1.2 java虚拟机提供的Volatile的规则

  volatile是虚拟机提供的最轻量级的同步机制,当一个变量定义为voliatile之后具备两种特性:

     1.保证此变量对所有线程的可见性,指当一个线程修改了这个变量的值,新值对于其他线程来说是立即可知的。

     2 在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题,但java里面的运算并非原子性,导致volatile变量的运算在并发下也是不安全的。可以加锁(synchronize或java.util.concurrent中的原子类)。

1.3 对于long和double的规则

  java内存模型要求对上述提到的8中操作都具有原子性,但对于64位的long和double规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。

  如果多个线程共享一个未被声明为volatile的long或double类型的变量,并且同时对他们进行读取和修改操作,那么某些线程可能会读取到有既非原值,又非其他线程修改值的代表了半个变量的数值。

1.4 原子性,可见性与有序性

  Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的,我们逐个看下哪些操作实现了这三个特性。

  (1)原子性(Atomicity):由Java内存模型来直接保证的原子性变量包括read、load、assign、use、store和write,我们大致可以认为基本数据类型的访问读写是具备原子性的。如果应用场景需要一个更大方位的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式的使用这两个操作,这两个字节码指令反应到Java代码中就是同步块--synchronized关键字,因此在synchronized块之间的操作也具备原子性。

  (2)可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。上文在讲解volatile变量的时候我们已详细讨论过这一点。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final.同步快的可见性是由“对一个变量执行unlock操作前,必须先把此变量同步回主内存”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把"this"的引用传递出去,那么在其他线程中就能看见final字段的值。

  (3)有序性(Ordering):Java内存模型的有序性在前面讲解volatile时也详细的讨论过了,Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的:如果在一个线程中观察另外一个线程,所有的线程操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序(https://www.cnblogs.com/tuhooo/p/7921651.html”现象和“工作内存与主内存同步延迟”现象。Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行的进入。

1.5先行并发原则

   java语言中的先行并发是判断数据是否存在竞争、线程是否安全的主要依据,它是java内存模型中定义的两项操作之间的偏序关系即:一个操作 ”时间上的先发生” 不变代表这 个操作会是“先行发生” ,如果一个操作先行发生也不能推出这个操作在时间上“先行发生”。

2、 缓存一致性问题

  以上jvm既解决了Write once, run anywhere问题,又解决了效率问题。但是又会带来缓存一致性问题。举个例子:

1 int i= 0;

  假如有两个线程对i进行加1操作。线程1将i的值从主内存拷贝到自己工作内存,同时线程2做了同样的事。然后都对i加1,再刷新到主存,主存中的i是2而不是3。

3、volatile 

        上面已经介绍了,在并发编程中有三个概念,原子性,可见性,有序性。而volatile 能保证可见性和有序性

       (1)保证的可见性

  当一个共享变量被volatile修饰后,一个线程修改共享变量后它会立即把变量的新值更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。这样每个线程在主存中拿到的值都是最新的,因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final。同步块(synchronized)的可见性是由“对一个变量执行unlock操作前,必须先把此变量同步回主内存”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把"this"的引用传递出去,那么在其他线程中就能看见final字段的值

        在各个线程的工作内存中,volatile变量也可以存在不一致的情况,由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题,但java里面的运算并非原子性,导致volatile变量的运算在并发下也是不安全的。可以加锁(synchronize或java.util.concurrent中的原子类)。

        以一个例子来说明:由于volatile不能保证原子性,故在并发情况下,依然不能保证线程安全。在这里创建1000个线程,只让一个线程修改变量,其他线程读取变量的值。

 1 package com.test;
 2 
 3 public class Main3 extends Thread {
 4     public static  int  i= 1;
 5     
 6     public void cheng(){
 7         System.out.println("线程:"+Thread.currentThread().getName()+"修改i的值");
 8         i = 3;
 9     }
10     public void print(){
11         System.out.println(Thread.currentThread().getName()+"打印i的值为:"+i);
12     }
13     public void run() {
14         Main3 m3 = new Main3();
15         if(Thread.currentThread().getName().indexOf("Thread-0") >-1){
16             m3.cheng();
17         }else{
18             m3.print();
19         }
20     }
21     public static void main(String[] args) {
22         for(int i = 0; i<1000;i++){
23              new Main3().start();
24         }
25     }
26 }
View Code

  运行结果:

       

  分析:在上面的例子中,创建了1000个线程,当前程 Thread-0 把 i 的值改为3后,线程 Thread-1 打印的仍然是1,第三个线程往后打印的都是3。接下来给 i 加上修饰符 volatile,代码如下:

public static volatile int  i= 1;

        运行结果:

       

       

       上图是两种可能的结果,可能还有其他结果,但是,最终的形式都一样,那就是,无论Thread-0线程什么时候执行,当线程Thread-0执行完i = 3 操作之后,其他线程立即读取的都是修改后的值。

       那怎么证明volatile不能保证原子性呢?再举一个例子:

 1 package com.test;
 2 
 3 public class Main3 extends Thread {
 4     public static  volatile int  i= 1;
 5     
 6     public void cheng(){
 7         System.out.println("线程:"+Thread.currentThread().getName()+"修改i的值");
 8         i = 3;
 9     }
10     public void print(){
11         System.out.println(Thread.currentThread().getName()+"打印i的值为:"+i);
12     }
13     public void run() {
14         Main3 m3 = new Main3();
15         if(Thread.currentThread().getName().indexOf("0") >-1){
16             m3.cheng();
17         }else{
18             m3.print();
19         }
20     }
21     public static void main(String[] args) {
22         for(int i = 0; i<1000;i++){
23              new Main3().start();
24         }
25     }
26 }
View Code

  运行结果:

       

    运行结果:

  明显和上面例子不一样了?在这个例子中,只要线程名称中包含 0 的线程都可以对变量进行修改,剩余线程对变量读取。在这种多线程并发的情况下,volatile不能保证线程安全。要保证线程全,使用上一章中的Synchronized关键字,为了方便观察,把线程设置为5个,代码如下:

 1 package com.test;
 2 
 3 public class Main3 extends Thread {
 4     public static  volatile int  i= 1;
 5     
 6     public static void cheng(){
 7         System.out.println("线程:"+Thread.currentThread().getName()+"开始修改i的值");
 8         i = 3;
 9         System.out.println("线程:"+Thread.currentThread().getName()+"修改i结束,i的值为:"+i);
10     }
11     public static  void print(){
12         System.out.println("线程:"+Thread.currentThread().getName()+"打印i的值为:"+i);
13     }
14     public void run() {
15         //if(Thread.currentThread().getName().indexOf("0") >-1){
16             cheng();
17         //}else{
18             print();
19         //}
20     }
21     public static void main(String[] args) {
22         for(int i = 0; i<5;i++){
23             synchronized (Main3.class) {
24                 new Main3().start();
25             }
26              
27         }
28     }
29 }
View Code

  运行结果:

       

  分析:仔细观察,每个线程拿到的都是最初的值,在本线程内修改完,再读取都是正确的,互不影响。

       综上,volatile能够保证可见性,不能保证原子性。

  (2)保证有序性:

  计算机执行指令的顺序在经过程序编译器编译之后形成的指令序列,这个指令序列是会输出确定的结果。但是,一般情况下,CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化,将指令的顺序打乱执行,可以节省线程等待的时间。在单线程中,指令被重排后程序运行的结果不受影响,而在多线程的情况下,可能会出现不同的结果,因此volatilt就是通过防止指令重排来避免多线程并发情况下带来的数据一致性问题。也就是保证有序性。

  举个例子:创建一个Student类,有一个name属性。创建一个多线程的测试类使用Student类。(以下例子要运行多次才会出现异常结果)

  创建Student 类

 1 public class Student {
 2     private String name;
 3 
 4     public String getName() {
 5         return name;
 6     }
 7 
 8     public void setName(String name) {
 9         this.name = name;
10     }
View Code

  创建测试类:

 1 package com.test;
 2 
 3 public class TestVolatile extends Thread {
 4     private static Student student = new Student();
 5     private static int flag = 0;
 6     int i = 0;
 7 
 8     public void write(String string,int i) {
 9         System.out.println(Thread.currentThread().getName()+"write开始");
10         student.setName(string);
11         flag = i;
12         System.out.println(Thread.currentThread().getName()+"write结束");
13     }
14     public void read() {
15         System.out.println(student.getName() + "==" + flag);
16     }
17 
18     public static void main(String[] args) {
19         final TestVolatile testVolatile = new TestVolatile();
20         Thread taThread = new Thread(new Runnable() {
21             public void run() {
22                 testVolatile.write("张三",1);
23             }
24         });
25         Thread tbThread = new Thread(new Runnable() {
26             public void run() {
27                 testVolatile.write("李四",2);
28             }
29         });
30         Thread tcThread = new Thread(new Runnable() {
31             public void run() {
32                 testVolatile.read();
33             }
34         });
35         taThread.start();
36         tbThread.start();
37         tcThread.start();
38     }
39 }
View Code

  运行结果:

        

       

       

      

       大概会出现以上四种结果,前三类都是正常结果,第四种结果  张三==2是异常异常结果,类似的还有其他结果,这里不再贴图。具体的分析在下面指令重排中分析。

  将代码修改如下:

1     private static volatile Student student = new Student();
2     private static volatile int flag = 0;

       运行结果只能出现前三种正常情况,暂未发现第四种异常情况。

       分析:在下面指令重排中详细分析。

4、指令重排

  指令重排基本概念已经介绍,接下来看具体的实现原理。

(1)、谁会对指令重排?

  编译器指令重排:单线程中,在不改变程序语义的情况下,指令的执行顺序可以发生重排。

  处理器指令重排:在数据不存在依赖性的情况下,指令的执行顺序可以发生重排。

(2)、什么情况下会指令重排?

  在单线程下,数据只要不存在依赖性就可能出现指令重排,在指令重排后,程序运行结果不受影响。如下例子:

1 int a = 0;       (1)
2 int b = 1;       (2)
3 int c = a + b;   (3)

  由于(1)和(2)之间不存在依赖关系,所以编译器或处理器在执行(1)和(2)时的顺序不影响结果,会出现指令重排。但是(3)的结果必须依赖(1)和(2),所有,(3)不能和(1)、(2)互换执行顺序,只能等到(1)和(2)执行完,才能执行(3)。这就是指令重排。

(3)、为什么要指令重排?

  大多数现代微处理器和Java运行时环境的JIT编译器都会采用将指令乱序执行(out-of-order execution,简称OoOE或OOE)的方法,在条件允许的情况下,直接运行当前有能力立即执行的后续指令,避开获取下一条指令所需数据时造成的等待。通过乱序执行的技术,处理器可以大大提高执行效率。

(4)、volatile 防止指令重排

  以上面保证有序性的例子来分析:

  在多线程中,以上面的例子来说,给代码标上序号如下:  

1 student.setName(string);   (1)   线程1或线程2执行
2 flag = i;                  (2)   线程1或线程2执行
3 read()                     (3)   线程3执行

  可能的执行顺序和运行结果如下:

  线程1执行(1),线程2看到(1)被其他线程持有,需要漫长的等待,进行指令重排,先执行(2),此时线程3读取的值就是"张三=2"。

5、volatile 如何防止指令重排——内存屏障

  为了实现volatile功能(指令重排),JMM(java memory model)java内存模型定义了一套规则:编译器在生成字节码时,在不能进行重排的指令之间插入了特定的操作来屏蔽指令重排,这种操作就是内存屏障。

  举个例子说明:

  

  如上图,上面有四个不同的人(指令),要排成一列,问有多少种排法,显然,有多种排法。现在加入屏障(红框中的竖线)后,所有人在排序时,不能越过中间线,那么中间两个人只能在中间两个位置排。这就像内存屏障。但是jmm的内存屏障比这复杂的多,下面进行详细分析。

  以下内容转自https://blog.csdn.net/hqq2023623/article/details/51013468

       为了实现volatile内存语义,JMM会分别限制编译器重排序和处理器重排序。

                                                     

  1.当第一个操作为普通的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作(1,3)

  2.当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前(第二行)

  3.当第一个操作是volatile写,第二个操作是volatile读时,不能重排序(3,2)

  4.当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序(第三列)

  为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

  JMM基于保守策略的JMM内存屏障插入策略:

  1.在每个volatile写操作的前面插入一个StoreStore屏障

  2.在每个volatile写操作的后面插入一个SotreLoad屏障

  3.在每个volatile读操作的后面插入一个LoadLoad屏障

  4.在每个volatile读操作的后面插入一个LoadStore屏障

                                                      

  上图的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。

  因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。

                                                   

                                                         

  x86处理器仅仅会对写-读操作做重排序

  因此会省略掉读-读、读-写和写-写操作做重排序的内存屏障

  在x86中,JMM仅需在volatile后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义

  这意味着在x86处理器中,volatile写的开销比volatile读的大,因为StoreLoad屏障开销比较大

                                                         

                                                             

原文地址:https://www.cnblogs.com/xyzyj/p/11185128.html