寫Java也得了解CPU--偽共享


第一次接觸偽共享的概念,是在馬丁的博客上;而ifeve也把這一系列博文翻譯整理好了。概讀了幾次,感覺到此概念的重要。因此有了這個系列的第二篇讀后總結。

 

1. 什么是偽共享(False sharing)

上一篇博文知道,緩存的存儲方式,是以緩存行(Cache Line)為單位的。一般緩存行的大小是64字節。這意味着,小於64字節的變量,是有可能存在於同一條緩存行的。例如變量X大小32字節,變量Y大小32字節,那么他們有可能會存在於一條緩存行上。

根據馬丁博客上的定義,偽共享,就是多個線程同時修改共享在同一個緩存行里的獨立變量,無意中影響了性能

 

2.偽共享是怎么發生的

借助馬丁的圖,我們可以窺知偽共享發生的過程。

 

 

當核心1上的線程想更新X,而核心2上的線程想更新Y,而X變量和Y變量在同一個緩存行中時;每個線程都要去競爭緩存行的所有權來更新變量。如果核心1獲得所緩存行的所有權,那么緩存子系統將會使核心2中對應的緩存行失效,反之亦然。這會來來回回的經過L3緩存,大大影響了性能。這種情況,就像多個線程同事競爭鎖的所有權一樣。如果互相競爭的核心位於不同的插槽,就要額外橫跨插槽連接,問題可能更加嚴重。

 

3. 怎么發現偽共享

很遺憾,沒有特別直接有效的方法。馬丁自己也承認,偽共享相當難發現,因此有“無聲性能殺手”之稱。但這不意味着無法發現。通過觀察L2和L3的緩存命中和丟失的情況,可以從側面發現是否有偽共享的發生。

 

4. 怎么解決偽共享

對於偽共享這種影響性能的問題,解決是關鍵。解決偽共享的方法是通過補齊(Padding),使得每一條緩存行只存一個多線程變量。請看下面的代碼:

public final class FalseSharing implements Runnable {
    public final static int NUM_THREADS = 2; // change
    public 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 {
        
        //test the size of VolatileLong
        System.out.println(ClassLayout.parseClass(VolatileLong.class).toPrintable());

        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; // comment out in order to trigger false sharing
    }
    

}

 

改變線程的數量以及運行此程序(基於Intel Xeon E31270 8GB/64bit Win7/64bit jdk 6),會得到以下的結果:

這個結果沒有馬丁的結果那么驚人,偽共享在3-4線程的時候會比較明顯地影響性能。這個結果后面還會繼續分析。然而這並不是個完美的測試,因為我們不能確定這些VolatileLong會布局在內存的什么位置。它們是獨立的對象。但是經驗告訴我們同一時間分配的對象趨向集中於一塊。

 

5.一些思考

上面是馬丁對偽共享的初步解釋。說實話,解釋得略微簡略了一點。讀了幾次,還是有不太明白的地方,因此厚着臉皮在這里發了個帖子問點疑惑,結果得到馬丁本人,Nitsan和Peter等眾大神的回答,收益匪淺。下面摘錄點我的疑惑和他們的解答,更好的理解偽共享:

 

5.1 上文得知,L1緩存和L2緩存是核心私有的緩存,不同的核心並不共享L1和L2緩存。為什么核心1更新它自己L1上的X,而核心2更新它自己L1上的Y,會發生偽共享?

要回答這個問題,首先得稍微了解CPU緩存工作的協議,MESI。這套協議是用來保證CPU緩存的一致性的(cache coherency)。簡單來說,這協議定義了多級緩存下的同一個變量改變后,該怎么辦。這套協議相當復雜,這里只是介紹偽共享相關的知識點,來回答我們的問題。

我們知道,緩存的最小使用單位,是緩存行。如上面所假設,變量X和變量Y不幸在同一個緩存行里,而核心1需要X,核心2需要Y。這時候,核心1就會拷貝這條緩存行到自己的L1,核心2也一樣。所以這條緩存行在L3,核心1的L1和核心2的L1里,正如上圖所示。

假設核心1修改變量X,那么根據MESI協議,這個緩存行的狀態就會變成M(Modified),表面這一行數據和內存數據不一致,得回寫(write back)此緩存行到L3里。而這時,需要發送一個Request For Ownership (RFO),來獲得L3的這條緩存行的所有權。由於X和Y在同一條緩存行,雖然核心2修改的變量是Y,但也需要做同樣的事情-發送RFO獲得L3同一條緩存行的所有權。因此,偽共享就這樣在L3里發生了。

 

5.2 補齊(Padding)會不會有失效的時候?

會的。Java 7淘汰或是重新排列了無用的字段,因此上述的補齊在Java 7里已經失效了,偽共享還會發生。要避免偽共享,需要改變補齊的方式如下:

 public static long sumPaddingToPreventOptimisation(final int index)
    {
        PaddedAtomicLong v = longs[index];
        return v.p1 + v.p2 + v.p3 + v.p4 + v.p5 + v.p6;
    }
 
    public static class PaddedAtomicLong extends AtomicLong
    {
        public volatile long p1, p2, p3, p4, p5, p6 = 7L;
    }

這個方法的由來在這里,並不打算深究。要注意的是,這個方法sumPaddingToPreventOptimisation只是用來防止JVM7消除無用的字段。

 

5.3 怎么計算VolatileLong的大小?為什么這樣補齊可以使它符合緩存行64字節大小?

理論上,我們知道在64bit Hotspot JVM下,一個long占8字節之類的知識。但實際的對象占多少字節,怎么分布,得依靠這個工具--JOL來測量。下面是補齊前和補齊后,VolatileLong的輸出:

VolatileLong object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 (alignment/padding gap) N/A
16 8 long VolatileLong.value N/A
Instance size: 24 bytes (estimated, the sample instance is not available)
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total

VolatileLong object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 (alignment/padding gap) N/A
16 8 long VolatileLong.value N/A
24 8 long VolatileLong.p1 N/A
32 8 long VolatileLong.p2 N/A
40 8 long VolatileLong.p3 N/A
48 8 long VolatileLong.p4 N/A
56 8 long VolatileLong.p5 N/A
64 8 long VolatileLong.p6 N/A
Instance size: 72 bytes (estimated, the sample instance is not available)
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total

這樣,我們可以看到補齊前,VolatileLong只有24字節小於緩存行大小,補齊后就超過緩存行大小。

 

5.4 補齊的對象超過了緩存行,有沒有影響會不會和接下來的變量發生潛在的偽共享?

假設補齊后,VolatileLong是72字節,緊接着剛好有一個變量Z是剛好56個字節,那么第二個緩存行存放着VolatileLong的8字節那一部分,以及變量Z。那么同時訪問VolatileLong和Z,會不會發生偽共享呢?是不是一定要補齊到緩存行大小才完全避免偽共享呢?

答案是否定的,補齊超過緩存行,最多浪費點珍貴的緩存,但不會產生偽共享。請看下面的圖:

| 8b | 16 | 24 | 32 | 40 | 48 | 56 | 64 |

| *  | *  | OH | OH | P   | P   | P    | P    |
| P   | P   | P    | V    | P    | P    | P    | P   |
| P   | P   | P    | *   |  *   |  *   |  *   |  *   |
 
如圖所示,補齊的對象橫跨3個緩存行時,我們需要的改變的變量,僅僅是V,只要保證V這個變量所在的同一條緩存行內,沒有另外一個需要改變的變量,那么偽共享不會發生。
補充一句,Nitsan大神的補齊對象在 這里。沒細看,但估計補齊得更加完美。
 

5.5 為何上述測試8線程的結果回比4線程的結果要好?

這是因為,用來測試的機器的CPU只是4核心,偽共享的發生是在L3緩存。如果8線程的話,其實有線程是共享了L1緩存。
 

6. 最后

偽共享是實實在在的問題,而且相當隱蔽。但現在Java的多線程庫,都會考慮到這個問題。例如Disruptor還有Netty都會使用Padding的類代替原有類,達到去除偽共享的目的。
 
 
參考:
 

本文完

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM