在前面一文中咱們深刻的分享了synchronized的實現原理,也知道了synchronized是一把重量級的鎖。在Java中還有一個關鍵詞,那就是volatile。volatile是輕量級的synchronized,它在多線程中保證了變量的「可見性」。可見性的意思是當一個線程修改了一個變量的值後,另外的線程可以讀取到這個變量修改後的值。volatile在Java語言規範中的定義以下:java
Java編程語言容許線程訪問共享變量,爲了確保共享變量可以被準確和一致性的更新,線程應該確保經過排他鎖單獨獲取這個變量。編程
這句話可能說的比較繞,咱們先來看一段代碼:數組
public class VolatileTest implements Runnable { private boolean flag = false; @Override public void run() { while (!flag){ } System.out.println("線程結束運行..."); } public void setFlag(boolean flag) { this.flag = flag; } public static void main(String[] args) throws InterruptedException { VolatileTest v = new VolatileTest(); Thread t1 = new Thread(v); t1.start(); Thread.sleep(2000); v.setFlag(true); } }
這段代碼的運行結果:緩存
能夠看到儘管在代碼中調用了v.setFlag(false)方法,線程也沒有結束運行。這是由於在上面的代碼中,其實是有2個線程在運行,一個是main線程,一個是在main線程中建立的t1線程。所以咱們能夠看到在線程中的變量是互不可見的。 要理解線程中變量的可見性,咱們須要先理解Java的內存模型。安全
<font color="#EE30A7">Java內存模型</font>
在Java中,全部的實例域、靜態變量和數組元素都存儲在堆內存中,堆內存在線程之間是共享的。局部變量,方法定義參數和異常數量參數是存放在Java虛擬機棧上面的。Java虛擬機棧是線程私有的所以不會在線程之間共享,它們不存在內存可見性的問題,也不受內存模型的影響。多線程
Java內存模型(Java Memory Model 簡稱 JMM),決定一個一個線程對共享變量的寫入什麼時候對其它線程可見。JMM定義了線程和主內存之間的抽象關係:併發
線程之間共享變量存儲在主內存中,每一個線程都有一個私有的本地內存,本地內存中存儲了該線程共享變量的副本。本地內存是JMM的一個抽象機率,並不真實的存在。它涵蓋了緩存,寫緩存區,寄存器以及其餘的硬件和編譯優化。編程語言
Java內存模型的抽象概念圖以下所示:ide
看完了Java內存模型的概念,咱們再來看看內存模型中主內存是如何和線程本地內存之間交互的。性能
<font color="#EE30A7">主內存和本地內存間的交互</font>
主內存和本地內存的交互即一個變量是如何從主內存中拷貝到本地內存又是如何從本地內存中回寫到主內存中的實現,Java內存模型提供了8中操做來完成主內存和本地內存之間的交互。它們分別以下:
- <span style="color:red">lock(鎖定)</span>:做用於主內存的變量,它把一個變量標識爲一條線程獨佔的狀態。
- <span style="color:red">unlock(解鎖)</span>:做用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量才能被其它線程鎖定。
- <span style="color:red">read(讀取)</span>:做用於主內存的變量,它把一個變量從主內存傳輸到線程的本地內存中,以便隨後的load動做使用。
- <span style="color:red">load(載入)</span>:做用於本地內存的變量,它把read操做從主內存中的到的變量值放入本地內存的變量副本中。
- <span style="color:red">use(使用)</span>:做用於本地內存的變量,它把本地內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個須要使用到變量值的字節碼指令時將會執行這個操做。
- <span style="color:red">assign(賦值)</span>:做用於本地內存的變量,它把一個從執行引擎接收到的變量賦予給本地內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時將會執行這個操做。
- <span style="color:red">store(存儲)</span>:做用於本地內存的變量,它把本地內存中的變量的值傳遞給主內存中,以便後面的write操做使用。
- <span style="color:red">write(寫入)</span>:做用於主內存的變量,它把store操做從本地內存中獲得的變量的值放入主內存的變量中。
從上面8種操做中,咱們能夠看出,當一個變量從主內存複製到線程的本地內存中時,須要順序的執行read和load操做,當一個變量從本地內存同步到主內存中時,須要順序的執行store和write操做。Java內存模型只要求上述的2組操做是順序的執行的,但並不要求連續執行。好比對主內存中的變量a 和 b 進行訪問時,有可能出現的順序是read a read b load b load a。除此以外,Java內存模型還規定了在執行上述8種基本操做時必須知足如下規則:
-
不容許read和load,store和write操做單獨出現,這2組操做必須是成對的。
-
不容許一個線程丟棄它最近的assign操做。即變量在線程的本地內存中改變後必須同步到主內存中。
-
不容許一個線程無緣由的把數據從線程的本地內存同步到主內存中。
-
不容許線程的本地內存中使用一個未被初始化的變量。
-
一個變量在同一時刻只容許一個線程對其進行lock操做,可是一個線程能夠對一個變量進行屢次的lock操做,當線程對同一變量進行了屢次lock操做後須要進行一樣次數的unlock操做才能將變量釋放。
-
若是一個變量執行了lock操做,則會清空本地內存中變量的拷貝,當須要使用這個變量時須要從新執行read和load操做。
-
若是一個變量沒有執行lock操做,那麼就不能對這個變量執行unlock操做,一樣也不容許unlock一個被其它線程執行了lock操做的變量。也就是說lock 和unlock操做是成對出現的而且是在同一個線程中。
-
對一個變量執行unlock操做以前,必須將這個變量的值同步到主內存中去。
<font color="#EE30A7">volatile 內存語義之可見性</font>
大概瞭解了Java的內存模型後,咱們再看上面的代碼結果咱們將很好理解爲何是這樣子的了。首先主內存中flag的值是false,在t1線程執行時,依次執行的操做有read、load和use操做,這個時候t1線程的本地內存中flag的值也是false,線程會一直執行。當main線程調用v.setFlag(true)方法時,main線程中的falg被賦值成了true,由於使用了assign操做,所以main線程中本地內存的flag值將同步到主內存中去,這時主內存中的flag的值爲true。可是t1線程沒有再次執行read 和 load操做,所以t1線程中flag的值任然是false,因此t1線程不會終止運行。想要正確的中止t1線程,只須要在flag變量前加上volatile修飾符便可,由於volatile保證了變量的可見性。既然volatile在各個線程中是一致的,那麼volatile是否可以保證在併發狀況下的安全呢?答案是否認的,由於volatile不能保證變量的原子性。示例以下:
public class VolatileTest2 implements Runnable { private volatile int i = 0; @Override public void run() { for (int j=0;j<1000;j++) { i++; } } public int getI() { return i; } public static void main(String[] args) throws InterruptedException { VolatileTest2 v2 = new VolatileTest2(); for (int i=0;i<100;i++){ new Thread(v2).start(); } Thread.sleep(5000); System.out.println(v2.getI()); } }
這段代碼啓動了100線程,每一個線程都對i變量進行1000次的自增操做,若果這段代碼可以正確的運行,那麼正確的結果應該是100000,可是實際並不是如此,實際運行的結果是少於100000的,這是由於volatile不能保證i++這個操做的原子性。咱們用javap反編譯這段代碼,截取run()方法的代碼片斷以下:
public void run(); descriptor: ()V flags: ACC_PUBLIC Code: stack=3, locals=2, args_size=1 0: iconst_0 1: istore_1 2: iload_1 3: sipush 1000 6: if_icmpge 25 9: aload_0 10: dup 11: getfield #2 // Field i:I 14: iconst_1 15: iadd 16: putfield #2 // Field i:I 19: iinc 1, 1 22: goto 2 25: return
咱們發現i++雖然只有一行代碼,可是在Class文件中倒是由4條字節碼指令組成的。從上面字節碼片斷,咱們很容易分析出併發失敗的緣由:當getfield指令把變量i的值取到操做棧時,volatile關鍵字保證了i的值在此時的正確性,可是在執行iconst_1和iadd指令時,i的值可能已經被其它的線程改變,此時再執行putfield指令時,就會把一個過時的值回寫到主內存中去了。因爲volatile只保證了變量的可見性,在不符合如下規則的場景中,咱們任然須要使用鎖來保證併發的正確性。
- 運算結果結果並不依賴變量的當前值,或者可以確保只有單一的線程修改了變量的值
- 變量不須要與其餘的狀態變量共同參與不變約束
<font color="#EE30A7">volatile 內存語義之禁止重排序</font>
在介紹volatile的禁止重排序以前,咱們先來了解下什麼是重排序。重排序是指編譯器和處理器爲了優化程序性能而對指令進行從新排序的一種手段。那麼重排序有哪些規則呢?不可能任何代碼均可以重排序,若是是這樣的話,那麼在單線程中,咱們將不能獲得明確的知道運行的結果。重排序規則以下:
- 具備數據依賴性操做不能重排序,數據依賴性是指兩個操做訪問同一個變量,若是一個操做是寫操做,那麼這兩個操做就存在數據依賴性。
- as-if-serial語義,as-if-serial語義的意思是,無論怎麼重排序,單線程的程序執行結果是不會改變的。
既然volatile禁止重排序,那是否是重排序對多線程有影響呢?咱們先來看下面的代碼示例
public class VolatileTest3 { int a = 0; boolean flag = false; public void write(){ a = 1; // 1 flag = true; // 2 } public void read(){ if(flag){ // 3 int i = a*a; // 4 System.out.println("i的值爲:"+i); } } }
此時有2個線程A和B,線程A先執行write()方法,雖有B執行read()方法,在B線程執行到第4步時,i的結果能正確獲得嗎?結論是 不必定 ,由於步驟1和2沒有數據依賴關係,所以編譯器和處理器可能對這2個操做進行重排序。一樣步驟3和4也沒有數據依賴關係,編譯器和處理器也能夠對這個2個操做進行重排序,咱們來看看這兩中重排序帶來的效果:
重上面圖片,這2組重排序都會破壞多線程的運行結果。瞭解了重排序的機率和知道了重排序對多線程的影響,咱們知道了volatile爲何須要禁止重排序,那JMM究竟是如何實現volatile禁止重排序的呢?下面咱們就來探討下JMM是如何實現volatile禁止重排序的。
前面提到過,重排序分爲編譯器重排序和處理器重排序,爲了實現volatile內存語義,JMM分別對這兩種重排序進行了如今。下圖是JMM對編譯器重排序指定的volatile規則:
從上面圖中咱們能夠分析出:
- 當第一個操做爲volatile讀時,無能第二個操做是什麼,都不容許重排序。這個規則確保了volatile讀以後的操做不能重排序到volatile讀以前。
- 當第二個操做爲volatile寫時,不管第一個操做是什麼,都不容許重排序。這個規則確保了volatile寫以前的操做不能重排序到volatile寫以後。
- 當第一個操做是volatile寫,第二個操做是volatile讀時,不容許重排序。
爲了實現volatile內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型處理器的重排序,在JMM中,內存屏障的插入策略以下:
- <font color="red">在每一個volatile寫操做以前插入一個StoreStore屏障</font>
- <font color="red">在每一個volatile寫操做以後插入一個StoreLoad屏障</font>
- <font color="red">在每一個volatile讀操做以後插入一個LoadLoad屏障</font>
- <font color="red">在每一個volatile讀操做以後插入一個LoadStore屏障</font>
StoreStore屏障能夠保證在volatile寫以前,前面全部的普通讀寫操做同步到主內存中
StoreLoad屏障能夠保證防止前面的volatile寫和後面有可能出現的volatile度/寫進行重排序
LoadLoad屏障能夠保證防止下面的普通讀操做和上面的volatile讀進行重排序
LoadStore屏障能夠保存防止下面的普通寫操做和上面的volatile讀進行重排序
上面的內存屏障策略能夠保證任何程序都能獲得正確的volatile內存語義。咱們如下面代碼來分析
public class VolatileTest3 { int a = 0; volatile boolean flag = false; public void write(){ a = 1; // 1 flag = true; // 2 } public void read(){ if(flag){ // 3 int i = a*a; // 4 } } }
經過上面的示例咱們分析了volatile指令的內存屏蔽策略,可是這種內存屏障的插入策略是很是保守的,在實際執行時,只要不改變volatile寫/讀的內存語義,編譯器能夠根據具體狀況來省略沒必要要的屏障。以下示例:
class VolatileBarrierExample { int a; volatile int v1 = 1; volatile int v2 = 2; void readAndWrite() { int i = v1; // 第一個volatile讀 int j = v2; // 第二個volatile讀 a = i + j; // 普通寫 v1 = i + 1; // 第一個volatile寫 v2 = j * 2; // 第二個 volatile寫 } }
上述代碼,編譯器在生成字節碼時,可能作了以下優化