存儲結構圖
對於現代CPU,廣告中我們經常可以聽到一個三級緩存的概念。其中第一級和第二級緩存,爲每個CPU核心獨享,第三級緩存,是所有CPU核心共享,緩存集成在CPU內部,讀取寫入速度非常快,但是容量很小
再往下一級便是我們的內存,容量大,讀寫速度相對較快,
再往下便是我們的磁盤, 容量可以非常大,但是讀寫速度比較一般。
那整個計算機存儲系統是如何工作的呢?假設我們CPU核心1執行到了某一條指令,需要讀入標識爲A的對象數據:
- 核心1去自己的第一級緩存L0中嘗試獲取數據A,如果沒有則嘗試從L1中嘗試獲取,如果還是沒有,則依次往下進行嘗試查找,比如當在L3中找到數據A之後,將數據寫入L2,再讀入L1,再L0
- 當下次還需要讀取數據A時,如果L0中還存在該數據,直接讀取,如果沒有了,重複步驟1
數據一致性
現代CPU一般都會多個核心,每個核心獨立工作,假設現有2個核心,分別開啓線程,將主內存中的變量X和Y分別讀進自己的一級二級緩存,執行對應的業務邏輯,如果程序運行中,CPU1將X值修改成了1,CPU2將X值修改成了2,當線程執行完畢,將X數據刷新進主存時,CPU1需要寫入的值是1,而CPU2需要寫入的值卻是2,產生數據不一致問題。
如何解決各個CPU核心之間的數據不一致問題呢?主要有以下兩種方式:
- 總線鎖(bus lock)
- 緩存一致性協議
總線鎖
概念:總線,也叫CPU總線,是CPU與芯片組成連接的主幹道,負責CPU與外界所有部件的通信,包括高速緩存,內存,向各個部件發送控制信號、尋址、數據傳輸等。
總線鎖:當CPU1需要執行某數據項時,其在總線上發出LOCK信號,這樣其他CPU,如CPU2將不能操作該數據項變量的內存地址緩存,以此阻塞住其他所有CPU核心,CPU1獨享此共享內存。
缺點:代價較大,在鎖定期間,其他CPU只能等待。
緩存一致性協議
概念:當CPU某一塊核心,修改了它自己的L1、L2緩存中的數據,通知其他CPU核心不要再使用它們內部的緩存,從主內存中重新讀取。
注意,它只是一個協議,每個CPU廠家的具體實現邏輯是不一樣的,有MESI,MSI等多種。以常用的Intel CPU爲例,使用的MESI協議,簡單介紹如下:
對緩存數據定義了以下這幾種狀態:
- modified(修改):當前CPU對該緩存塊進行了修改,必須要寫回主存,其他CPU不能再緩存原有信息
- exclusive(獨享):當前CPU獨享該緩存塊,其他CPU不能裝載該緩存塊
- share(共享):該緩存塊同時被多個CPU裝載讀取,未被修改
- invalid(無效):該緩存塊數據,已經被其他CPU修改了,當前CPU對該緩存塊的緩存失效,不能再使用
問題:
- 會涉及緩存行對齊的問題,下面會講
- 某些數據很大的時候,無法進行緩存,還是需要使用總線鎖
緩存行對齊
設想一下這樣一個問題,當CPU運行到某一條指令時,需要讀取一個變量a,那CPU讀取數據時,是否僅僅是讀取a這一條數據呢?CPU爲了提高效率,會在內存中一次性讀取一整行的數據(一個cache line),現在CPU一般是64個字節
還是以上面的圖示爲例:
現有x,y兩個數據在內存同一行,CPU有CPU1和CPU2兩個核心分別執行不同的計算任務,CPU1任務中只需要用到x,CPU2任務重只需要用到y。
下面就會有這樣的一種場景,CPU1和CPU2會同時將x,y兩條數據都緩存進自己的L1、L2,然後CPU1不停的修改x數據,根據緩存一致性協議,需要不停的通知CPU2,讓CPU2不要再使用x的緩存,需要到主存中重新獲取。但是實際上CPU2裏的任務根本就不要用到x,還不得不不間斷的從主存中加載x和y。返回來當CPU2修改了Y值,CPU1同樣也是有這樣的問題。
因緩存行問題,導致CPU的高速緩存區失去了作用,需要不停的讀寫主存。
小demo
現在有一個class A,內部定義一個變x,有兩個線程分別負責不停的修改A1和A2。兩個程序唯一不同的點就是class A,在第二次運行時,在變量x之前,加了7個long類型的變量。對比下兩次運行時間,可以看到第二次耗時明顯小於第一次。這是爲什麼呢?
我們看下第一次運行時內存情況:
數組合計佔16個字節,A1和A2這兩個對象會在一個緩存行裏,CPU1在讀取A1時會順帶着將A2頁讀取,CPU2在讀取A2時會順帶着將A1頁讀取。當CPU1不停修改A1時,需要通知CPU2去主存中讀取新的A1,同理當CPU2不停修改A2時,需要通知CPU1去主存中讀取新的A2,直接導致兩個CPU的高速緩存失效。
我們再來看下第二次運行時的情況:
第二次運行時,對象中合計有8個long類型字段,佔64字節,如此CPU不能同時讀取A1和A2,也即A1和A2在兩個緩存行中。
CPU1在讀取A1時,不會讀取到A2,同理CPU2在讀取A2時,不會讀取到A1,後續每次對變量X的修改,都在各個CPU的內部高速緩存中進行,所以執行效率大幅提升。
WCBuffer(write combining buffer)
當CPU執行存儲指令時,會優先試圖將數據寫入離CPU最近的L1,如果在L1上沒有命中緩存,則會依次去訪問L2,L3,主存,性能呈指數級下降。這種情況下該怎麼辦呢?這個時候就涉及到另一個緩存區:WCBuffer。
WCBuffer,也叫合併寫緩衝區。CPU會在L0緩衝區沒有命中,在請求L2緩衝區所有權過程中,將待寫入的數據,先寫入WCBuffer,其大小與一個cache line相同,64字節。該緩衝區允許CPU在訪問該緩衝區數據時同時執行其他指令。
後續對一個緩存行的寫操作,在寫操作執行提交到L2之前,會在WCBuffer合併寫。比如先將緩存行數據修改爲1,再修改爲2,再修改爲3,那提交到L2的數據將直接是最終數據3。
public class Main {
private static final int ITERATIONS = Integer.MAX_VALUE;
private static final int ITEMS = 1 << 24;
private static final int MASK = ITEMS - 1;
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];
private static final byte[] arrayG = new byte[ITEMS];
private static final byte[] arrayH = new byte[ITEMS];
public static void main(final String[] args) {
System.out.println("case1 運行時間 = " + runCaseOne());
System.out.println("case2 運行時間 = " + runCaseTwo());
}
public static long runCaseOne() {
long start = System.nanoTime();
int i = ITERATIONS;
while (--i != 0) {
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;
arrayG[slot] = b;
arrayH[slot] = b;
}
return System.nanoTime() - start;
}
public static long runCaseTwo() {
long start = System.nanoTime();
int i = ITERATIONS;
while (--i != 0) {
int slot = (i & MASK);
byte b = (byte) i;
arrayA[slot] = b;
arrayB[slot] = b;
arrayC[slot] = b;
arrayG[slot] = b;
}
i = ITERATIONS;
while (--i != 0) {
int slot = (i & MASK);
byte b = (byte) i;
arrayD[slot] = b;
arrayE[slot] = b;
arrayF[slot] = b;
arrayH[slot] = b;
}
return System.nanoTime() - start;
}
}
本人電腦iMac,3 GHz Intel Core i5,輸出結果:
case1 運行時間 = 7358440377
case2 運行時間 = 5989994707
注:網上其他文檔裏面的數據差距可以達到1.5倍,但是case1和case2的單個運行時間是我電腦耗時的2倍,可能是我CPU運行比較快,差距沒那麼明顯。還有一些例子合計是6個數組,也是不對的。
看上面的代碼,是不是很困惑,明明case2的循環次數是case1的2倍,爲什麼case2的執行時間反而比case1短呢?
一個WCBuffer內64字節的緩衝區維護了一個64位的字段,每更新一個字節就會設置對應的位,來表示將緩衝區交換到外部緩存時哪些數據是有效的。
一個intel的CPU,在同一個時刻只能拿到4個WCBuffer緩衝區,在case1中需要連續寫入8個不同位置的內存,那麼連續更新數據寫滿緩衝區時,CPU就需要等待,將緩衝區的內容寫入L2,此時CPU是處於強制暫停狀態。一個循環內的剩餘數據,需要等待下一次填滿緩衝區。
而case2中,每一次循環正好是4個不同位置的內存,因合併寫緩衝區滿到引起的cpu暫停的次數會大大減少,實現了執行效率的提示。
這意味着,在一個循環中,我們不應該同時寫超過4個不同的內存位置,否則我們將可能不能享受到合併寫(write combining)的好處。
CPU亂序執行
處理器基本上會按照程序中書寫的機器指令的順序執行。按照書寫順序執行稱爲按序執行(In-Order )。按照書寫順序執行時,如果從內存讀取數據的加載指令、除法運算指令等延遲(等待結果的時間)較長的指令後面緊跟着使用該指令結果的指令,就會陷入長時間的等待。儘管這種情況無可奈何,但有時,再下一條指令並不依賴於前面那條延遲較長的指令,只要有了操作數就能執行。這種情況就會產生輸出結果和預計不符。
CPU 內存屏障
爲了避免在某些情況下出現CPU指令的亂序執行,便出現了CPU的內存屏障技術,注意這裏說的是CPU的內存屏障技術,不是JVM的內存屏障。各個品牌的CPU對屏障的定義和處理不一樣,還是以intel爲例:
intel有3中內存屏障:
- sfence:store fence,寫屏障,在sfence指令前的寫操作必須在sfence指令後的寫操作前完成。
- lfence:load fence,讀屏障,在lfence指令前的讀屏障操作必須在lfence指令後的讀操作前完成。
- mfence:讀寫屏障,在mfence指令前的讀寫操作必須在mfence指令後的讀寫操作前完成。
是不是沒看懂啥意思?以sfence屏障指令爲例:
目前有兩條寫指令1和2,中間插入一條sfence,表示寫指令1必須先執行,然後再執行寫指令2,兩者不可以換順序。lfence和mfence同理可得。
彙編指令,“Lock”就是一個讀寫屏障,相信很多人反編譯Class文件後會看到有Lock指令,其實這就是JVM幫我們加的CPU內存屏障指令。
JVM內存屏障
JVM的內存屏障,一定是基於CPU內存屏障來的。JVM對內存讀寫屏障進行了組合:
- LoadLoad屏障
- StoreStore屏障
- LoadStore屏障
- StoreLoad屏障
以LoadLoad屏障爲例,其他幾個類似:
LoadLoad指令要求,必須Load1先執行,然後再執行Load2