第一次接觸偽共享的概念,是在馬丁的博客上;而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 |
5.5 為何上述測試8線程的結果回比4線程的結果要好?
6. 最后
本文完