雜談 什麼是偽共享(false sharing)?

彤哥讀原始碼發表於2019-05-11

問題

(1)什麼是 CPU 快取行?

(2)什麼是記憶體屏障?

(3)什麼是偽共享?

(4)如何避免偽共享?

CPU快取架構

CPU 是計算機的心臟,所有運算和程式最終都要由它來執行。

主記憶體(RAM)是資料存放的地方,CPU 和主記憶體之間有好幾級快取,因為即使直接訪問主記憶體也是非常慢的。

如果對一塊資料做相同的運算多次,那麼在執行運算的時候把它載入到離 CPU 很近的地方就有意義了,比如一個迴圈計數,你不想每次迴圈都跑到主記憶體去取這個資料來增長它吧。

ABA

越靠近 CPU 的快取越快也越小。

所以 L1 快取很小但很快,並且緊靠著在使用它的 CPU 核心。

L2 大一些,也慢一些,並且仍然只能被一個單獨的 CPU 核使用。

L3 在現代多核機器中更普遍,仍然更大,更慢,並且被單個插槽上的所有 CPU 核共享。

最後,主存儲存著程式執行的所有資料,它更大,更慢,由全部插槽上的所有 CPU 核共享。

當 CPU 執行運算的時候,它先去 L1 查詢所需的資料,再去 L2,然後是 L3,最後如果這些快取中都沒有,所需的資料就要去主記憶體拿。

走得越遠,運算耗費的時間就越長。

所以如果進行一些很頻繁的運算,要確保資料在 L1 快取中。

CPU快取行

快取是由快取行組成的,通常是 64 位元組(常用處理器的快取行是 64 位元組的,比較舊的處理器快取行是 32 位元組),並且它有效地引用主記憶體中的一塊地址。

一個 Java 的 long 型別是 8 位元組,因此在一個快取行中可以存 8 個 long 型別的變數。

ABA

在程式執行的過程中,快取每次更新都從主記憶體中載入連續的 64 個位元組。因此,如果訪問一個 long 型別的陣列時,當陣列中的一個值被載入到快取中時,另外 7 個元素也會被載入到快取中。

但是,如果使用的資料結構中的項在記憶體中不是彼此相鄰的,比如連結串列,那麼將得不到免費快取載入帶來的好處。

不過,這種免費載入也有一個壞處。設想如果我們有個 long 型別的變數 a,它不是陣列的一部分,而是一個單獨的變數,並且還有另外一個 long 型別的變數 b 緊挨著它,那麼當載入 a 的時候將免費載入 b。

看起來似乎沒有什麼毛病,但是如果一個 CPU 核心的執行緒在對 a 進行修改,另一個 CPU 核心的執行緒卻在對 b 進行讀取。

當前者修改 a 時,會把 a 和 b 同時載入到前者核心的快取行中,更新完 a 後其它所有包含 a 的快取行都將失效,因為其它快取中的 a 不是最新值了。

而當後者讀取 b 時,發現這個快取行已經失效了,需要從主記憶體中重新載入。

請記住,我們的快取都是以快取行作為一個單位來處理的,所以失效 a 的快取的同時,也會把 b 失效,反之亦然。

ABA

這樣就出現了一個問題,b 和 a 完全不相干,每次卻要因為 a 的更新需要從主記憶體重新讀取,它被快取未命中給拖慢了。

這就是傳說中的偽共享。

偽共享

好了,上面介紹完CPU的快取架構及快取行機制,下面進入我們的正題——偽共享。

當多執行緒修改互相獨立的變數時,如果這些變數共享同一個快取行,就會無意中影響彼此的效能,這就是偽共享。

我們來看看下面這個例子,充分說明了偽共享是怎麼回事。

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 進行自增1億次,一個執行緒對 y 進行自增1億次。

可以看到,x 和 y 完全沒有任何關係,但是更新 x 的時候會把其它包含 x 的快取行失效,同時也就失效了 y,執行這段程式輸出的時間為3890ms

避免偽共享

偽共享的原理我們知道了,一個快取行是 64 個位元組,一個 long 型別是 8 個位元組,所以避免偽共享也很簡單,筆者總結了下大概有以下三種方式:

(1)在兩個 long 型別的變數之間再加 7 個 long 型別

我們把上面的Pointer改成下面這個結構:

class Pointer {
    volatile long x;
    long p1, p2, p3, p4, p5, p6, p7;
    volatile long y;
}
複製程式碼

再次執行程式,會發現輸出時間神奇的縮短為了695ms

(2)重新建立自己的 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++;,把 pointer.y++; 修改為 pointer.y.value++;,再次執行程式發現時間是724ms

(3)使用 @sun.misc.Contended 註解(java8)

修改 MyLong 如下:

@sun.misc.Contended
class MyLong {
    volatile long value;
}
複製程式碼

預設使用這個註解是無效的,需要在JVM啟動引數加上-XX:-RestrictContended才會生效,,再次執行程式發現時間是718ms

注意,以上三種方式中的前兩種是通過加欄位的形式實現的,加的欄位又沒有地方使用,可能會被jvm優化掉,所以建議使用第三種方式。

總結

(1)CPU具有多級快取,越接近CPU的快取越小也越快;

(2)CPU快取中的資料是以快取行為單位處理的;

(3)CPU快取行能帶來免費載入資料的好處,所以處理陣列效能非常高;

(4)CPU快取行也帶來了弊端,多執行緒處理不相干的變數時會相互影響,也就是偽共享;

(5)避免偽共享的主要思路就是讓不相干的變數不要出現在同一個快取行中;

(6)一是每兩個變數之間加七個 long 型別;

(7)二是建立自己的 long 型別,而不是用原生的;

(8)三是使用 java8 提供的註解;

彩蛋

java中有哪些類避免了偽共享的干擾呢?

還記得我們前面介紹過的 ConcurrentHashMap 的原始碼解析嗎?

裡面的 size() 方法使用的是分段的思想來構造的,每個段使用的類是 CounterCell,它的類上就有 @sun.misc.Contended 註解。

不知道的可以關注我的公眾號“彤哥讀原始碼”檢視歷史訊息找到這篇文章看看。

除了這個類,java中還有個 LongAdder 也使用了這個註解避免偽共享,下一章我們將一起學習 LongAdder 的原始碼分析,敬請期待。

你還知道哪些避免偽共享的應用呢?


歡迎關注我的公眾號“彤哥讀原始碼”,檢視更多原始碼系列文章, 與彤哥一起暢遊原始碼的海洋。

qrcode

相關文章