最通俗易懂的 volatile 關鍵字詳解,看完不懂你打我

Java面試筆試面經、Java技術每天學習一點

作者:fumitzuki

volatile關鍵字是由JVM提供的最輕量級同步機制。與被濫用的synchronized不同,我們並不習慣使用它。想要正確且完全的理解它並不容易。

Java記憶體模型

Java記憶體模型由Java虛擬機器規範定義,用來遮蔽各個平臺的硬體差異。簡單來說:

所有變數儲存在主記憶體。

每條執行緒擁有自己的工作記憶體,其中儲存了主記憶體中執行緒使用到的變數的副本。

執行緒不能直接讀寫主記憶體中的變數,所有操作均在工作記憶體中完成。

執行緒,主記憶體,工作記憶體的互動關係如圖。

最通俗易懂的 volatile 關鍵字詳解,看完不懂你打我

記憶體間的互動操作有很多,和volatile有關的操作為:

read(讀取):作用於主記憶體變數,把一個變數值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用

load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。

use(使用):作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作。

assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦值給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。

store(儲存):作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體中,以便隨後的write的操作。

write(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中一個變數的值傳送到主記憶體的變數中。

對被volatile修飾的變數進行操作時,需要滿足以下規則:

規則1:執行緒對變數執行的前一個動作是load時才能執行use,反之只有後一個動作是use時才能執行load。執行緒對變數的read,load,use動作關聯,必須連續一起出現。——-這保證了執行緒每次使用變數時都需要從主存拿到最新的值,保證了其他執行緒修改的變數本執行緒能看到。

規則2:執行緒對變數執行的前一個動作是assign時才能執行store,反之只有後一個動作是store時才能執行assign。執行緒對變數的assign,store,write動作關聯,必須連續一起出現。——-這保證了執行緒每次修改變數後都會立即同步回主記憶體,保證了本執行緒修改的變數其他執行緒能看到。

規則3:有執行緒T,變數V、變數W。假設動作A是T對V的use或assign動作,P是根據規則2、3與A關聯的read或write動作;動作B是T對W的use或assign動作,Q是根據規則2、3與B關聯的read或write動作。如果A先與B,那麼P先與Q。————這保證了volatile修飾的變數不會被指令重排序最佳化,程式碼的執行順序與程式的順序相同。

使用volatile關鍵字的特性

1。被volatile修飾的變數保證對所有執行緒可見。

由上文的規則1、2可知,volatile變數對所有執行緒是立即可見的,在各個執行緒中不存在一致性問題。那麼,我們是否能得出結論:volatile變數在併發運算下是執行緒安全的呢?

這確實是一個非常常見的誤解,寫個簡單的例子:

public class VolatileTest extends Thread{

static volatile int increase = 0;

static AtomicInteger aInteger=new AtomicInteger();//對照組

static void increaseFun() {

increase++;

aInteger。incrementAndGet();

}

public void run(){

int i=0;

while (i

increaseFun();

i++;

}

}

public static void main(String[] args) {

VolatileTest vt = new VolatileTest();

int THREAD_NUM = 10;

Thread[] threads = new Thread[THREAD_NUM];

for (int i = 0; i

threads[i] = new Thread(vt, “執行緒” + i);

threads[i]。start();

}

//idea中會返回主執行緒和守護執行緒,如果用Eclipse的話改為1

while (Thread。activeCount() > 2) {

Thread。yield();

}

System。out。println(“volatile的值: ”+increase);

System。out。println(“AtomicInteger的值: ”+aInteger);

}

}

這個程式我們跑了10個執行緒同時對volatile修飾的變數進行10000的自增操作(AtomicInteger實現了原子性,作為對照組),如果volatile變數是併發安全的話,執行結果應該為100000,可是多次執行後,每次的結果均小於預期值。顯然上文的說法是有問題的。

圖片

volatile修飾的變數並不保值原子性,所以在上述的例子中,用volatile來保證執行緒安全不靠譜。我們用Javap對這段程式碼進行反編譯,為什麼不靠譜簡直一目瞭然:

getstatic指令把increase的值拿到了操作棧的頂部,此時由於volatile的規則,該值是正確的。

iconst_1和iadd指令在執行的時候increase的值很有可能已經被其他執行緒加大,此時棧頂的值過期。

putstatic指令接著把過期的值同步回主存,導致了最終結果較小。

volatile關鍵字只保證可見性,所以在以下情況中,需要使用鎖來保證原子性:

運算結果依賴變數的當前值,並且有不止一個執行緒在修改變數的值。

變數需要與其他狀態變數共同參與不變約束

那麼volatile的這個特性的使用場景是什麼呢?

模式1:狀態標誌

模式2:獨立觀察(independent observation)

模式3:“volatile bean” 模式

模式4:開銷較低的“讀-寫鎖”策略

具體場景:

https://blog。csdn。net/vking_wang/article/details/9982709

2。禁止指令重排序最佳化。

由上文的規則3可知,volatile變數的第二個語義是禁止指令重排序。指令重排序是什麼?簡單點說就是

jvm會把程式碼中沒有依賴賦值的地方打亂執行順序,由於一些規則限定,我們在單執行緒內觀察不到打亂的現象(執行緒內表現為序列的語義),但是在併發程式中,從別的執行緒看另一個執行緒,操作是無序的。

一個非常經典的指令重排序例子:

最通俗易懂的 volatile 關鍵字詳解,看完不懂你打我

這是單例模式中的“雙重檢查加鎖模式”,我們看到instance用了volatile修飾,由於 可分解為:

//分配物件的記憶體空間

//初始化物件

//設定instance指向剛分配的記憶體地址

操作2依賴1,但是操作3不依賴2,所以有可能出現1,3,2的順序,當出現這種順序的時候,雖然instance不為空,但是物件也有可能沒有正確初始化,會出錯。

總結

併發三特徵可見性和有序性和原子性中,volatile透過新值立即同步到主記憶體和每次使用前從主記憶體重新整理機制保證了可見性。透過禁止指令重排序保證了有序性。無法保證原子性。

而我們知道,synchronized關鍵字透過lock和unlock操作保證了原子性,透過對一個變數unlock前,把變數同步回主記憶體中保證了可見性,透過一個變數在同一時刻只允許一條執行緒對其進行lock操作保證了有序性。

他的“萬能”也間接導致了我們對synchronized關鍵字的濫用,越泛用的控制,對效能的影響也越大,雖然jvm不斷的對synchronized關鍵字進行各種各樣的最佳化,但是我們還是要在合適的時候想起volatile關鍵字啊,哈哈哈哈。