什麼是僞共享
CPU緩存系統中是以緩存行(cache line)爲單位存儲的。目前主流的CPU Cache 的 Cache Line 大小都是64Bytes。在多線程情況下,如果需要修改“共享同一個緩存行的變量”,就會無意中影響彼此的性能,這就是僞共享(False Sharing)。
CPU的三級緩存
由於CPU的速度遠遠大於內存速度,所以CPU設計者們就給CPU加上了緩存(CPU Cache)。 以免運算被內存速度拖累。CPU Cache 分成了三個級別:L1,L2,L3。級別越小越接近CPU, 所以速度也更快, 同時也代表着容量越小。
CPU獲取數據會依次從L1,L2,L3中查找,如果都找不到則會直接向內存查找。
緩存行
由於共享變量在CPU緩存中的存儲是以緩存行爲單位,一個緩存行可以存儲多個變量(存滿當前緩存行的字節數),而CPU對緩存的修改又是以緩存行爲最小單位的,那麼就會出現上訴的僞共享問題。
Cache Line 可以簡單的理解爲 CPU Cache 中的最小緩存單位。當你讀一個特定的內存地址,整個緩存行將從主存換入緩存,並且訪問同一個緩存行內的其它值的開銷是很小的。
看如下代碼示例:
int[] arr = new int[64 * 1024 * 1024];
long start = System.nanoTime();
for (int i = 0; i < arr.length; i++) {
arr[i] *= 3;
}
System.out.println(System.nanoTime() - start);
long start2 = System.nanoTime();
for (int i = 0; i < arr.length; i += 16) {
arr[i] *= 3;
}
System.out.println(System.nanoTime() - start2);
表面上看,第二個循環工作量爲第一個循環的1/16;但是執行時間是相差不大的,假設在內存規整的情況下,每16個int 佔用4*16=64字節,正好一個緩存行,也就是說這兩個循環訪問內存的次數是一致的。導致耗時相差不大。
緩存關聯性
目前常用的緩存設計是N路組關聯(N-Way Set Associative Cache),他的原理是把一個緩存按照N個 Cache Line 作爲一組(Set),緩存按組劃爲等分。每個內存塊能夠被映射到相對應的set中的任意一個緩存行中。比如一個16路緩存,16個 Cache Line 作爲一個Set,每個內存塊能夠被映射到Set中的16個 CacheLine 的任意一個。一般地,具有一定相同低bit位地址的內存塊將共享同一個Set。
下圖爲一個 2-Way 的 Cache。由圖中可以看到 Main Memory 中的 Index 0,2,4都映射在 Way 0 的不同 CacheLine 中,Index 1,3,5都映射在 Way 1 的不同 CacheLine 中。
MESI協議
多核CPU都有自己的專有緩存(一般爲L1,L2),以及同一個CPU插槽之間的核共享的緩存(一般爲L3)。不同核心的CPU緩存中難免會加載同樣的數據,那麼如何保證數據的一致性呢,就是MESI協議了。
在MESI協議中,每個Cache line有4個狀態,可用2個bit表示,它們分別是:
- M(Modified):這行數據有效,數據被修改了,和內存中的數據不一致,數據只存在於本Cache中
- E(Exclusive):這行數據有效,數據和內存中的數據一致,數據只存在於本Cache中
- S(Shared):這行數據有效,數據和內存中的數據一致,數據存在於很多Cache中
- I(Invalid):這行數據無效
那麼,假設有一個變量i=3(應該是包括變量i的緩存塊,塊大小爲緩存行大小);已經加載到多核(a,b,c)的緩存中,此時該緩存行的狀態爲S;此時其中的一個核a改變了變量i的值,那麼在覈a中的當前緩存行的狀態將變爲M,b,c核中的當前緩存行狀態將變爲I。如下圖:
僞共享問題
那麼爲什麼會出現僞共享問題呢?上訴的情況再擴展一下,假設在多線程情況下,x,y兩個共享變量在同一個緩存行中,核a修改變量x,會導致核b,核c中的x變量和y變量同時失效。
此時對於在覈a上運行的線程,僅僅只是修改了了變量x,卻導致同一個緩存行中的所有變量都無效,需要重新刷緩存(並不一定代表每次都要從內存中重新載入,也有可能是從其他Cache中導入數據,具體的實現要看各個芯片廠商的實現了)。
假設此時在覈b上運行的線程,正好想要修改變量Y,那麼就會出現相互競爭,相互失效的情況,這就是僞共享。
Java對於僞共享的傳統解決方案
在Java 7 之前,可以通過以下方式進行填充解決僞共享的問題。但Java 7 開始會淘汰或重新排列無用字段,因此需採用其它填充方式。
public final class FalseSharing implements Runnable {
private final static int NUM_THREADS = 4; // change
private final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];
static {
for (int i = 0; i < longs.length; i++) {
longs[i] = new VolatileLong();
}
}
public FalseSharing(final int arrayIndex) {
this.arrayIndex = arrayIndex;
}
public static void main(final String[] args) throws Exception {
final long start = System.nanoTime();
runTest();
System.out.println("duration = " + (System.nanoTime() - start));
}
private static void runTest() throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new FalseSharing(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;
}
}
Java 8 中的解決方案
Java8中新增了一個註解:@sun.misc.Contended。加上這個註解的類會自動補齊緩存行,需要注意的是此註解默認是無效的,需要在jvm啓動時設置 -XX:-RestrictContended 纔會生效。
運行結果:
@sun.misc.Contended
public final static class VolatileLong {
public volatile long value = 0L;
//public long p1, p2, p3, p4, p5, p6;
}
duration = 8987991013
參考文獻: