寫在開頭
在之前的學習我們瞭解到,為了充分利用快取,提高程式的執行速度,編譯器在底層執行的時候,會進行指令重排序的最佳化操作,但這種最佳化,在有些時候會帶來 有序性 的問題。
那何為有序性呢?我們可以通俗理解為:程式執行的順序要按照程式碼的先後順序。
當然,之前我們還說過發生有序性問題時,我們可以透過給變數新增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成長計劃》系列博文,還有提升工作效率的小筆記、讀書心得、大廠面經、人生感悟等等,歡迎您的加入!