轉載自:http://ifeve.com/from-javaeye-cpu-cache/
http://ifeve.com/from-javaeye-false-sharing/
CPU是電腦的大腦,它負責執行程式的指令;記憶體負責存資料,包括程式自身資料。記憶體比CPU慢很多,現在擷取記憶體中的一條資料大概需要200多個CPU周期(CPU cycles),而CPU寄存器一般情況下1個CPU周期就夠了。
網頁瀏覽器為了加快速度,會在本機存緩衝以前瀏覽過的資料;傳統資料庫或NoSQL資料庫為了加速查詢,常在記憶體設定一個緩衝,減少對磁碟(慢)的IO。同樣記憶體與CPU的速度相差太遠,於是CPU設計者們就給CPU加上了緩衝(CPU Cache)。如果需要對同一批資料操作很多次,那麼把資料放至離CPU更近的緩衝,會給程式帶來很大的速度提升。例如,做一個迴圈計數,把計數變數放到緩衝裡,就不用每次迴圈都往記憶體存取資料了。下面是CPU Cache的簡單示意圖:
隨著多核的發展,CPU Cache分成了三個層級:L1、 L2、L3。層級越小越接近CPU,所以速度也更快,同時也代表著容量越小。L1是最接近CPU的,它容量最小,例如32K,速度最快,每個核上都有一個L1 Cache(準確地說每個核上有兩個L1 Cache,一個存資料 L1d Cache,一個存指令 L1i Cache)。L2 Cache 更大一些,例如256K,速度要慢一些,一般情況下每個核上都有一個獨立的L2 Cache;L3 Cache是三級緩衝中最大的一級,例如12MB,同時也是最慢的一級,在同一個CPU插槽之間的核共用一個L3 Cache。
就像資料庫cache一樣,擷取資料時首先會在最快的cache中找資料,如果沒有命中(Cache miss)則往下一級找,直到三層Cache都找不到,那隻有向記憶體要資料了。一次次地未命中,代表取資料消耗的時間越長。
為了高效地存取緩衝,不是簡單隨意地將單條資料寫入緩衝的。緩衝是由緩衝行組成的,典型的一行是64位元組。CPU存取緩衝都是按行為最小單位操作的。一個Java long型佔8位元組,所以從一條緩衝行上可以擷取到8個long型變數。所以如果訪問一個long型數組,當有一個long被載入到cache中,將會無消耗地載入了另外7個,所以可以非常快地遍曆數組。
既然典型的CPU微架構有3級緩衝,每個核都有自己私人的L1、 L2緩衝,那麼多線程編程時,另外一個核的線程想要訪問當前核內L1、L2緩衝行的資料時,該怎麼辦呢。
有一種辦法可以通過第2個核直接存取第1個核的緩衝行。這是可行的,但這種方法不夠快。跨核訪問需要通過Memory Controller,典型的情況是第2個核經常訪問第1個核的這條資料,那麼每次都有跨核的消耗。更糟的情況是,有可能第2個核與第1個核不在一個插槽內,況且Memory Controller的匯流排頻寬是有限的,扛不住這麼多資料轉送。所以CPU設計者們更偏向於另一種辦法:如果第2個核需要這份資料,由第1個核直接把資料內容發過去,資料只需要傳一次。
那麼什麼時候會發生緩衝行的傳輸呢。答案很簡單:當一個核需要讀取另外一個核的髒緩衝行時發生。但是前者怎麼判斷後者的緩衝行已經被弄髒(寫)了呢。
下面將詳細地解答以上問題. 首先需要談到一個協議---MESI協議。現在主流的處理器都是用它來保證緩衝的相干性和記憶體的相干性。M、E、S和I代表使用MESI協議時緩衝行所處的四個狀態:
M(修改,Modified):本地處理器已經修改緩衝行, 即是髒行, 它的內容與記憶體中的內容不一樣. 並且此cache只有本地一個拷貝(專有).
E(專有,Exclusive):緩衝行內容和記憶體中的一樣, 而且其它處理器都沒有這行資料.
S(共用,Shared):緩衝行內容和記憶體中的一樣, 有可能其它處理器也存在此緩衝行的拷貝.
I(無效,Invalid):緩衝行失效, 不能使用.
下面簡單地說明下緩衝行的四種狀態怎麼轉換的:
初始:一開始時,緩衝行沒有載入任何資料,所以它處於I狀態。
本地寫(Local Write):如果本地處理器寫資料至處於I狀態的緩衝行,則緩衝行的狀態變成M。
本地讀(Local Read):如果本地處理器讀取處於I狀態的緩衝行, 很明顯此緩衝沒有資料給它。此時分兩種情況:(1)其它處理器的緩衝裡也沒有此行資料,則從記憶體載入資料到此緩衝行後,再將它設成E狀態,表示只有我一家有這條資料,其它處理器都沒有;(2)其它處理器的緩衝有此行資料,則將此緩衝行的狀態設為S狀態。P.S.如果處於M狀態的緩衝行,再由本地處理器寫入/讀出,狀態是不會改變的。
遠程讀(Remote Read):假設有兩個處理器c1和c2。如果c2需要讀另外一個處理器c1的緩衝行內容,c1需要把它緩衝行的內容通過記憶體控制器(Memory Controller)發送給c2,c2接到後將相應的緩衝行狀態設為S。在設定之前,記憶體也得從匯流排上得到這份資料並儲存。
遠程寫(Remote Write):其實確切地說不是遠程寫,而是c2得到c1的資料後,不是為了讀,而是為了寫,也算是本地寫,只是c1也擁有這份資料的拷貝,這該怎麼辦呢。c2將發出一個RFO(Request For Owner)請求,它需要擁有這行資料的許可權,其它處理器的相應緩衝行設為I,除了它自已,誰不能動這行資料。這保證了資料的安全,同時處理RFO請求以及設定I的過程將給寫操作帶來很大的效能消耗。
上述內容知道,寫操作的代價很高,特別當需要發送RFO訊息時。那編寫程式時,什麼時候會發生RFO請求呢。有以下兩種:
1. 線程的工作從一個處理器移到另一個處理器,它操作的所有緩衝行都需要移到新的處理器上。此後如果再寫緩衝行,則此緩衝行在不同核上有多個拷貝,需要發送RFO請求了。
2. 兩個不同的處理器確實都需要操作相同的緩衝行。
在Java程式中,數組的成員在緩衝中也是連續的。其實從Java對象的相鄰成員變數也會載入到同一緩衝行中。如果多個線程操作不同的成員變數,但是相同的緩衝行,偽共用(False Sharing)問題就發生了。
舉個例子:一個運行在處理器core 1上的線程想要更新變數X的值,同時另外一個運行在處理器core 2上的線程想要更新變數Y的值。但是,這兩個頻繁改動的變數都處於同一條緩衝行。兩個線程就會輪番發送RFO訊息,佔得此緩衝行的擁有權。當core 1取得了擁有權開始更新X,則core 2對應的緩衝行需要設為I狀態。當core 2取得了擁有權開始更新Y,則core 1對應的緩衝行需要設為I狀態(失效態)。輪番奪取擁有權不但帶來大量的RFO訊息,而且如果某個線程需要讀此行資料時,L1和L2緩衝上都是失效資料,只有L3緩衝上是同步好的資料。讀L3的資料非常影響效能,更壞的情況是跨槽讀取,L3都要miss,只能從記憶體上載入。
表面上X和Y都是被獨立線程操作的,而且兩操作之間也沒有任何關係。只不過它們共用了一個緩衝行,但所有競爭衝突都是來源於共用。
那麼怎麼避免偽共用呢。一條緩衝行有64位元組,而Java程式的對象頭固定佔8位元組(32位系統)或12位元組(64位系統預設開啟壓縮, 不開壓縮為16位元組)。只需要填6個無用的長整型補上6*8=48位元組,讓不同的變數處於不同的緩衝行,就可以避免偽共用了(64位系統超過緩衝行的64位元組也無所謂,只要保證不同線程不要操作同一緩衝行就可以),這個辦法叫做補齊(Padding)。例如:
public final static class VolatileLong { public volatile long value = 0L; public long p1, p2, p3, p4, p5, p6; } 偽共用在多核編程中很容易發生,而且比較隱蔽。例如在JDK的LinkedBlockingQueue中,存在指向隊列頭的引用head和指向隊列尾的引用last。而這種隊列經常在非同步編程中使有,這兩個引用的值經常的被不同的線程修改,但它們卻很可能在同一個緩衝行,於是就產生了偽共用。線程越多,核越多,對效能產生的負面效果就越大。
某些Java編譯器會將沒有使用到的補齊資料,即使範例程式碼中的6個長整型在編譯時間最佳化掉,可以在程式中加入一些代碼防止被編譯最佳化。
public static long preventFromOptimization(VolatileLong v) { return v.p1 + v.p2 + v.p3 + v.p4 + v.p5 + v.p6;} 另外,由於Java的GC問題。資料在記憶體和對應的CPU緩衝行的位置有可能發生變化,所以在使用pad的時候應該注意GC的影響。