1. 程式人生 > 實用技巧 >【Java虛擬機器5】Java記憶體模型(硬體層面的併發優化基礎知識--指令亂序問題)

【Java虛擬機器5】Java記憶體模型(硬體層面的併發優化基礎知識--指令亂序問題)

前言

其實之前大家都瞭解過volatile,它的第一個作用是保證記憶體可見,第二個作用是禁止指令重排序。今天系統學習下為什麼CPU會指令重排。
儲存器的層次結構圖

1.CPU亂序執行指令的根源

CPU讀取資料的時候會先從離自己最近且速度最快的L1_cache快取記憶體取資料,取不到就找L2_cache,還取不到,就讀記憶體。
CPU如果一個cpu在執行的時候需要訪問的記憶體都不在cache中,cpu必須要通過記憶體匯流排到主存中取,那麼在資料返回到cpu這段時間內(這段時間cpu大致能夠執行成百上千條指令的時間,至少兩個資料量級)
在CPU看來,從記憶體讀取資料的速度慢得接受不了,CPU得找點事做,那它幹什麼呢?
答案是cpu會繼續執行其他的符合條件的指令


比如cpu有一個指令序列【指令1 指令2 指令3 …】,在指令1時需要訪問主存,在資料返回前cpu會繼續後續的和指令1在邏輯關係上沒有依賴的”獨立指令”,cpu一般是依賴指令間的記憶體引用關係來判斷的指令間的”獨立關係”,具體細節可參見各cpu的文件。
這也是導致cpu亂序執行指令的根源之一。

2.CPU合併寫(Write Combining)技術

當cpu執行儲存指令時,它會首先試圖將資料寫到離cpu最近的L1_cache, 如果此時cpu出現L1未命中,則會訪問下一級快取。速度上L1_cache基本能和cpu持平,其他的均明顯低於cpu,L2_cache的速度大約比cpu慢20-30倍,而且還存在L2_cache不命中的情況,又需要更多的週期去主存讀取。其實在L1_cache未命中以後,cpu就會使用一個另外的緩衝區,叫做合併寫儲存緩衝區。這一技術稱為合併寫入技術。

當CPU執行一個Store操作時,它將會把資料寫到離CPU最近的L1_cache的資料快取,如果這個時候發生Write miss, 則CPU將會去L2_cache快取。
這個時候,Write Combining Buffer就來了,為了減少Write Miss帶來的效能開銷,Intel和其它很多型號的CPU都引入了Write Combining 技術。
Write Combining Buffer不是程式設計時記憶體裡的Buffer,而是CPU裡面真實的儲存單元,是硬體。

當發生L1 Write Miss時,WC可以把多個對同一快取行Store操作的資料放在WC中,在程式對相應快取行(或者理解為這些資料)讀之前先合併,等到需要讀取時再一次性寫入來減少寫的次數和匯流排的壓力


此時,CPU可以在把資料放入WC後繼續執行指令,減少了很多時鐘週期的浪費。不同的CPU, WC的數量可能是不一樣的。Intel的CPU中,其實只有4個WC可以真正被我們同時使用。

這幾個Buffer非常有意思的是要求後續的寫操作都要對同一快取行進行寫操作,這樣後續的寫操作才可以被放到一起提交到L2快取。
WC緩衝區大小和一個cache line大小一致,一般都是64位元組

public final class WriteCombining {

    private static final int ITERATIONS = Integer.MAX_VALUE;
    private static final int ITEMS = 1 << 24; //ITEMS == 16777216
    private static final int MASK = ITEMS - 1;

    //每個位元組陣列大小是16777216,保證每個陣列的元素改變都不在同一個快取行
    private static final byte[] arrayA = new byte[ITEMS];
    private static final byte[] arrayB = new byte[ITEMS];
    private static final byte[] arrayC = new byte[ITEMS];
    private static final byte[] arrayD = new byte[ITEMS];
    private static final byte[] arrayE = new byte[ITEMS];
    private static final byte[] arrayF = new byte[ITEMS];

    public static void main(final String[] args) {
        for (int i = 1; i <= 3; i++) {
            System.out.println(i + " SingleLoop duration (ns) = " + runCaseOne());
            System.out.println(i + " SplitLoop  duration (ns) = " + runCaseTwo());
        }
    }

    public static long runCaseOne() {
        long start = System.nanoTime();
        int i = ITERATIONS;

        while (--i != 0) {
            /**
             * 下面的程式碼一次性通過CPU寫了7個快取行:因為6個位元組陣列肯定佔6個不同的快取行,還有一個int的賦值。
             * 一次性的WC緩衝區是裝不下的,因為一個CPU只有4個WC緩衝區的位置。
             * 因此它需要先寫滿前4個到WC buffer,滿了,然後cpu需要暫停,等待WC刷到L2_cache裡去,然後再寫後3個
             */
            int slot = i & MASK;
            byte b = (byte) i;
            arrayA[slot] = b;
            arrayB[slot] = b;
            arrayC[slot] = b;
            arrayD[slot] = b;
            arrayE[slot] = b;
            arrayF[slot] = b;
        }
        return System.nanoTime() - start;
    }

    public static long runCaseTwo() {
        long start = System.nanoTime();
        int i = ITERATIONS;
        while (--i != 0) {
            /**
             * 下面的程式碼一次性通過CPU寫了4個快取行:因為3個位元組陣列肯定佔3個不同的快取行。還有一個int的賦值。
             * 因為byte b = (byte) i;可能佔另外的快取行
             * 每迴圈一次WC就會刷到L2_cache上去,CPU暫停的時間就沒那麼多。
             */
            int slot = i & MASK;
            byte b = (byte) i;
            arrayA[slot] = b;
            arrayB[slot] = b;
            arrayC[slot] = b;

        }
        i = ITERATIONS;
        while (--i != 0) {
            int slot = i & MASK;
            byte b = (byte) i;
            arrayD[slot] = b;
            arrayE[slot] = b;
            arrayF[slot] = b;
        }
        return System.nanoTime() - start;
    }
}

執行結果:我電腦效能比較差,其他人一般是一半的時間差。

1 SingleLoop duration (ns) = 10489399305
1 SplitLoop  duration (ns) = 7520512347
2 SingleLoop duration (ns) = 10046797497
2 SplitLoop  duration (ns) = 7413160235
3 SingleLoop duration (ns) = 9980700466
3 SplitLoop  duration (ns) = 8644205043

原理:
最開始,我看這個例子的時候:沒有理得非常清楚,還是懵懵懂懂的。
後面自己想了很久很久,時間無限放大後runCaseOne的執行流程應該是這樣的:
step1:CPU收到6條指令,在一個迴圈內。分別把arrayA,BCDEF的某個位置寫為b。
step2:CPU給arrayA執行寫操作,準備把它設定為b
step3:CPU查詢L1_cache是否有快取行,發現沒有。申請L2_cache的快取行許可權【請求L2快取行的所有權】,申請的過程比較慢,CPU在此期間是有能力執行很多其他指令的
step4:CPU把寫操作arrayA[slot] = b;寫到WC buffer中,這個時候WC Buffer使用了一個,總共四個。L2_cache的申請結果還沒有來。
step5:CPU把寫操作arrayB[slot] = b;arrayC[slot] = b;arrayD[slot] = b;都寫進了WC Buffer。這個時候申請還沒來。但是此時WC buffer已經使用4個,總共4個。
step6:沒辦法,CPU必須等待了。
經過上述步驟後,WC緩衝區的資料還是會在某個延時的時刻更新到外部的快取(L2_cache).如果我們能在WC緩衝區傳輸到快取之前將其儘可能填滿,這樣的效果就會提高各級傳輸匯流排的效率,以提高程式效能。
step7:L2申請下來了,WC的緩衝區4個數據能刷給L2了
step8:然後CPU繼續執行下面的寫操作

runCaseTwo函式中是每次寫入4個不同位置的記憶體,可以很好的利用合併寫緩衝區,因合併寫緩衝區滿到引起的cpu暫停的次數會大大減少,當然如果每次寫入的記憶體位置數目小於4,也是一樣的。

下面是其他文章的分析:

上面提到的合併寫存入緩衝區離cpu很近,容量為64位元組,很小了,估計很貴。數量也是有限的,一般都是4個WC緩衝,每個64位元組。WC緩衝個數(4個)是依賴cpu模型的,intel的cpu在同一時刻只能拿到4個。
因此,runCaseOne函式中連續寫入7個不同位置的記憶體,那麼當4個數據寫滿了合併寫緩衝時,cpu就要等待合併寫緩衝區更新到L2cache中,因此cpu就被強制暫停了。然而在runCaseTwo函式中是每次寫入4個不同位置的記憶體,可以很好的利用合併寫緩衝區,因合併寫緩衝區滿到引起的cpu暫停的次數會大大減少,當然如果每次寫入的記憶體位置數目小於4,也是一樣的。雖然多了一次迴圈的i++操作(實際上你可能會問,--i也是會寫入記憶體的啊,其實i這個變數儲存在了暫存器上), 但是它們之間的效能差距依然非常大。

當CPU執行儲存指令(store)時,它會嘗試將資料寫到離CPU最近的L1快取。如果此時出現快取未命中,CPU會訪問下一級快取。此時,無論是英特爾還是許多其它廠商的CPU都會使用一種稱為“合併寫(write combining)”的技術。
在請求L2快取行的所有權尚未完成時,待儲存的資料被寫到處理器自身的眾多跟快取行一樣大小的儲存緩衝區之一。這些晶片上的緩衝區允許CPU在快取子系統準備好接收和處理資料時繼續執行指令。當資料不在任何其它級別的快取中時,將獲得最大的優勢。
當後續的寫操作需要修改相同的快取行時,這些緩衝區變得非常有趣。在將後續的寫操作提交到L2快取之前,可以進行緩衝區寫合併。 這些64位元組的緩衝區維護了一個64位的欄位,每更新一個位元組就會設定對應的位,來表示將緩衝區交換到外部快取時哪些資料是有效的。

這些緩衝區的數量是有限的,且隨CPU模型而異。例如在Intel CPU中,同一時刻只能拿到4個。這意味著,在一個迴圈中,你不應該同時寫超過4個不同的記憶體位置,否則你將不能享受到合併寫(write combining)的好處。

從上面的例子可以看出,這些cpu底層特性對程式設計師並不是透明的。程式的稍微改變會帶來顯著的效能提升。對於儲存密集型的程式,更應當考慮到此到特性。

3.CPU亂序執行的Java證明

下面程式如果run()方法內的指令沒有重排序的話,x和y的值可能為如下三種:
x=0 y=1
x=1 y=0
x=1 y=1
一旦程式停止並輸出了結果,說明指令重排了。

public class T04_Disorder {
    private static int x = 0, y = 0;
    private static int a = 0, b =0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for(;;) {
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread one = new Thread(new Runnable() {
                public void run() {
                    //由於執行緒one先啟動,下面這句話讓它等一等執行緒two. 讀著可根據自己電腦的實際效能適當調整等待時間.
                    //shortWait(100000);
                    a = 1;
                    x = b;
                }
            });

            Thread other = new Thread(new Runnable() {
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            one.start();other.start();
            one.join();other.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                //System.out.println(result);
            }
        }
    }


    public static void shortWait(long interval){
        long start = System.nanoTime();
        long end;
        do{
            end = System.nanoTime();
        }while(start + interval >= end);
    }
}

執行結果:每個機器都肯定會遇到程式結束,時間問題。結果如下圖

4.CPU級別(彙編級別)的有序性保障

硬體層面的記憶體屏障(Intel x86)

sfence: store| 在sfence指令前的寫操作當必須在sfence指令後的寫操作前完成。
lfence:load | 在lfence指令前的讀操作當必須在lfence指令後的讀操作前完成。
mfence:modify/mix | 在mfence指令前的讀寫操作當必須在mfence指令後的讀寫操作前完成。

原子指令(windows的虛擬機器直接偷懶使用lock彙編指令,不知道說得對不對)

原子指令,如x86上的”lock …” 指令是一個Full Barrier,執行時會鎖住記憶體子系統來確保執行順序,甚至跨多個CPU。Software Locks通常使用了記憶體屏障或原子指令來實現變數可見性和保持程式順序

5.JVM對屏障的規範

JVM級別如何規範(JSR133)
可以參考Doug Lea的文章:The JSR-133 Cookbook for Compiler Writers

LoadLoadBarrier:
對於這樣的語句Load1; LoadLoadBarrier; Load2,
在Load2及後續讀取操作要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢。

StoreStoreBarrier:
對於這樣的語句Store1; StoreStoreBarrier; Store2,
在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。

LoadStoreBarrier:
對於這樣的語句Load1; LoadStoreBarrier; Store2,
在Store2及後續寫入操作被刷出前,保證Load1要讀取的資料被讀取完畢。

StoreLoadBarrier:
對於這樣的語句Store1; StoreLoadBarrier; Load2,
​ 在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。

6.volatile的實現細節

6.1 位元組碼層面

編譯之後,會發現位元組碼檔案在j變數前加了“ACC_VOLATILE”標記

6.2 JVM層面

volatile的底層實現是通過插入記憶體屏障,但是對於編譯器來說,發現一個最優佈置來最小化插入記憶體屏障的總數幾乎是不可能的,所以,JMM採用了保守策略。如下:

在每一個volatile寫操作前面插入一個StoreStoreBarrier
在每一個volatile寫操作後面插入一個StoreLoadBarrier
在每一個volatile讀操作後面插入一個LoadLoadBarrier
在每一個volatile讀操作後面插入一個LoadStoreBarrier
StoreStore屏障可以保證在volatile寫之前,其前面的所有普通寫操作都已經重新整理到主記憶體中。
StoreLoad屏障的作用是避免volatile寫與後面可能有的volatile讀/寫操作重排序。
LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。
LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。

6.3 OS和硬體層面

windows的volatile實現:採用lock彙編指令操作,lock add dword ptr [rsp],0h
linux的volatile實現:暫時就不知道了,有可能就是LFENCE、SFENCE、MFENCE等指令了
參考 五月的倉頡的文章:

反覆思考IA-32手冊對lock指令作用的這幾段描述,可以得出lock指令的幾個作用:
鎖匯流排,其它CPU對記憶體的讀寫請求都會被阻塞,直到鎖釋放,不過實際後來的處理器都採用鎖快取替代鎖匯流排,因為鎖匯流排的開銷比較大,鎖匯流排期間其他CPU沒法訪問記憶體
lock後的寫操作會回寫已修改的資料,同時讓其它CPU相關快取行失效,從而重新從主存中載入最新的資料
不是記憶體屏障卻能完成類似記憶體屏障的功能,阻止屏障兩遍的指令重排序

所以為什麼volatile可以實現禁止重排和記憶體可見呢?完全就是這裡的根本原因。

7.synchronized實現細節

7.1 位元組碼層面

synchronized修飾方法時
方法上加ACC_SYNCHRONIZED flag

synchronized同步塊
monitorenter
monitorexit

7.2 JVM層面

c/c++呼叫了作業系統的同步機制.

JVM規範中描述:每個物件有一個監視器鎖(monitor)。

當monitor被佔用時就會處於鎖定狀態,執行緒執行monitorenter指令時嘗試獲取monitor的所有權,過程如下:

1、如果monitor的進入數為0,則該執行緒進入monitor,然後將進入數設定為1,該執行緒即為monitor的所有者。

2、如果執行緒已經佔有該monitor,只是重新進入,則進入monitor的進入數加1.

3.如果其他執行緒已經佔用了monitor,則該執行緒進入阻塞狀態,直到monitor的進入數為0,再重新嘗試獲取monitor的所有權。

Synchronized的語義底層是通過一個monitor的物件來完成,其實wait/notify等方法也依賴於monitor物件,

這就是為什麼只有在同步的塊或者方法中才能呼叫wait/notify等方法,否則會丟擲java.lang.IllegalMonitorStateException的異常的原因。

Synchronized是通過物件內部的一個叫做監視器鎖(monitor)來實現的。

7.3 OS和硬體層面

但是監視器鎖本質又是依賴於底層的作業系統的互斥鎖(Mutex Lock)來實現的。而作業系統實現執行緒之間的切換這就需要從使用者態轉換到核心態,這個成本非常高,狀態之間的轉換需要相對比較長的時間,這就是為什麼synchronized效率低的原因。
因此,這種依賴於作業系統互斥鎖(Mutex Lock)所實現的鎖我們稱之為“重量級鎖”。
我還不確定是在synchronized優化後會不會存在這樣的問題,需要後續詳細學習併發的時候再研究了。
可以參考這篇文章瞭解優化原理:Java中Synchronized的優化原理

X86的synchronized底層組合語言: lock cmpxchg / xxx
在x86架構上,CAS被翻譯為"lock cmpxchg..."

可以參考鎖&鎖與指令原子操作的關係 & cas_Queue
鎖的深入知識需要自己後續詳細學習併發的時候再研究了。

參考彙總

現代cpu的合併寫技術對程式的影響
合併寫(write combining)
五月的倉頡_就是要你懂Java中volatile關鍵字實現原理
Java中Synchronized的優化原理
鎖&鎖與指令原子操作的關係 & cas_Queue
Java使用位元組碼和組合語言同步分析volatile,synchronized的底層實現