CPU 如何讀寫數(shù)據(jù)的?
先來認(rèn)識一下 CPU 的架構(gòu)

一個 CPU 里通常會有多個 CPU 核心,并且每個 CPU 核心都有自己的 L1 Cache 和 L2 Cache,而 L1 Cache 通常分為(數(shù)據(jù)緩存)和(指令緩存),L3 Cache 則是多個核心共享的,這就是 CPU 典型的緩存層次。
上面提到的都是 CPU 內(nèi)部的 Cache,放眼外部的話,還會有內(nèi)存和硬盤,這些存儲設(shè)備共同構(gòu)成了金字塔存儲層次。如下圖所示:

從上圖也可以看到,從上往下,存儲設(shè)備的容量會越大,而訪問速度會越慢。
CPU 訪問 L1 Cache 速度比訪問內(nèi)存快 100 倍,這就是為什么 CPU 里會有 L1~L3 Cache 的原因,目的就是把 Cache 作為 CPU 與內(nèi)存之間的緩存層,以減少對內(nèi)存的訪問頻率。
CPU 從內(nèi)存中讀取數(shù)據(jù)到 Cache 的時候,并不是一個字節(jié)一個字節(jié)讀取,而是一塊一塊的方式來讀取數(shù)據(jù)的,這一塊一塊的數(shù)據(jù)被稱為 CPU Cache Line(緩存塊),所以 CPU Cache Line 是 CPU 從內(nèi)存讀取數(shù)據(jù)到 Cache 的單位。
至于 CPU Cache Line 大小,在 linux 系統(tǒng)可以用下面的方式查看到,你可以看我服務(wù)器的 L1 Cache Line 大小是 64 字節(jié),也就意味著 L1 Cache 一次載入數(shù)據(jù)的大小是 64 字節(jié)。

那么對數(shù)組的加載, CPU 就會加載數(shù)組里面連續(xù)的多個數(shù)據(jù)到 Cache 里,因此我們應(yīng)該按照物理內(nèi)存地址分布的順序去訪問元素,這樣訪問數(shù)組元素的時候,Cache 命中率就會很高,于是就能減少從內(nèi)存讀取數(shù)據(jù)的頻率, 從而可提高程序的性能。
但是,在我們不使用數(shù)組,而是使用單獨的變量的時候,則會有 Cache 偽共享的問題,Cache 偽共享問題上是一個性能殺手,我們應(yīng)該要規(guī)避它。
接下來,就來看看 Cache 偽共享是什么?又如何避免這個問題?
Cache 偽共享
現(xiàn)在假設(shè)有一個雙核心的 CPU,這兩個 CPU 核心并行運行著兩個不同的線程,它們同時從內(nèi)存中讀取兩個不同的數(shù)據(jù),分別是類型為 long 的變量 A 和 B,這個兩個數(shù)據(jù)的地址在物理內(nèi)存上是連續(xù)的,如果 Cahce Line 的大小是 64 字節(jié),并且變量 A 在 Cahce Line 的開頭位置,那么這兩個數(shù)據(jù)是位于同一個 Cache Line 中,又因為 CPU Cache Line 是 CPU 從內(nèi)存讀取數(shù)據(jù)到 Cache 的單位,所以這兩個數(shù)據(jù)會被同時讀入到了兩個 CPU 核心中各自 Cache 中。

我們來思考一個問題,如果這兩個不同核心的線程分別修改不同的數(shù)據(jù),比如 1 號 CPU 核心的線程只修改了 變量 A,或 2 號 CPU 核心的線程的線程只修改了變量 B,會發(fā)生什么呢?
分析偽共享的問題
現(xiàn)在我們結(jié)合保證多核緩存一致的 MESI 協(xié)議,來說明這一整個的過程。
最開始變量 A 和 B 都還不在 Cache 里面,假設(shè) 1 號核心綁定了線程 A,2 號核心綁定了線程 B,線程 A 只會讀寫變量 A,線程 B 只會讀寫變量 B。

1 號核心讀取變量 A,由于 CPU 從內(nèi)存讀取數(shù)據(jù)到 Cache 的單位是 Cache Line,也正好變量 A 和 變量 B 的數(shù)據(jù)歸屬于同一個 Cache Line,所以 A 和 B 的數(shù)據(jù)都會被加載到 Cache,并將此 Cache Line 標(biāo)記為「獨占」?fàn)顟B(tài)。

接著,2 號核心開始從內(nèi)存里讀取變量 B,同樣的也是讀取 Cache Line 大小的數(shù)據(jù)到 Cache 中,此 Cache Line 中的數(shù)據(jù)也包含了變量 A 和 變量 B,此時 1 號和 2 號核心的 Cache Line 狀態(tài)變?yōu)椤腹蚕怼範(fàn)顟B(tài)。

1 號核心需要修改變量 A,發(fā)現(xiàn)此 Cache Line 的狀態(tài)是「共享」?fàn)顟B(tài),所以先需要通過總線發(fā)送消息給 2 號核心,通知 2 號核心把 Cache 中對應(yīng)的 Cache Line 標(biāo)記為「已失效」?fàn)顟B(tài),然后 1 號核心對應(yīng)的 Cache Line 狀態(tài)變成「已修改」?fàn)顟B(tài),并且修改變量 A。

之后,2 號核心需要修改變量 B,此時 2 號核心的 Cache 中對應(yīng)的 Cache Line 是已失效狀態(tài),另外由于 1 號核心的 Cache 也有此相同的數(shù)據(jù),且狀態(tài)為「已修改」?fàn)顟B(tài),所以要先把 1 號核心的 Cache 對應(yīng)的 Cache Line 寫回到內(nèi)存,然后 2 號核心再從內(nèi)存讀取 Cache Line 大小的數(shù)據(jù)到 Cache 中,最后把變量 B 修改到 2 號核心的 Cache 中,并將狀態(tài)標(biāo)記為「已修改」?fàn)顟B(tài)。

所以,可以發(fā)現(xiàn)如果 1 號和 2 號 CPU 核心這樣持續(xù)交替的分別修改變量 A 和 B,就會重復(fù) ④ 和 ⑤ 這兩個步驟,Cache 并沒有起到緩存的效果,雖然變量 A 和 B 之間其實并沒有任何的關(guān)系,但是因為同時歸屬于一個 Cache Line ,這個 Cache Line 中的任意數(shù)據(jù)被修改后,都會相互影響,從而出現(xiàn) ④ 和 ⑤ 這兩個步驟。
因此,當(dāng)多線程修改互相獨立的變量時,如果這些變量共享同一個緩存行,就會無意中影響彼此的性能,這就是偽共享。
如何避免
舉個栗子
public class FalseSharingTest {
public static void mAIn(String[] args) throws InterruptedException {
testPointer(new Pointer());
}
private static void testPointer(Pointer pointer) throws InterruptedException {
long start = System.currentTimeMillis();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000000; i++) {
pointer.x++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000000; i++) {
pointer.y++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(System.currentTimeMillis() - start);
System.out.println(pointer);
}
}
class Pointer {
volatile long x;
volatile long y;
}
上面這個例子,我們聲明了一個Pointer的類,它包含了x和y兩個變量(必須聲明為volatile,保證可見性),一個線程對x進(jìn)行自增1億次,一個線程對y進(jìn)行自增1億次。
可以看到,x和y完全沒有任何關(guān)系,但是更新x的時候會把其它包含x的緩存行失效,同時y也就失效了,運行這段程序輸出的時間為3890ms。
偽共享的原理我們知道了,一個緩存行是64字節(jié),一個long類型是8個字節(jié),所以避免偽共享也很簡單,大概有以下三種方式:
(1)在兩個long類型的變量之間再加7個long類型
我們把上面的pointer改成下面這個結(jié)構(gòu)
class Pointer {
volatile long x;
long p1, p2, p3, p4, p5, p6, p7;
volatile long y;
}
再次運行程序,會發(fā)現(xiàn)輸出時間神奇的縮短為695ms
(2)重新創(chuàng)建自己的long類型,而不是JAVA自帶的long修改Pointer如下
class Pointer {
MyLong x = new MyLong();
MyLong y = new MyLong();
}
class MyLong {
volatile long value;
long p1, p2, p3, p4, p5, p6, p7;
}
同時把pointer.x++改為pointer.x.value++;等,再次運行程序發(fā)現(xiàn)時間是724ms,這樣本質(zhì)上還是填充。所以,避免 Cache 偽共享實際上是用空間換時間的思想,浪費一部分 Cache 空間,從而換來性能的提升。
(3)使用@sun.misc.Contended注解(java8)
修改MyLong如下:
@sun.misc.Contended
class MyLong {
volatile long value;
}
默認(rèn)使用這個注解是無效的,需要在JVM啟動參數(shù)加上-XX:-RestrictContended才會生效,再次運行程序發(fā)現(xiàn)時間是718ms。注意,以上三種方式中的前兩種是通過加字段的形式實現(xiàn)的,加的字段又沒有地方使用,可能會被jvm優(yōu)化掉,所以建議使用第三種方式。
Java 并發(fā)框架 Disruptor 使用「字節(jié)填充 + 繼承」的方式,來避免偽共享的問題。感興趣的同學(xué)可以自己去學(xué)習(xí)了解一下。