Java并发分析—volatile

  在http://www.javashuo.com/article/p-arfmgdvg-r.html中已经说明了在多线程并发的状况下,会出现数据的不一致问题,但归根结底就是一个缘由,在宏观上就是线程的执行顺序致使的,上文中是经过synchronized解决了线程对共享变量的互斥操做。而在微观上,有个指令重排也会致使数据不一致问题。指令重排是一个比较复杂的概念,这里先从内存模型提及。html

1 、内存模型

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

  为何要有这种规范?先看两个概念:编程

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

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

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

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

                                               

   这种模型是怎么工做的?架构

1.1 内存之间交互的过程

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

  工做内存:是每一个线程本身工做时的内存,线程的工做内存中保存了被该线程使用的变量的主内存副本的拷贝,线程对变量全部的操做都必须在工做内存中进行,而不能直接对主内存中的变量进行读写。不一样的线程中间也没法访问对方工做内存中的变量。jvm

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

  关于主内存与工做内存之间的交互协议,即一个变量如何从主内存拷贝到工做内存。如何从工做内存同步到主内存中的实现细节。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程序中自然的有序性能够总结为一句话:若是在本线程内观察,全部的操做都是有序的:若是在一个线程中观察另一个线程,全部的线程操做都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序(http://www.javashuo.com/article/p-ncocmsqx-a.html”现象和“工做内存与主内存同步延迟”现象。Java语言提供了volatile和synchronized两个关键字来保证线程之间操做的有序性,volatile关键字自己就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只容许一条线程对其进行lock操做”这条规则得到的,这条规则决定了持有同一个锁的两个同步块只能串行的进入。

1.5先行并发原则

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

二、 缓存一致性问题

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

1 int i= 0;

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

三、volatile 

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

       (1)保证可见性

  当一个变量被volatile修饰后,表示着线程本地内存无效当一个线程修改共享变量后他会当即把变量的新值更新到主内存中,当其余线程读取共享变量时,它会直接从主内存中读取。这样每一个线程在主存中拿到的值都是最新的,所以,能够说volatile保证了多线程操做时变量的可见性,而普通变量则不能保证这一点。除了volatile以外,Java还有两个关键字能实现可见性,即synchronized和final.同步快的可见性是由“对一个变量执行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;

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

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

四、指令重排

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

(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"。

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

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

  举个例子说明:

  

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

  如下内容转自http://www.javashuo.com/article/p-ptjcyjzo-ko.html

       为了实现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屏障开销比较大

                                                         

 

                                                             

 

        欢迎扫码关注个人微信公众号,或者微信公众号直接搜索Java传奇,不定时更新一些学习笔记!

 

                                          

相关文章
相关标签/搜索