一、前言
【閑話開篇】:這段時間項目接近尾聲,我終於閑了一點,又拿起了早先未看完的書《JAVA高並發程序設計》。看到其中介紹《無鎖的緩存框架:Disruptor》時,接觸到了一個概念——偽共享(false sharing),說是會影響並發程序的執行性能,被很多人描述成無聲的性能殺手,突然感覺到了自己知識的匱乏,罪過啊。
偽共享(false sharing),究竟是怎樣一回事呢?不急,我們先倒杯水邊喝邊回顧,以前上學時丟下的計算機組成原理相關知識點。

二、概念解析
CPU 緩存(三級)
CPU 緩存(Cache Memory)是位於CPU與內存之間的臨時存儲器,它的容量比內存小很多,但是交換速度卻比內存要快得多。CPU和主內存之間有好幾級緩存,CPU緩存可以分為一級緩存,二級緩存,部分高端CPU還具有三級緩存。每一級緩存中所儲存的全部數據都是下一級緩存的一部分,越靠近 CPU 的緩存越快也越小。
高速緩存的出現主要是為了解決CPU運算速度與內存讀寫速度不匹配的矛盾,因為CPU運算速度要比內存讀寫速度快很多,這樣會使 CPU 花費很長時間等待數據到來或把數據寫入內存。在緩存中的數據是內存中的一小部分,但這一小部分是短時間內CPU即將訪問的,當CPU調用大量數據時,就可避開內存直接從緩存中調用,從而加快讀取速度。
如果我們的程序正在多次對同一數據塊做相同的運算,那么在執行運算的時候把它加載到離 CPU 很近的緩存中就能大大的提高程序運行速度。
我們以L1、L2、L3分別表示一級緩存、二級緩存、三級緩存,按照數據讀取順序和與CPU結合的緊密程度,速度是L1 >L2 > L3 >主存,容量是L1< L2< L3< 主存。
L1 緩存很小但是很快,並且緊靠着在使用它的 CPU 內核,L2 大一些,也慢一些,並且仍然只能被一個單獨的 CPU 核使用,L3 更大,更慢,並且被單個插槽上的所有 CPU 核共享,最后是主存,由全部插槽上的所有 CPU 核共享。擁有三級緩存的的 CPU,到三級緩存時能夠達到95%的命中率,只有不到5%的數據需要從內存中查詢。
三級緩存示意圖:

緩存行
緩存行 (Cache Line) 是 CPU 緩存中的最小單位,CPU 緩存由若干緩存行組成,一個緩存行的大小通常是 64 字節(備注:取決於 CPU,本文基於64字節,其他長度的如32字節等本文不作討論),並且它有效地引用主內存中的一塊地址。一個 Java 的 long 類型是 8 字節,因此在一個緩存行中可以存 8 個 long 類型的變量。
所以,如果你訪問一個 long 數組,當數組中的一個值被加載到緩存中,它會額外加載另外 7 個,以致你能非常快地遍歷這個數組。事實上,你可以非常快速的遍歷在連續的內存塊中分配的任意數據結構。而如果你在數據結構中的項在內存中不是彼此相鄰的(如鏈表),你將得不到緩存加載所帶來的優勢,並且在這些數據結構中的每一項都可能會出現緩存未命中的情況。
MESI 協議
MESI 協議是基於Invalidate的高速緩存一致性協議,並且是支持回寫高速緩存的最常用協議之一。
緩存行狀態
CPU 的緩存是以緩存行(cache line)為單位的,MESI協議描述了多核處理器中一個緩存行的狀態。(現在主流的處理器都是用它來保證緩存的相干性和內存的相干性。)
在MESI協議中,每個緩存行有4個狀態,分別是:
M(修改,Modified):本地處理器已經修改緩存行,即是臟行,它的內容與內存中的內容不一樣,並且此 cache 只有本地一個拷貝(專有)
E(專有,Exclusive):緩存行內容和內存中的一樣,而且其它處理器都沒有這行數據
S(共享,Shared):緩存行內容和內存中的一樣, 有可能其它處理器也存在此緩存行的拷貝
I(無效,Invalid):緩存行失效, 不能使用
狀態轉換
在 MESI 協議中,每個Cache的Cache控制器不僅知道自己的讀寫操作,而且也監聽其它Cache的讀寫操作。每個Cache line所處的狀態根據本核和其它核的讀寫操作在4個狀態間進行遷移。MESI 協議狀態遷移圖如下:

初始:一開始時,緩存行沒有加載任何數據,所以它處於 I 狀態。
本地寫(Local Write):如果本地處理器寫數據至處於 I 狀態的緩存行,則緩存行的狀態變成 M。
本地讀(Local Read):如果本地處理器讀取處於I狀態的緩存行,很明顯此緩存沒有數據給它。此時分兩種情況:
(1)其它處理器的緩存里也沒有此行數據,則從內存加載數據到此緩存行后,再將它設成 E 狀態,表示只有我
一家有這條數據,其它處理器都沒有
(2)其它處理器的緩存有此行數據,則將此緩存行的狀態設為 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的過程將給寫操作帶來很大的性能消耗。
偽共享
了解了上述一些概念之后,咱們提出一個疑問?如果有多個線程操作不同的成員變量,但它們是相同的緩存行,這個時候會發生什么?

沒錯,偽共享(False Sharing)問題就發生了!咱們來看一張經典的CPU 緩存行示意圖:

注釋:一個運行在處理器 core1上的線程想要更新變量 X 的值,同時另外一個運行在處理器 core2 上的線程想要更新變量 Y 的值。
但是,這兩個頻繁改動的變量都處於同一條緩存行。兩個線程就會輪番發送 RFO (Request For Owner) 消息,占得此緩存行的擁有
權。當 core1 取得了擁有權開始更新 X,則 core2 對應的緩存行需要設為 I 狀態(失效態)。當 core2 取得了擁有權開始更新 Y,
則core1 對應的緩存行需要設為 I 狀態(失效態)。輪番奪取擁有權不但帶來大量的 RFO 消息,而且如果某個線程需要讀此行數據時,
L1 和 L2 緩存上都是失效數據,只有L3緩存上是同步好的數據。從前面的內容我們知道,讀L3的數據會影響性能,更壞的情況是跨槽
讀取,L3 都出現緩存未命中,只能從主存上加載。
舉例說明:
咱們以Java里面的ArrayBlockingQueue為例采用生產消費模型說明,ArrayBlockingQueue有三個成員變量:
- takeIndex:需要被取走的元素下標
- putIndex:可被元素插入的位置的下標
- count:隊列中元素的數量
這三個變量很容易放到一個緩存行中,但是修改並沒有太多的關聯。所以每次修改,都會使之前緩存的數據失效,從而不能完全達到共享的效果。

當生產者線程put一個元素到ArrayBlockingQueue時,putIndex會修改,從而導致消費者線程的緩存中的緩存行無效,需要向上重新讀取,這種無法充分使用緩存行特性的現象,稱為偽共享。
看到此處,我們可以自行總結,關於偽共享給出一個非標准的定義:
CPU 緩存系統中是以緩存行為單位存儲的,當多線程修改互相獨立的變量時,如果這些變量共享同一個緩存行,就會無意中影響彼此的性能,這就是偽共享。
三、程序模擬
程序用四個線程修改一數組不同元素的內容,元素類型為 VolatileLong,包含一個長整型成員 value 和 6 個沒用到的長整型成員,value 設為 volatile 是為了讓 value 的修改對所有線程都可見。主要代碼如下:
public class FalseShare implements Runnable {
public static int NUM_THREADS = 4;
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
private static VolatileLong[] longs;
public static long SUM_TIME = 0l;
public FalseShare(final int arrayIndex) {
this.arrayIndex = arrayIndex;
}
private static void exeTest() throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new FalseShare(i));
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
t.join();
}
}
public void run() {
long i = ITERATIONS + 1;
while (0 != --i) {
longs[arrayIndex].value = i;
}
}
public final static class VolatileLong {
public volatile long value = 0L;
// public long p1, p2, p3, p4, p5, p6; //緩存行填充
}
public static void main(final String[] args) throws Exception {
for (int j = 0; j < 10; j++) {
System.out.println("第" + j + "次...");
longs = new VolatileLong[NUM_THREADS];
for (int i = 0; i < longs.length; i++) {
longs[i] = new VolatileLong();
}
long start = System.nanoTime();
exeTest();
long end = System.nanoTime();
SUM_TIME += end - start;
}
System.out.println("平均耗時:" + SUM_TIME / 10);
}
}
第一次執行:
// public long p1, p2, p3, p4, p5, p6; //緩存行填充
第二次執行:
public long p1, p2, p3, p4, p5, p6; //緩存行填充
程序每次運行,循環10次,取平均耗時,耗時結果如下:
第一次:
平均耗時:28305116160
第二次:
平均耗時:14071204270
【實例說明】:一個緩存行有 64 字節,一個long占8個字節,而 Java 程序的對象頭固定占 8 字節(32位系統)或 12 字節( 64 位系統默認開啟壓縮, 不開壓縮為 16 字節),所以我們只需要填 6個無用的長整型補上6*8=48字節,讓不同的 VolatileLong 對象處於不同的緩存行,就避免了偽共享( 64 位系統超過緩存行的 64 字節也無所謂,只要保證不同線程不操作同一緩存行就可以了)。
四、偽共享避免
1.緩存行填充(讓不同線程操作的對象處於不同的緩存行)
1)緩存行手動填充
2)使用Contended注解自動進行填充
2.使用編譯指示,來強制每一個變量對齊(略)
五、總結
1)CPU 緩存是以緩存行為單位進行操作的,產生偽共享問題的根源在於不同的CPU核同時操作同一個緩存行;
2)可以通過緩存行填充來解決偽共享問題,且Java8 中引入了@sun.misc.Contended注解來自動填充;
3)一個緩存行有四種狀態,分別為:M(修改)E(專有)S(共享)I(無效);
4)每個緩存行所處的狀態根據本核和其它核的讀寫操作在4個狀態間進行遷移;
5)不是所有的場景都需要解決偽共享問題,因為CPU緩存是有限的,填充會犧牲掉一部分緩存;
over
