volatile記憶體可見性和指令重排

早睡早起愛學習i發表於2020-11-17
轉載於:https://blog.csdn.net/jiyiqinlovexx/article/details/50989328

volatile兩大作用

1、保證記憶體可見性

2、防止指令重排

 

此外需注意volatile並不保證操作的原子性。


(一)記憶體可見性

1 概念

         JVM記憶體模型:主記憶體和執行緒獨立的工作記憶體

Java記憶體模型規定,對於多個執行緒共享的變數,儲存在主記憶體當中,每個執行緒都有自己獨立的工作記憶體(比如CPU的暫存器),執行緒只能訪問自己的工作記憶體,不可以訪問其它執行緒的工作記憶體。

工作記憶體中儲存了主記憶體共享變數的副本,執行緒要操作這些共享變數,只能通過操作工作記憶體中的副本來實現,操作完畢之後再同步回到主記憶體當中。

如何保證多個執行緒操作主記憶體的資料完整性是一個難題,Java記憶體模型也規定了工作記憶體與主記憶體之間互動的協議,定義了8種原子操作:

(1) lock:將主記憶體中的變數鎖定,為一個執行緒所獨佔

(2) unclock:將lock加的鎖定解除,此時其它的執行緒可以有機會訪問此變數

(3) read:將主記憶體中的變數值讀到工作記憶體當中

(4) load:將read讀取的值儲存到工作記憶體中的變數副本中。

(5) use:將值傳遞給執行緒的程式碼執行引擎

(6) assign:將執行引擎處理返回的值重新賦值給變數副本

(7) store:將變數副本的值儲存到主記憶體中。

(8) write:將store儲存的值寫入到主記憶體的共享變數當中。

 

通過上面Java記憶體模型的概述,我們會注意到這麼一個問題,每個執行緒在獲取鎖之後會在自己的工作記憶體來操作共享變數,操作完成之後將工作記憶體中的副本回寫到主記憶體,並且在其它執行緒從主記憶體將變數同步回自己的工作記憶體之前,共享變數的改變對其是不可見的。即其他執行緒的本地記憶體中的變數已經是過時的,並不是更新後的值。


2 記憶體可見性帶來的問題

很多時候我們需要一個執行緒對共享變數的改動,其它執行緒也需要立即得知這個改動該怎麼辦呢?下面舉兩個例子說明記憶體可見性的重要性:

 

例子1

有一個全域性的狀態變數open:

boolean open=true;

這個變數用來描述對一個資源的開啟關閉狀態,true表示開啟,false表示關閉,假設有一個執行緒A,在執行一些操作後將open修改為false:

 

//執行緒A

resource.close();

open = false;

 

執行緒B隨時關注open的狀態,當open為true的時候通過訪問資源來進行一些操作:

//執行緒B

while(open) {

doSomethingWithResource(resource);

}

 

當A把資源關閉的時候,open變數對執行緒B是不可見的,如果此時open變數的改動尚未同步到執行緒B的工作記憶體中,那麼執行緒B就會用一個已經關閉了的資源去做一些操作,因此產生錯誤。


例子2

下面是一個通過布林標誌判斷執行緒是否結束的例子:

public class CancelThreadTest {

         publicstatic void main(String[] args) throws Exception{

                   PrimeGeneratorgen = new PrimeGenerator();

                   newThread(gen).start();

                   try

                   {

                            Thread.sleep(3000);

                   }finally{

                            gen.cancel();

                   }

         }

}

 

class PrimeGenerator implements Runnable{

         privateboolean cancelled;      

         @Override

         publicvoid run() {

                   while(!cancelled)

                   {

                            System.out.println("Running...");

                            //doingsomething here...

                   }                

         }       

         publicvoid cancel(){cancelled = true;}

}

主執行緒中設定PrimeGenerator執行緒的是否取消標識,PrimeGenerator執行緒檢測到這個標識後就會結束執行緒,由於主執行緒修改cancelled變數的記憶體可見性,主執行緒修改cancelled標識後並不馬上同步回主記憶體,所以PrimeGenerator執行緒結束的時間難以把控(最終是一定會同步回主記憶體,讓PrimeGenerator執行緒結束)。

如果PrimeGenerator執行緒執行一些比較關鍵的操作,主執行緒希望能夠及時終止它,這時將cenceled用volatile關鍵字修飾就是必要的。

特別注意:上面演示這個並不是正確的取消執行緒的方法,因為一旦PrimeGenerator執行緒中包含BolckingQueue.put()等阻塞方法,那麼將可能永遠不會去檢查cancelled標識,導致執行緒永遠不會退出。正確的方法參見另外一篇關於如何正確終止執行緒的方法。


3 提供記憶體可見性

volatile保證可見性的原理是在每次訪問變數時都會進行一次重新整理,因此每次訪問都是主記憶體中最新的版本。所以volatile關鍵字的作用之一就是保證變數修改的實時可見性

 

針對上面的例子1:

要求一個執行緒對open的改變,其他的執行緒能夠立即可見,Java為此提供了volatile關鍵字,在宣告open變數的時候加入volatile關鍵字就可以保證open的記憶體可見性,即open的改變對所有的執行緒都是立即可見的。

 

針對上面的例子2:

將cancelled標誌設定的volatile保證主執行緒針對cancelled標識的修改能夠讓PrimeGenerator執行緒立馬看到。

 

備註:也可以通過提供synchronized同步的open變數的Get/Set方法解決此記憶體可見性問題,因為要Get變數open,必須等Set方完全釋放鎖之後。後面將介紹到兩者的區別。

 

(二)指令重排

1 概念

指令重排序是JVM為了優化指令,提高程式執行效率,在不影響單執行緒程式執行結果的前提下,儘可能地提高並行度。編譯器、處理器也遵循這樣一個目標。注意是單執行緒。多執行緒的情況下指令重排序就會給程式設計師帶來問題。

不同的指令間可能存在資料依賴。比如下面計算圓的面積的語句:

double r = 2.3d;//(1)

double pi =3.1415926; //(2)

double area = pi* r * r; //(3)

area的計算依賴於r與pi兩個變數的賦值指令。而r與pi無依賴關係。

as-if-serial語義是指:不管如何重排序(編譯器與處理器為了提高並行度),(單執行緒)程式的結果不能被改變。這是編譯器、Runtime、處理器必須遵守的語義。

雖然,(1) - happensbefore -> (2),(2) - happens before -> (3),但是計算順序(1)(2)(3)與(2)(1)(3) 對於r、pi、area變數的結果並無區別。編譯器、Runtime在優化時可以根據情況重排序(1)與(2),而絲毫不影響程式的結果。

指令重排序包括編譯器重排序和執行時重排序。


2 指令重排帶來的問題

如果一個操作不是原子的,就會給JVM留下重排的機會。下面看幾個例子:

例子1:A執行緒指令重排導致B執行緒出錯

對於在同一個執行緒內,這樣的改變是不會對邏輯產生影響的,但是在多執行緒的情況下指令重排序會帶來問題。看下面這個情景:

線上程A中:

context = loadContext();

inited = true;

 

線上程B中:

while(!inited ){ //根據執行緒A中對inited變數的修改決定是否使用context變數

   sleep(100);

}

doSomethingwithconfig(context);

 

假設執行緒A中發生了指令重排序:

inited = true;

context = loadContext();

 

那麼B中很可能就會拿到一個尚未初始化或尚未初始化完成的context,從而引發程式錯誤。

 

例子2:指令重排導致單例模式失效

我們都知道一個經典的懶載入方式的雙重判斷單例模式:

public class Singleton {

  private static Singleton instance = null;

  private Singleton() { }

  public static Singleton getInstance() {

     if(instance == null) {

        synchronzied(Singleton.class) {

           if(instance == null) {

               instance = new Singleton();  //非原子操作

           }

        }

     }

     return instance;

   }

}

 

看似簡單的一段賦值語句:instance= new Singleton(),但是很不幸它並不是一個原子操作,其實際上可以抽象為下面幾條JVM指令:

memory =allocate();    //1:分配物件的記憶體空間 

ctorInstance(memory);  //2:初始化物件 

instance =memory;     //3:設定instance指向剛分配的記憶體地址 

 

上面操作2依賴於操作1,但是操作3並不依賴於操作2,所以JVM是可以針對它們進行指令的優化重排序的,經過重排序後如下:

memory =allocate();    //1:分配物件的記憶體空間 

instance =memory;     //3:instance指向剛分配的記憶體地址,此時物件還未初始化

ctorInstance(memory);  //2:初始化物件

 

可以看到指令重排之後,instance指向分配好的記憶體放在了前面,而這段記憶體的初始化被排在了後面。

線上程A執行這段賦值語句,在初始化分配物件之前就已經將其賦值給instance引用,恰好另一個執行緒進入方法判斷instance引用不為null,然後就將其返回使用,導致出錯。


3 防止指令重排

除了前面記憶體可見性中講到的volatile關鍵字可以保證變數修改的可見性之外,還有另一個重要的作用:在JDK1.5之後,可以使用volatile變數禁止指令重排序。

        

解決方案:例子1中的inited和例子2中的instance以關鍵字volatile修飾之後,就會阻止JVM對其相關程式碼進行指令重排,這樣就能夠按照既定的順序指執行。

 

volatile關鍵字通過提供“記憶體屏障”的方式來防止指令被重排序,為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。

大多數的處理器都支援記憶體屏障的指令。

對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,為此,Java記憶體模型採取保守策略。下面是基於保守策略的JMM記憶體屏障插入策略:

在每個volatile寫操作的前面插入一個StoreStore屏障。

在每個volatile寫操作的後面插入一個StoreLoad屏障。

在每個volatile讀操作的後面插入一個LoadLoad屏障。

在每個volatile讀操作的後面插入一個LoadStore屏障。


(三)總結

volatile是輕量級同步機制

相對於synchronized塊的程式碼鎖,volatile應該是提供了一個輕量級的針對共享變數的鎖,當我們在多個執行緒間使用共享變數進行通訊的時候需要考慮將共享變數用volatile來修飾。

volatile是一種稍弱的同步機制,在訪問volatile變數時不會執行加鎖操作,也就不會執行執行緒阻塞,因此volatilei變數是一種比synchronized關鍵字更輕量級的同步機制。

volatile使用建議

使用建議:在兩個或者更多的執行緒需要訪問的成員變數上使用volatile。當要訪問的變數已在synchronized程式碼塊中,或者為常量時,沒必要使用volatile。

由於使用volatile遮蔽掉了JVM中必要的程式碼優化,所以在效率上比較低,因此一定在必要時才使用此關鍵字。

volatile和synchronized區別

1、volatile不會進行加鎖操作:

volatile變數是一種稍弱的同步機制在訪問volatile變數時不會執行加鎖操作,因此也就不會使執行執行緒阻塞,因此volatile變數是一種比synchronized關鍵字更輕量級的同步機制。

2、volatile變數作用類似於同步變數讀寫操作:

從記憶體可見性的角度看,寫入volatile變數相當於退出同步程式碼塊,而讀取volatile變數相當於進入同步程式碼塊。

3、volatile不如synchronized安全:

在程式碼中如果過度依賴volatile變數來控制狀態的可見性,通常會比使用鎖的程式碼更脆弱,也更難以理解。僅當volatile變數能簡化程式碼的實現以及對同步策略的驗證時,才應該使用它。一般來說,用同步機制會更安全些。

4、volatile無法同時保證記憶體可見性和原則性:

加鎖機制(即同步機制)既可以確保可見性又可以確保原子性,而volatile變數只能確保可見性,原因是宣告為volatile的簡單變數如果當前值與該變數以前的值相關,那麼volatile關鍵字不起作用,也就是說如下的表示式都不是原子操作:“count++”、“count = count+1”。

 

當且僅當滿足以下所有條件時,才應該使用volatile變數:

1、 對變數的寫入操作不依賴變數的當前值,或者你能確保只有單個執行緒更新變數的值。

2、該變數沒有包含在具有其他變數的不變式中。

 

總結:在需要同步的時候,第一選擇應該是synchronized關鍵字,這是最安全的方式,嘗試其他任何方式都是有風險的。尤其在、jdK1.5之後,對synchronized同步機制做了很多優化,如:自適應的自旋鎖、鎖粗化、鎖消除、輕量級鎖等,使得它的效能明顯有了很大的提升。

 

轉載請註明出處:jiq•欽'stechnical Blog

volatile兩大作用

1、保證記憶體可見性

2、防止指令重排

 

此外需注意volatile並不保證操作的原子性。


(一)記憶體可見性

1 概念

         JVM記憶體模型:主記憶體和執行緒獨立的工作記憶體

Java記憶體模型規定,對於多個執行緒共享的變數,儲存在主記憶體當中,每個執行緒都有自己獨立的工作記憶體(比如CPU的暫存器),執行緒只能訪問自己的工作記憶體,不可以訪問其它執行緒的工作記憶體。

工作記憶體中儲存了主記憶體共享變數的副本,執行緒要操作這些共享變數,只能通過操作工作記憶體中的副本來實現,操作完畢之後再同步回到主記憶體當中。

如何保證多個執行緒操作主記憶體的資料完整性是一個難題,Java記憶體模型也規定了工作記憶體與主記憶體之間互動的協議,定義了8種原子操作:

(1) lock:將主記憶體中的變數鎖定,為一個執行緒所獨佔

(2) unclock:將lock加的鎖定解除,此時其它的執行緒可以有機會訪問此變數

(3) read:將主記憶體中的變數值讀到工作記憶體當中

(4) load:將read讀取的值儲存到工作記憶體中的變數副本中。

(5) use:將值傳遞給執行緒的程式碼執行引擎

(6) assign:將執行引擎處理返回的值重新賦值給變數副本

(7) store:將變數副本的值儲存到主記憶體中。

(8) write:將store儲存的值寫入到主記憶體的共享變數當中。

 

通過上面Java記憶體模型的概述,我們會注意到這麼一個問題,每個執行緒在獲取鎖之後會在自己的工作記憶體來操作共享變數,操作完成之後將工作記憶體中的副本回寫到主記憶體,並且在其它執行緒從主記憶體將變數同步回自己的工作記憶體之前,共享變數的改變對其是不可見的。即其他執行緒的本地記憶體中的變數已經是過時的,並不是更新後的值。


2 記憶體可見性帶來的問題

很多時候我們需要一個執行緒對共享變數的改動,其它執行緒也需要立即得知這個改動該怎麼辦呢?下面舉兩個例子說明記憶體可見性的重要性:

 

例子1

有一個全域性的狀態變數open:

boolean open=true;

這個變數用來描述對一個資源的開啟關閉狀態,true表示開啟,false表示關閉,假設有一個執行緒A,在執行一些操作後將open修改為false:

 

//執行緒A

resource.close();

open = false;

 

執行緒B隨時關注open的狀態,當open為true的時候通過訪問資源來進行一些操作:

//執行緒B

while(open) {

doSomethingWithResource(resource);

}

 

當A把資源關閉的時候,open變數對執行緒B是不可見的,如果此時open變數的改動尚未同步到執行緒B的工作記憶體中,那麼執行緒B就會用一個已經關閉了的資源去做一些操作,因此產生錯誤。


例子2

下面是一個通過布林標誌判斷執行緒是否結束的例子:

public class CancelThreadTest {

         publicstatic void main(String[] args) throws Exception{

                   PrimeGeneratorgen = new PrimeGenerator();

                   newThread(gen).start();

                   try

                   {

                            Thread.sleep(3000);

                   }finally{

                            gen.cancel();

                   }

         }

}

 

class PrimeGenerator implements Runnable{

         privateboolean cancelled;      

         @Override

         publicvoid run() {

                   while(!cancelled)

                   {

                            System.out.println("Running...");

                            //doingsomething here...

                   }                

         }       

         publicvoid cancel(){cancelled = true;}

}

主執行緒中設定PrimeGenerator執行緒的是否取消標識,PrimeGenerator執行緒檢測到這個標識後就會結束執行緒,由於主執行緒修改cancelled變數的記憶體可見性,主執行緒修改cancelled標識後並不馬上同步回主記憶體,所以PrimeGenerator執行緒結束的時間難以把控(最終是一定會同步回主記憶體,讓PrimeGenerator執行緒結束)。

如果PrimeGenerator執行緒執行一些比較關鍵的操作,主執行緒希望能夠及時終止它,這時將cenceled用volatile關鍵字修飾就是必要的。

特別注意:上面演示這個並不是正確的取消執行緒的方法,因為一旦PrimeGenerator執行緒中包含BolckingQueue.put()等阻塞方法,那麼將可能永遠不會去檢查cancelled標識,導致執行緒永遠不會退出。正確的方法參見另外一篇關於如何正確終止執行緒的方法。


3 提供記憶體可見性

volatile保證可見性的原理是在每次訪問變數時都會進行一次重新整理,因此每次訪問都是主記憶體中最新的版本。所以volatile關鍵字的作用之一就是保證變數修改的實時可見性

 

針對上面的例子1:

要求一個執行緒對open的改變,其他的執行緒能夠立即可見,Java為此提供了volatile關鍵字,在宣告open變數的時候加入volatile關鍵字就可以保證open的記憶體可見性,即open的改變對所有的執行緒都是立即可見的。

 

針對上面的例子2:

將cancelled標誌設定的volatile保證主執行緒針對cancelled標識的修改能夠讓PrimeGenerator執行緒立馬看到。

 

備註:也可以通過提供synchronized同步的open變數的Get/Set方法解決此記憶體可見性問題,因為要Get變數open,必須等Set方完全釋放鎖之後。後面將介紹到兩者的區別。

 

(二)指令重排

1 概念

指令重排序是JVM為了優化指令,提高程式執行效率,在不影響單執行緒程式執行結果的前提下,儘可能地提高並行度。編譯器、處理器也遵循這樣一個目標。注意是單執行緒。多執行緒的情況下指令重排序就會給程式設計師帶來問題。

不同的指令間可能存在資料依賴。比如下面計算圓的面積的語句:

double r = 2.3d;//(1)

double pi =3.1415926; //(2)

double area = pi* r * r; //(3)

area的計算依賴於r與pi兩個變數的賦值指令。而r與pi無依賴關係。

as-if-serial語義是指:不管如何重排序(編譯器與處理器為了提高並行度),(單執行緒)程式的結果不能被改變。這是編譯器、Runtime、處理器必須遵守的語義。

雖然,(1) - happensbefore -> (2),(2) - happens before -> (3),但是計算順序(1)(2)(3)與(2)(1)(3) 對於r、pi、area變數的結果並無區別。編譯器、Runtime在優化時可以根據情況重排序(1)與(2),而絲毫不影響程式的結果。

指令重排序包括編譯器重排序和執行時重排序。


2 指令重排帶來的問題

如果一個操作不是原子的,就會給JVM留下重排的機會。下面看幾個例子:

例子1:A執行緒指令重排導致B執行緒出錯

對於在同一個執行緒內,這樣的改變是不會對邏輯產生影響的,但是在多執行緒的情況下指令重排序會帶來問題。看下面這個情景:

線上程A中:

context = loadContext();

inited = true;

 

線上程B中:

while(!inited ){ //根據執行緒A中對inited變數的修改決定是否使用context變數

   sleep(100);

}

doSomethingwithconfig(context);

 

假設執行緒A中發生了指令重排序:

inited = true;

context = loadContext();

 

那麼B中很可能就會拿到一個尚未初始化或尚未初始化完成的context,從而引發程式錯誤。

 

例子2:指令重排導致單例模式失效

我們都知道一個經典的懶載入方式的雙重判斷單例模式:

public class Singleton {

  private static Singleton instance = null;

  private Singleton() { }

  public static Singleton getInstance() {

     if(instance == null) {

        synchronzied(Singleton.class) {

           if(instance == null) {

               instance = new Singleton();  //非原子操作

           }

        }

     }

     return instance;

   }

}

 

看似簡單的一段賦值語句:instance= new Singleton(),但是很不幸它並不是一個原子操作,其實際上可以抽象為下面幾條JVM指令:

memory =allocate();    //1:分配物件的記憶體空間 

ctorInstance(memory);  //2:初始化物件 

instance =memory;     //3:設定instance指向剛分配的記憶體地址 

 

上面操作2依賴於操作1,但是操作3並不依賴於操作2,所以JVM是可以針對它們進行指令的優化重排序的,經過重排序後如下:

memory =allocate();    //1:分配物件的記憶體空間 

instance =memory;     //3:instance指向剛分配的記憶體地址,此時物件還未初始化

ctorInstance(memory);  //2:初始化物件

 

可以看到指令重排之後,instance指向分配好的記憶體放在了前面,而這段記憶體的初始化被排在了後面。

線上程A執行這段賦值語句,在初始化分配物件之前就已經將其賦值給instance引用,恰好另一個執行緒進入方法判斷instance引用不為null,然後就將其返回使用,導致出錯。


3 防止指令重排

除了前面記憶體可見性中講到的volatile關鍵字可以保證變數修改的可見性之外,還有另一個重要的作用:在JDK1.5之後,可以使用volatile變數禁止指令重排序。

        

解決方案:例子1中的inited和例子2中的instance以關鍵字volatile修飾之後,就會阻止JVM對其相關程式碼進行指令重排,這樣就能夠按照既定的順序指執行。

 

volatile關鍵字通過提供“記憶體屏障”的方式來防止指令被重排序,為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。

大多數的處理器都支援記憶體屏障的指令。

對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,為此,Java記憶體模型採取保守策略。下面是基於保守策略的JMM記憶體屏障插入策略:

在每個volatile寫操作的前面插入一個StoreStore屏障。

在每個volatile寫操作的後面插入一個StoreLoad屏障。

在每個volatile讀操作的後面插入一個LoadLoad屏障。

在每個volatile讀操作的後面插入一個LoadStore屏障。


(三)總結

volatile是輕量級同步機制

相對於synchronized塊的程式碼鎖,volatile應該是提供了一個輕量級的針對共享變數的鎖,當我們在多個執行緒間使用共享變數進行通訊的時候需要考慮將共享變數用volatile來修飾。

volatile是一種稍弱的同步機制,在訪問volatile變數時不會執行加鎖操作,也就不會執行執行緒阻塞,因此volatilei變數是一種比synchronized關鍵字更輕量級的同步機制。

volatile使用建議

使用建議:在兩個或者更多的執行緒需要訪問的成員變數上使用volatile。當要訪問的變數已在synchronized程式碼塊中,或者為常量時,沒必要使用volatile。

由於使用volatile遮蔽掉了JVM中必要的程式碼優化,所以在效率上比較低,因此一定在必要時才使用此關鍵字。

volatile和synchronized區別

1、volatile不會進行加鎖操作:

volatile變數是一種稍弱的同步機制在訪問volatile變數時不會執行加鎖操作,因此也就不會使執行執行緒阻塞,因此volatile變數是一種比synchronized關鍵字更輕量級的同步機制。

2、volatile變數作用類似於同步變數讀寫操作:

從記憶體可見性的角度看,寫入volatile變數相當於退出同步程式碼塊,而讀取volatile變數相當於進入同步程式碼塊。

3、volatile不如synchronized安全:

在程式碼中如果過度依賴volatile變數來控制狀態的可見性,通常會比使用鎖的程式碼更脆弱,也更難以理解。僅當volatile變數能簡化程式碼的實現以及對同步策略的驗證時,才應該使用它。一般來說,用同步機制會更安全些。

4、volatile無法同時保證記憶體可見性和原則性:

加鎖機制(即同步機制)既可以確保可見性又可以確保原子性,而volatile變數只能確保可見性,原因是宣告為volatile的簡單變數如果當前值與該變數以前的值相關,那麼volatile關鍵字不起作用,也就是說如下的表示式都不是原子操作:“count++”、“count = count+1”。

 

當且僅當滿足以下所有條件時,才應該使用volatile變數:

1、 對變數的寫入操作不依賴變數的當前值,或者你能確保只有單個執行緒更新變數的值。

2、該變數沒有包含在具有其他變數的不變式中。

 

總結:在需要同步的時候,第一選擇應該是synchronized關鍵字,這是最安全的方式,嘗試其他任何方式都是有風險的。尤其在、jdK1.5之後,對synchronized同步機制做了很多優化,如:自適應的自旋鎖、鎖粗化、鎖消除、輕量級鎖等,使得它的效能明顯有了很大的提升。

 

相關文章