1. 程式人生 > >雜談 什麽是偽共享(false sharing)?

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

完全 分享圖片 throws 其它 也有 ava 循環 哪些 訪問

問題

(1)什麽是 CPU 緩存行?

(2)什麽是內存屏障?

(3)什麽是偽共享?

(4)如何避免偽共享?

CPU緩存架構

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

主內存(RAM)是數據存放的地方,CPU 和主內存之間有好幾級緩存,因為即使直接訪問主內存也是非常慢的。

如果對一塊數據做相同的運算多次,那麽在執行運算的時候把它加載到離 CPU 很近的地方就有意義了,比如一個循環計數,你不想每次循環都跑到主內存去取這個數據來增長它吧。

技術分享圖片

越靠近 CPU 的緩存越快也越小。

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

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

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

最後,主存保存著程序運行的所有數據,它更大,更慢,由全部插槽上的所有 CPU 核共享。

當 CPU 執行運算的時候,它先去 L1 查找所需的數據,再去 L2,然後是 L3,最後如果這些緩存中都沒有,所需的數據就要去主內存拿。

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

所以如果進行一些很頻繁的運算,要確保數據在 L1 緩存中。

CPU緩存行

緩存是由緩存行組成的,通常是 64 字節(常用處理器的緩存行是 64 字節的,比較舊的處理器緩存行是 32 字節),並且它有效地引用主內存中的一塊地址。

一個 Java 的 long 類型是 8 字節,因此在一個緩存行中可以存 8 個 long 類型的變量。

技術分享圖片

在程序運行的過程中,緩存每次更新都從主內存中加載連續的 64 個字節。因此,如果訪問一個 long 類型的數組時,當數組中的一個值被加載到緩存中時,另外 7 個元素也會被加載到緩存中。

但是,如果使用的數據結構中的項在內存中不是彼此相鄰的,比如鏈表,那麽將得不到免費緩存加載帶來的好處。

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

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

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

而當後者讀取 b 時,發現這個緩存行已經失效了,需要從主內存中重新加載。

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

技術分享圖片

這樣就出現了一個問題,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 的源碼分析,敬請期待。

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


歡迎關註我的公眾號“彤哥讀源碼”,查看更多源碼系列文章, 與彤哥一起暢遊源碼的海洋。

技術分享圖片

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