什麼是僞共享(false sharing)

今天看go的sync.pool的代碼,發現了一個比較陌生的名詞 false sharing , 之前沒聽說過,就去查了下,瞬間學到了

type poolLocal struct {
   poolLocalInternal

   // Prevents false sharing on widespread platforms with
   // 128 mod (cache line size) = 0 .
   pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

什麼是false sharing

這裏需要解決這幾個問題

(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的更新需要從主內存重新讀取,它被緩存未命中給拖慢了。這就是傳說中的僞共享。

僞共享

當多線程修改互相獨立的變量時,如果這些變量共享同一個緩存行,就會無意中影響彼此的性能,這就是僞共享。

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++;等,再次運行程序發現時間是724ms,這樣本質上還是填充。

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

修改MyLong如下:

@sun.misc.Contended
class MyLong {
    volatile long value;
}

默認使用這個註解是無效的,需要在JVM啓動參數加上-XX:-RestrictContended纔會生效,再次運行程序發現時間是718ms。注意,以上三種方式中的前兩種是通過加字段的形式實現的(上面go代碼裏的實現也是這樣的),加的字段又沒有地方使用,可能會被jvm優化掉,所以建議使用第三種方式。

內存屏障

1.volatile是一個類型修飾符,volatile的作用是作爲指令關鍵字,確保本條指令不會因編譯器的優化而省略。

2.volatile的特性:

(1)保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其它線程來說是立即可見的-》實現可見性

(2)禁止進行指令重排序(實現有序性)

(3)volatile只能保證對單次讀寫的原子性。i++這種操作不能保證原子性

3.volatile的實現原理中的可見性就是基於內存屏障實現

內存屏障(Memory Barrier):又稱內存柵欄,是一個CPU指令。

在程序運行時,爲了提高執行性能,編譯器和處理器會對指令進行重排序,JVM爲了保證在不同的編譯器和CPU上有相同的結果,通過插入特定類型的內存屏障來禁止特定類型的編譯器重排序和處理器重排序,插入一條內存屏障會告訴編譯器和CPU:不管什麼指令都不能和這條內存屏障指令重排序

總結

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

(2)CPU緩存中的數據是以緩存行爲單位處理的;

(3)CPU緩存行能帶來免費加載數據的好處,所以處理數據性能非常高

(4)CPU緩存行也帶來了弊端,多線程處理不相干的變量時會相互影響,也就是僞共享

(5)避免僞共享的主要思路就是讓不相干的變量不要出現在同一個緩存行中;

1是每兩個變量之間加上7個long類型;2是創建自己的long類型,而不是用原生的;3是使用java8的註解

 

參考鏈接:

https://www.jianshu.com/p/64240319ed60

https://www.jianshu.com/p/7758bb277985

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章