Java記憶體可見性volatile
概述
JMM規範指出,每一個執行緒都有自己的工作記憶體(working memory),當變數的值發生變化時,先更新自己的工作記憶體,然後再拷貝到主存(main memory),這樣其他執行緒就能讀取到更新後的值了。
注意:工作記憶體和主存是JMM規範裡抽象的概念,在JVM的記憶體模型下,可以將CPU快取對應作執行緒工作記憶體,將JVM堆記憶體對應主存。
寫執行緒更新後的值何時拷貝到主存?讀執行緒何時從主存中獲取變數的最新值?hotspotJVM中引入volatile關鍵字來解決這些問題,當某個變數被volatile關鍵字修飾後,多執行緒對該變數的操作都將直接在主存中進行。在CPU時鐘順序上,某個寫操作執行完成後,後續的讀操作一定讀取的都是最新的值。
記憶體可見性帶來的問題
如下程式碼片段,寫執行緒每隔1秒遞增共享變數counter,讀執行緒是個死迴圈,如果讀執行緒始終能讀取到counter的最新值,那麼最終的輸出應該是 12345。
public class App { // 共享變數 static int counter = 0; public static void main(String[] args) { Thread thread1 = new Thread(() -> { int temp = 0; while (true) { if (temp != counter) { temp = counter; // 列印counter的值,期望列印 12345 System.out.print(counter); } } }); Thread thread2 = new Thread(() -> { for (int i = 0; i < 5; i++) { counter++; // 等待1秒,給讀執行緒足夠的時間讀取變數counter的最新值 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } // 退出程式 System.exit(0); }); thread1.start(); thread2.start(); } }
在沒有volatile的情況下,實際的輸出結構如下:
1
Process finished with exit code 0
通過volatile解決問題
將共享變數用volatile關鍵字修飾即可,如下:
// 共享變數
static volatile int counter = 0;
再次執行程式,輸出結果如下:
12345
Process finished with exit code 0
綜上,volatile關鍵字使得各個執行緒對共享變數的操作變得一致。在非volatile欄位上做更新操作時,無法保證其修改後的值何時從工作記憶體(CPU快取)重新整理到主存。對於非volatile欄位的讀操作也是如此,無法保證執行緒何時從主存中讀取最新的值。
volatile無法保證執行緒安全性
如下程式碼片段,多個執行緒同時遞增一個計數器:
public class App {
// 共享變數
static volatile int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter++;
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter++;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("總和:" + counter);
}
輸入結果:
總和:12374
如果volatile能保證執行緒安全,那麼輸出結果應該是20000,但上面的程式碼輸出12374,所以說,volatile不能解決執行緒安全(thread)的問題。
所以,還是要通過其他手段來解決多執行緒安全的問題,比如synchronized。
volatile和synchronized的區別
在上述的程式碼示例中,我們並沒有涉及到多執行緒競態(race condition)的問題,核心點是“多執行緒情況下,對共享變數的寫入如何被其他執行緒及時讀取到”。
synchronized關鍵字是Java中最常用的鎖機制,保證臨界區(critical section)中的程式碼在同一個時間只能有一個執行緒執行,臨界區中使用的變數都將直接從主存中讀取,對變數的更新也會直接重新整理到主存中。所以利用synchronized也能解決記憶體可見性問題。
程式碼如下:
public class App {
// 共享變數
static int counter = 0;
public static void main(String[] args) {
// 讀取變數的執行緒
Thread readThread = new Thread(() -> {
int temp = 0;
while (true) {
synchronized (App.class) {
if (temp != counter) {
temp = counter;
// 列印counter的值,期望列印 12345
System.out.print(counter);
}
}
}
});
// 修改變數的執行緒
Thread writeThread = new Thread(() -> {
for (int i = 0; i < 5; i++) {
synchronized (App.class) {
counter++;
}
// 等待1秒,給讀執行緒足夠的時間讀取變數counter的最新值
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.exit(0);
});
readThread.start();
writeThread.start();
}
}
執行,輸入結果:
12345
Process finished with exit code 0
雖然通過synchronized也能解決記憶體可見性的問題,但是這個解決方案也帶來了其他問題,比如效能會比較差。
總結
多執行緒可以提升程式的執行速度,充分利用多核CPU的算力,但多執行緒也是“惡魔”,會給程式設計師帶來很多問題,比如本文中的記憶體可見性問題。volatile可以使變數的更新及時重新整理到主存,變數的讀取也是直接從主存中獲取,保證了資料的記憶體一致性。但是volatile不是用來解決執行緒安全問題的,無法替代鎖機制。
參考:
[1] Java Memory Model - Visibility problem, fixing with volatile variable
[2] Guide to the Volatile Keyword in Java
[3] Managing volatility
[4] Java Volatile Keyword
[5] Thread and Lo