寫在開頭
在之前的幾篇博文中,我們都提到了 volatile
關鍵字,這個單詞中文釋義為:不穩定的,易揮發的,在Java中代表變數修飾符,用來修飾會被不同執行緒訪問和修改的變數,對於方法,程式碼塊,方法引數,區域性變數以及例項常量,類常量多不能進行修飾。
自JDK1.5之後,官網對volatile進行了語義增強,這讓它在Java多執行緒領域越發重要!因此,我們今天就抽一晚上時間,來學一學這個關鍵字,首先,我們從標題入手,思考這樣的一個問題:
volatile是如何保證可見性的?又是如何禁止指令重排的,它為什麼不能實現原子性呢?
帶著疑問,我們一起走進volatile的世界,探索它與可見性,有序性,原子性之間的愛恨情仇!
volatile如何保證可見性?
volatile保證了不同執行緒對共享變數進行操作時的可見性,即一個執行緒修改了共享變數的值,共享變數修改後的值對其他執行緒立即可見。
我們先透過之前寫的一個小案例來感受一下什麼是可見性問題:
【程式碼示例1】
public class Test {
//是否停止 變數
private static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
//啟動執行緒 1,當 stop 為 true,結束迴圈
new Thread(() -> {
System.out.println("執行緒 1 正在執行...");
while (!stop) ;
System.out.println("執行緒 1 終止");
}).start();
//休眠 1 秒
Thread.sleep(1000);
//啟動執行緒 2, 設定 stop = true
new Thread(() -> {
System.out.println("執行緒 2 正在執行...");
stop = true;
System.out.println("設定 stop 變數為 true.");
}).start();
}
}
輸出:
執行緒 1 正在執行...
執行緒 2 正在執行...
設定 stop 變數為 true.
原因:
我們會發現,執行緒1執行起來後,休眠1秒,啟動執行緒2,可即便執行緒2把stop設定為true了,執行緒1仍然沒有停止,這個就是因為 CPU 快取導致的可見性導致的問題。執行緒 2 設定 stop 變數為 true,執行緒 1 在 CPU 1上執行,讀取的 CPU 1 快取中的 stop 變數仍然為 false,執行緒 1 一直在迴圈執行。
那這個問題怎麼解決呢?很好解決!我們排volatile上場可以秒搞定,只需要給stop變數加上volatile修飾符即可!
【程式碼示例2】
//給stop變數增加volatile修飾符
private static volatile boolean stop = false;
輸出:
執行緒 1 正在執行...
執行緒 2 正在執行...
設定 stop 變數為 true.
執行緒 1 終止
從結果中看,執行緒1成功的讀取到了執行緒而設定為true的stop變數值,解決了可見性問題。那volatile到底是什麼讓變數在多個執行緒之間保持可見性的呢?請看下圖!
如果我們將變數宣告為 volatile ,這就指示 JVM,這個變數是共享且不穩定的,每次使用它都到主存中進行讀取,具體實現可總結為5步。
- 1️⃣在生成最低成彙編指令時,對volatile修飾的共享變數寫操作增加Lock字首指令,Lock 字首的指令會引起 CPU 快取寫回記憶體;
- 2️⃣CPU 的快取回寫到記憶體會導致其他 CPU 快取了該記憶體地址的資料無效;
- 3️⃣volatile 變數透過快取一致性協議保證每個執行緒獲得最新值;
- 4️⃣快取一致性協議保證每個 CPU 透過嗅探在匯流排上傳播的資料來檢查自己快取的值是不是修改;
- 5️⃣當 CPU 發現自己快取行對應的記憶體地址被修改,會將當前 CPU 的快取行設定成無效狀態,重新從記憶體中把資料讀到 CPU 快取。
volatile如何保證有序性?
在之前的學習我們瞭解到,為了充分利用快取,提高程式的執行速度,編譯器在底層執行的時候,會進行指令重排序的最佳化操作,但這種最佳化,在有些時候會帶來 有序性 的問題。
那何為有序性呢?我們可以通俗理解為:程式執行的順序要按照程式碼的先後順序。
當然,之前我們還說過發生有序性問題時,我們可以透過給變數新增volatile修飾符進行解決。
首先,我們來回顧一下之前寫的一個關於有序性問題的測試類。
【程式碼示例1】
int a = 1;(1)
int b = 2;(2)
int c = a + b;(3)
上面的這段程式碼中,c變數依賴a,b的值,因此,在編譯器最佳化重排時,c肯定會在a,b賦值以後執行,但a,b之間沒有依賴關係,可能會發生重排序,但這種重排序即便到了多執行緒中依舊不會存在問題,因為即便重排對執行結果也無影響。
但有些時候,指令重排序可以保證序列語義一致,但是沒有義務保證多執行緒間的語義也一致,我們繼續看下面這段程式碼:
【程式碼示例2】
public class Test {
private static int num = 0;
private static boolean ready = false;
//禁止指令重排,解決順序性問題
//private static volatile boolean ready = false;
public static class ReadThread extends Thread {
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
if (ready) {//(1)
System.out.println(num + num);//(2)
}
System.out.println("讀取執行緒...");
}
}
}
public static class WriteRead extends Thread {
@Override
public void run() {
num = 2;//(3)
ready = true;//(4)
System.out.println("賦值執行緒...");
}
}
public static void main(String[] args) throws InterruptedException {
ReadThread rt = new ReadThread();
rt.start();
WriteRead wr = new WriteRead();
wr.start();
Thread.sleep(10);
rt.interrupt();
System.out.println("rt stop...");
}
}
我們定義了2個執行緒,一個用來求和操作,一個用來賦值操作,因為定義的是成員變數,所以程式碼(1)(2)(3)(4)之間不存在依賴關係,在執行時極可能發生指令重排序,如將(4)在(3)前執行,順序為(4)(1)(3)(2),這時輸出的就是0而不是4,但在很多效能比較好的電腦上,這種重排序情況不易復現。
這時,我們給ready 變數新增一個volatile關鍵字,就成功的解決問題了。
volatile關鍵字可以禁止指令重排的原因主要有兩個!
一、3 個 happens-before 規則的實現
- 對一個 volatile 變數的寫 happens-before 任意後續對這個 volatile 變數的讀;
- 一個執行緒內,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作;
- happens-before 傳遞性,A happens-before B,B happens-before C,則 A happens-before C。
二、記憶體屏障
變數宣告為 volatile 後,在對這個變數進行讀寫操作的時候,會透過插入特定的 記憶體屏障
的方式來禁止指令重排序。
記憶體屏障(Memory Barrier 又稱記憶體柵欄,是一個 CPU 指令),為了實現volatile 記憶體語義,volatile 變數的寫操作,在變數的前面和後面分別插入記憶體屏障;volatile 變數的讀操作是在後面插入兩個記憶體屏障。
具體屏障規則:
- 在每個 volatile 寫操作的前面插入一個 StoreStore 屏障;
- 在每個 volatile 寫操作的後面插入一個 StoreLoad 屏障;
- 在每個 volatile 讀操作的後面插入一個 LoadLoad 屏障;
- 在每個 volatile 讀操作的後面插入一個 LoadStore 屏障。
屏障說明:
- StoreStore:禁止之前的普通寫和之後的 volatile 寫重排序;
- StoreLoad:禁止之前的 volatile 寫與之後的 volatile 讀/寫重排序;
- LoadLoad:禁止之後所有的普通讀操作和之前的 volatile 讀重排序;
- LoadStore:禁止之後所有的普通寫操作和之前的 volatile 讀重排序。
OK,知道了這些內容之後,我們再回頭看程式碼示例2中,增加了volatile關鍵字後的執行順序,在賦值執行緒啟動後,執行順序會變成(3)(4)(1)(2),這時列印的結果就為4啦!
volatile為什麼不能保證原子性?
我們講完了volatile修飾符保證可見性與有序性的內容,接下來我們思考另外一個問題,它能夠保證原子性嗎?為什麼?我們依舊透過一段程式碼去證明一下!
【程式碼示例3】
public class Test {
//計數變數
static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
//執行緒 1 給 count 加 10000
Thread t1 = new Thread(() -> {
for (int j = 0; j <10000; j++) {
count++;
}
System.out.println("thread t1 count 加 10000 結束");
});
//執行緒 2 給 count 加 10000
Thread t2 = new Thread(() -> {
for (int j = 0; j <10000; j++) {
count++;
}
System.out.println("thread t2 count 加 10000 結束");
});
//啟動執行緒 1
t1.start();
//啟動執行緒 2
t2.start();
//等待執行緒 1 執行完成
t1.join();
//等待執行緒 2 執行完成
t2.join();
//列印 count 變數
System.out.println(count);
}
}
我們建立了2個執行緒,分別對count進行加10000操作,理論上最終輸出的結果應該是20000萬對吧,但實際並不是,我們看一下真實輸出。
輸出:
thread t1 count 加 10000 結束
thread t2 count 加 10000 結束
14281
原因:
Java 程式碼中 的 count++並非原子的,而是一個複合性操作,至少需要三條CPU指令:
- 指令 1:把變數 count 從記憶體載入到CPU的暫存器
- 指令 2:在暫存器中執行 count + 1 操作
- 指令 3:+1 後的結果寫入CPU快取或記憶體
即使是單核的 CPU,當執行緒 1 執行到指令 1 時發生執行緒切換,執行緒 2 從記憶體中讀取 count 變數,此時執行緒 1 和執行緒 2 中的 count 變數值是相等,都執行完指令 2 和指令 3,寫入的 count 的值是相同的。從結果上看,兩個執行緒都進行了 count++,但是 count 的值只增加了 1。這種情況多發生在cpu佔用時間較長的執行緒中,若單執行緒對count僅增加100,那我們就很難遇到執行緒的切換,得出的結果也就是200啦。
要想解決也很簡單,利用 synchronized、Lock或者AtomicInteger都可以,我們在後面的文章中會聊到的,請繼續保持關注哦!
結尾彩蛋
如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!
如果您想與Build哥的關係更近一步,還可以關注“JavaBuild888”,在這裡除了看到《Java成長計劃》系列博文,還有提升工作效率的小筆記、讀書心得、大廠面經、人生感悟等等,歡迎您的加入!