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傳奇,不定時更新一些學習筆記!

 

                                          

相關文章
相關標籤/搜索