1. 程式人生 > >@sun.misc.Contended 解決偽共享問題

@sun.misc.Contended 解決偽共享問題

快取系統中是以快取行(cache line)為單位儲存的。快取行是2的整數冪個連續位元組,一般為32-256個位元組。最常見的快取行大小是64個位元組。當多執行緒修改互相獨立的變數時,如果這些變數共享同一個快取行,就會無意中影響彼此的效能,這就是偽共享。快取行上的寫競爭是執行在SMP系統中並行執行緒實現可伸縮性最重要的限制因素。有人將偽共享描述成無聲的效能殺手,因為從程式碼中很難看清楚是否會出現偽共享。

為了讓可伸縮性與執行緒數呈線性關係,就必須確保不會有兩個執行緒往同一個變數或快取行中寫。兩個執行緒寫同一個變數可以在程式碼中發現。為了確定互相獨立的變數是否共享了同一個快取行,就需要了解記憶體佈局,或找個工具告訴我們。Intel VTune就是這樣一個分析工具。本文中我將解釋Java物件的記憶體佈局以及我們該如何填充快取行以避免偽共享。

cache-line.png
圖 1.

圖1說明了偽共享的問題。在核心1上執行的執行緒想更新變數X,同時核心2上的執行緒想要更新變數Y。不幸的是,這兩個變數在同一個快取行中。每個執行緒都要去競爭快取行的所有權來更新變數。如果核心1獲得了所有權,快取子系統將會使核心2中對應的快取行失效。當核心2獲得了所有權然後執行更新操作,核心1就要使自己對應的快取行失效。這會來來回回的經過L3快取,大大影響了效能。如果互相競爭的核心位於不同的插槽,就要額外橫跨插槽連線,問題可能更加嚴重。

對於HotSpot JVM,所有物件都有兩個字長的物件頭。第一個字是由24位雜湊碼和8位標誌位(如鎖的狀態或作為鎖物件)組成的Mark Word。第二個字是物件所屬類的引用。如果是陣列物件還需要一個額外的字來儲存陣列的長度。每個物件的起始地址都對齊於8位元組以提高效能。因此當封裝物件的時候為了高效率,物件欄位宣告的順序會被重排序成下列基於位元組大小的順序:

  1. doubles (8) 和 longs (8)
  2. ints (4) 和 floats (4)
  3. shorts (2) 和 chars (2)
  4. booleans (1) 和 bytes (1)
  5. references (4/8)
  6. <子類欄位重複上述順序>

為了展示其效能影響,我們啟動幾個執行緒,每個都更新它自己獨立的計數器。計數器是volatile long型別的,所以其它執行緒能看到它們的進展。

public final class FalseSharing implements Runnable

{

    public final static int NUM_THREADS = 4; // 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

    {

        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

    }

}

執行上面的程式碼,增加執行緒數以及新增/移除快取行的填充,下面的圖2描述了我得到的結果。這是在我4核Nehalem上測得的執行時間。

duration.png
圖 2.

從不斷上升的測試所需時間中能夠明顯看出偽共享的影響。沒有快取行競爭時,我們幾近達到了隨著執行緒數的線性擴充套件。

這並不是個完美的測試,因為我們不能確定這些VolatileLong會佈局在記憶體的什麼位置。它們是獨立的物件。但是經驗告訴我們同一時間分配的物件趨向集中於一塊。

需要注意的是 1.7,某個版本之後 會對上面的程式碼進行優化,使我們的填充行的做法失效,不過好在1.8版本官方利用了@sun.misc.Contended 代替了這一做法

以下部分摘抄自

 
// jdk8新特性,Contended註解避免false sharing  
// Restricted on user classpath  
// Unlock: -XX:-RestrictContended  
@sun.misc.Contended  
public class VolatileLong {  
        volatile long v = 0L;  
}  

需要注意的是在啟動jvm的時候要加入-XX:-RestrictContended  

jdk8中已經使用sun.misc.Contended的地方:   

src/share/classes/java/util/concurrent/ConcurrentHashMap.java  
2458: @sun.misc.Contended static final class CounterCell {  
  
src/share/classes/java/util/concurrent/Exchanger.java  
313: @sun.misc.Contended static final class Node {  
  
src/share/classes/java/util/concurrent/ForkJoinPool.java
 
  
src/share/classes/java/util/concurrent/atomic/Striped64.java  
119: @sun.misc.Contended static final class Cell {  
  
src/share/classes/java/lang/Thread.java  
2004: @sun.misc.Contended("tlr")  
2008: @sun.misc.Contended("tlr")  
2012: @sun.misc.Contended("tlr")