1. 程式人生 > >偽共享(FalseSharing)

偽共享(FalseSharing)

快取系統中是以快取行(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;//, p7;// comment out
    }

}

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

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

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

所以你也看到了,偽共享可能是無聲的效能殺手。