Java中的偽共享以及應對方案

盧子召發表於2016-11-04

什麼是偽共享

CPU快取系統中是以快取行(cache line)為單位儲存的。目前主流的CPU Cache的Cache Line大小都是64Bytes。在多執行緒情況下,如果需要修改“共享同一個快取行的變數”,就會無意中影響彼此的效能,這就是偽共享(False Sharing)。

CPU的三級快取

由於CPU的速度遠遠大於記憶體速度,所以CPU設計者們就給CPU加上了快取(CPU Cache)。 以免運算被記憶體速度拖累。(就像我們寫程式碼把共享資料做Cache不想被DB存取速度拖累一樣),CPU Cache分成了三個級別:L1,L2,L3。級別越小越接近CPU, 所以速度也更快, 同時也代表著容量越小。
CPU獲取資料回依次從L1,L2,L3中查詢,如果都找不到則會直接向記憶體查詢。

快取行

由於共享變數在CPU快取中的儲存是以快取行為單位,一個快取行可以儲存多個變數(存滿當前快取行的位元組數);而CPU對快取的修改又是以快取行為最小單位的,那麼就會出現上訴的偽共享問題。

Cache Line可以簡單的理解為CPU Cache中的最小快取單位,今天的CPU不再是按位元組訪問記憶體,而是以64位元組為單位的塊(chunk)拿取,稱為一個快取行(cache line)。當你讀一個特定的記憶體地址,整個快取行將從主存換入快取,並且訪問同一個快取行內的其它值的開銷是很小的。
看如下程式碼示例:

    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中的Index0,2,4都對映在Way0的不同CacheLine中,Index1,3,5都對映在Way1的不同CacheLine中。

2_way

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。如下圖:
MESI

偽共享問題

那麼為什麼會出現偽共享問題呢?上訴的情況再擴充套件一下,假設在多執行緒情況下,x,y兩個共享變數在同一個快取行中,核a修改變數x,會導致核b,核c中的x變數和y變數同時失效。
此時對於在核a上執行的執行緒,僅僅只是修改了了變數x,卻導致同一個快取行中的所有變數都無效,需要重新刷快取(並不一定代表每次都要從記憶體中重新載入,也有可能是從其他Cache中匯入資料,具體的實現要看各個晶片廠商的實現了)。
假設此時在核b上執行的執行緒,正好想要修改變數Y,那麼就會出現相互競爭,相互失效的情況,這就是偽共享啦。

Java對於偽共享的傳統解決方案

package com.alibaba;

/**
 * Created by Administrator on 2016/10/13 0013.
 */
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;
    }
}

執行結果:

duration = 9465942893

現在,我們將VolatileLong中不使用的6個long變數註釋掉,再次執行:

   public final static class VolatileLong {
        public volatile long value = 0L;
        //public long p1, p2, p3, p4, p5, p6; 
    }

duration = 20362748888

可以看到,兩個程式邏輯完全一致,只是註釋掉了幾個沒有使用到的變數,卻導致效能相差很大。 我們知道一條快取行有64位元組, 而Java程式的物件頭固定佔8位元組(32位系統)或12位元組(64位系統預設開啟壓縮, 不開壓縮為16位元組). 我們只需要填6個無用的長整型補上6*8=48位元組, 讓不同的VolatileLong物件處於不同的快取行, 就可以避免偽共享了(64位系統超過快取行的64位元組也無所謂,只要保證不同執行緒不要操作同一快取行就可以)。這個辦法叫做補齊(Padding)。

Java8中的解決方案

Java8中已經提供了官方的解決方案,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

參考文獻:

1:http://igoro.com/archive/gallery-of-processor-cache-effects/
2:http://ifeve.com/false-sharing/
3:http://blog.csdn.net/muxiqingyang/article/details/6615199


相關文章