Java併發:volatile內存可見性和指令重排

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:

1

boolean open=true;

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

1

2

3

<strong>//線程A

resource.close();

open = false;

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

1

2

3

4

<strong>//線程B

while(open) {

doSomethingWithResource(resource);

}

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

例子2

下面是一個通過布爾標誌判斷線程是否結束的例子:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

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爲了優化指令,提高程序運行效率,在不影響單線程程序執行結果的前提下,儘可能地提高並行度。編譯器、處理器也遵循這樣一個目標。注意是單線程。多線程的情況下指令重排序就會給程序員帶來問題。

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

1

2

3

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中:

1

2

context = loadContext();

inited = true;

在線程B中:

1

2

3

4

while(!inited ){ //根據線程A中對inited變量的修改決定是否使用context變量

   sleep(100);

}

doSomethingwithconfig(context);

假設線程A中發生了指令重排序:

1

2

inited = true;

context = loadContext();

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

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

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

public class Singleton {

  private static Singleton instance = null;

  private Singleton() { }

  public static Singleton getInstance() {

     if(instance == null) {

        synchronzied(Singleton.class) {

           if(instance == null) {

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

           }

        }

     }

     return instance;

   }

}

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

1

2

3

memory =allocate();    //1:分配對象的內存空間 

ctorInstance(memory);  //2:初始化對象 

instance =memory;     //3:設置instance指向剛分配的內存地址

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

1

2

3

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的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。

(三)總結

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同步機制做了很多優化,如:自適應的自旋鎖、鎖粗化、鎖消除、輕量級鎖等,使得它的性能明顯有了很大的提升。

 

 

轉載自:https://www.cnblogs.com/baizhanshi/p/6422926.html

發佈了61 篇原創文章 · 獲贊 287 · 訪問量 64萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章