您好,登錄后才能下訂單哦!
這篇文章將為大家詳細講解有關volatile的實現原理是什么,文章內容質量較高,因此小編分享給大家做個參考,希望大家閱讀完這篇文章后對相關知識有一定的了解。
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的內存模型。
在Java中,所有的實例域、靜態變量和數組元素都存儲在堆內存中,堆內存在線程之間是共享的。局部變量,方法定義參數和異常數量參數是存放在Java虛擬機棧上面的。Java虛擬機棧是線程私有的因此不會在線程之間共享,它們不存在內存可見性的問題,也不受內存模型的影響。
Java內存模型(Java Memory Model 簡稱 JMM),決定一個一個線程對共享變量的寫入何時對其它線程可見。JMM定義了線程和主內存之間的抽象關系:
線程之間共享變量存儲在主內存中,每個線程都有一個私有的本地內存,本地內存中存儲了該線程共享變量的副本。本地內存是JMM的一個抽象概率,并不真實的存在。它涵蓋了緩存,寫緩存區,寄存器以及其他的硬件和編譯優化。
Java內存模型的抽象概念圖如下所示:
看完了Java內存模型的概念,我們再來看看內存模型中主內存是如何和線程本地內存之間交互的。
主內存和本地內存的交互即一個變量是如何從主內存中拷貝到本地內存又是如何從本地內存中回寫到主內存中的實現,Java內存模型提供了8中操作來完成主內存和本地內存之間的交互。它們分別如下:
<span >lock(鎖定)</span>:作用于主內存的變量,它把一個變量標識為一條線程獨占的狀態。
<span >unlock(解鎖)</span>:作用于主內存的變量,它把一個處于鎖定狀態的變量釋放出來,釋放后的變量才能被其它線程鎖定。
<span >read(讀取)</span>:作用于主內存的變量,它把一個變量從主內存傳輸到線程的本地內存中,以便隨后的load動作使用。
<span >load(載入)</span>:作用于本地內存的變量,它把read操作從主內存中的到的變量值放入本地內存的變量副本中。
<span >use(使用)</span>:作用于本地內存的變量,它把本地內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用到變量值的字節碼指令時將會執行這個操作。
<span >assign(賦值)</span>:作用于本地內存的變量,它把一個從執行引擎接收到的變量賦予給本地內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時將會執行這個操作。
<span >store(存儲)</span>:作用于本地內存的變量,它把本地內存中的變量的值傳遞給主內存中,以便后面的write操作使用。
<span >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操作之前,必須將這個變量的值同步到主內存中去。
大概了解了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只保證了變量的可見性,在不符合以下規則的場景中,我們任然需要使用鎖來保證并發的正確性。
運算結果結果并不依賴變量的當前值,或者能夠確保只有單一的線程修改了變量的值
變量不需要與其他的狀態變量共同參與不變約束
在介紹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寫 } }
上述代碼,編譯器在生成字節碼時,可能做了如下優化
關于volatile的實現原理是什么就分享到這里了,希望以上內容可以對大家有一定的幫助,可以學到更多知識。如果覺得文章不錯,可以把它分享出去讓更多的人看到。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。