Java8使用@sun.misc.Contended避免偽共享

博客鏈接:http://www.ideabuffer.cn/2017/05/12/Java8使用-sun-misc-Contended避免偽共享/


什么是偽共享

緩存系統中是以緩存行(cache line)為單位存儲的。緩存行是2的整數冪個連續字節,一般為32-256個字節。最常見的緩存行大小是64個字節。當多線程修改互相獨立的變量時,如果這些變量共享同一個緩存行,就會無意中影響彼此的性能,這就是偽共享。

緩存行上的寫競爭是運行在SMP系統中并行線程實現可伸縮性最重要的限制因素。有人將偽共享描述成無聲的性能殺手,因為從代碼中很難看清楚是否會出現偽共享。

為了讓可伸縮性與線程數呈線性關系,就必須確保不會有兩個線程往同一個變量或緩存行中寫。兩個線程寫同一個變量可以在代碼中發現。為了確定互相獨立的變量是否共享了同一個緩存行,就需要了解緩存行和對象的內存布局,有關緩存行和對象內存布局可以參考我的另外兩篇文章理解CPU CacheJava對象內存布局

下面的圖說明了偽共享的問題:

假設在核心1上運行的線程想更新變量X,同時核心2上的線程想要更新變量Y。不幸的是,這兩個變量在同一個緩存行中。每個線程都要去競爭緩存行的所有權來更新變量。如果核心1獲得了所有權,緩存子系統將會使核心2中對應的緩存行失效。當核心2獲得了所有權然后執行更新操作,核心1就要使自己對應的緩存行失效。這會來來回回的經過L3緩存,大大影響了性能。如果互相競爭的核心位于不同的插槽,就要額外橫跨插槽連接,問題可能更加嚴重。

避免偽共享

假設有一個類中,只有一個long類型的變量:

public final static class VolatileLong {
    public volatile long value = 0L;
}

這時定義一個VolatileLong類型的數組,然后讓多個線程同時并發訪問這個數組,這時可以想到,在多個線程同時處理數據時,數組中的多個VolatileLong對象可能存在同一個緩存行中,通過上文可知,這種情況就是偽共享。

怎么樣避免呢?在Java 7之前,可以在屬性的前后進行padding,例如:

public final static class VolatileLong {
    volatile long p0, p1, p2, p3, p4, p5, p6;
    public volatile long value = 0;
    volatile long q0, q1, q2, q3, q4, q5, q6;
}

通過Java對象內存布局文章中結尾對paddign的分析可知,由于都是long類型的變量,這里就是按照聲明的順序分配內存,那么這可以保證在同一個緩存行中只有一個VolatileLong對象。

__ 這里有一個問題:據說Java7優化了無用字段,會使這種形式的補位無效,但經過測試,無論是在JDK 1.7 還是 JDK 1.8中,這種形式都是有效的。網上有關偽共享的文章基本都是來自Martin的兩篇博客,這種優化方式也是在他的博客中提到的。但國內的文章貌似根本就沒有驗證過而直接引用了此觀點,這也確實迷惑了一大批同學!__

在Java 8中,提供了@sun.misc.Contended注解來避免偽共享,原理是在使用此注解的對象或字段的前后各增加128字節大小的padding,使用2倍于大多數硬件緩存行的大小來避免相鄰扇區預取導致的偽共享沖突。具體可以參考http://mail.openjdk.java.net/pipermail/hotspot-dev/2012-November/007309.html

下面用代碼來看一下加padding和不加的效果:

運行環境:JDK 1.8,macOS 10.12.4,2.2 GHz Intel Core i7,四核-八線程

public 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];
//    private static VolatileLong2[] longs = new VolatileLong2[NUM_THREADS];
//    private static VolatileLong3[] longs = new VolatileLong3[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 {
        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;
    }

    // long padding避免false sharing
    // 按理說jdk7以后long padding應該被優化掉了,但是從測試結果看padding仍然起作用
    public final static class VolatileLong2 {
        volatile long p0, p1, p2, p3, p4, p5, p6;
        public volatile long value = 0L;
        volatile long q0, q1, q2, q3, q4, q5, q6;
    }

    /**
     * jdk8新特性,Contended注解避免false sharing
     * Restricted on user classpath
     * Unlock: -XX:-RestrictContended
     */
    @sun.misc.Contended
    public final static class VolatileLong3 {
        public volatile long value = 0L;
    }
}

VolatileLong對象只有一個long類型的字段,VolatileLong2加了padding,下面分別執行看下時間:

duration = 57293259577
duration = 4679059000

沒加padding時用了大概57秒,加padding后用時大概4.6秒,可見加padding后有效果了。

在Java8中提供了@sun.misc.Contended來避免偽共享,例如這里的VolatileLong3,在運行時需要設置JVM啟動參數-XX:-RestrictContended,運行一下結果如下:

duration = 4756952426

結果與加padding的時間差不多。

下面看一下VolatileLong對象在運行時的內存大小(參考Java對象內存布局):

再來看下VolatileLong2對象在運行時的內存大小:

因為多了14個long類型的變量,所以24+8*14=136字節。

下面再來看下使用@sun.misc.Contended注解后的對象內存大小:

在堆內存中并沒有看到對變量進行padding,大小與VolatileLong對象是一樣的。

這就奇怪了,看起來與VolatileLong沒什么不一樣,但看一下內存的地址,用十六進制算一下,兩個VolatileLong對象地址相差24字節,而兩個VolatileLong3對象地址相差280字節。這就是前面提到的@sun.misc.Contended注解會在對象或字段的前后各增加128字節大小的padding,那么padding的大小就是256字節,再加上對象的大小24字節,結果就是280字節,所以確實是增加padding了。

八線程運行比四線程運行還快?

根據上面的代碼,把NUM_THREADS改為8,測試看下結果:

VolatileLong:  44305002641
VolatileLong2: 7100172492
VolatileLong3: 7335024041

可以看到,加了padding和@sun.misc.Contended注解的運行時間多了不到1倍,而VolatileLong運行的時間比線程數是4的時候還要短,這是為什么呢?

再說一下,我的CPU是四核八線程,每個核有一個L1 Cache,那么我的環境一共有4個L1 Cache,所以,2個CPU線程會共享同一個L1 Cache;由于VolatileLong對象占用24字節內存,而代碼中VolatileLong對象是保存在數組中的,所以內存是連續的,2個VolatileLong對象的大小是48字節,這樣一來,對于緩存行大小是64字節來說,每個緩存行只能存放2個VolatileLong對象。

通過上面的分析可知,偽共享發生在L3 Cache,如果每個核操作的數據不在同一個緩存行中,那么就會避免偽共享的發生,所以,8個線程的情況下其實是CPU線程共享了L1 Cache,所以執行的時間可能比4線程的情況還要短。下面看下執行時4線程和8線程的CPU使用情況:

可以看到,在4線程時,線程被平均分配到了4個核中,這樣一來,L1 Cache肯定是不能共享的,這時會發生偽共享;而8線程時,每個核都使用了2個線程,這時L1 Cache是可以共享的,這在一定程度上能減少偽共享的發生,從而時間會變短(也不一定,但總體來說8線程的情況與4線程的運行時間幾乎不會向加padding和注解的方式差那么多)。

在Windows上情況就不太一樣了,在雙核四線程的CPU上,測試結果并不和mac中一樣,在不加padding和注解時,2線程和4線程執行的時間都是將近差了1倍,看下使用2個線程在Windows中執行的時候CPU的使用情況:

雖然只使用了2個線程,但從圖像上來看,似乎都在工作,即使把線程數量設置為1也是這種情況。這應該是Windows和UNIX對CPU線程調度的方式不一樣,具體我現在也不太清楚他們之間的差別,希望有知道的同學告知,感謝。

@sun.misc.Contended注解

上文中將@sun.misc.Contended注解用在了對象上,@sun.misc.Contended注解還可以指定某個字段,并且可以為字段進行分組,下面通過代碼來看下:

/**
 * VM Options: 
 * -javaagent:/Users/sangjian/dev/source-files/classmexer-0_03/classmexer.jar
 * -XX:-RestrictContended
 */
public class ContendedTest {

    byte a;
    @sun.misc.Contended("a")
    long b;
    @sun.misc.Contended("a")
    long c;
    int d;

    private static Unsafe UNSAFE;

    static {
        try {
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            UNSAFE = (Unsafe) f.get(null);
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws NoSuchFieldException {
        System.out.println("offset-a: " + UNSAFE.objectFieldOffset(ContendedTest.class.getDeclaredField("a")));
        System.out.println("offset-b: " + UNSAFE.objectFieldOffset(ContendedTest.class.getDeclaredField("b")));
        System.out.println("offset-c: " + UNSAFE.objectFieldOffset(ContendedTest.class.getDeclaredField("c")));
        System.out.println("offset-d: " + UNSAFE.objectFieldOffset(ContendedTest.class.getDeclaredField("d")));

        ContendedTest contendedTest = new ContendedTest();

        // 打印對象的shallow size
        System.out.println("Shallow Size: " + MemoryUtil.memoryUsageOf(contendedTest) + " bytes");
        // 打印對象的 retained size
        System.out.println("Retained Size: " + MemoryUtil.deepMemoryUsageOf(contendedTest) + " bytes");
    }

}

這里還是使用到了classmexer.jar,可以參考Java對象內存布局中的說明。

這里在變量b和c中使用了@sun.misc.Contended注解,并將這兩個變量分為1組,執行結果如下:

offset-a: 16
offset-b: 152
offset-c: 160
offset-d: 12
Shallow Size: 296 bytes
Retained Size: 296 bytes

可見int類型的變量的偏移地址是12,也就是在對象頭后面,因為它正好是4個字節,然后是變量a。@sun.misc.Contended注解的變量會加到對象的最后面,這里就是b和c了,那么b的偏移地址是152,之前說過@sun.misc.Contended注解會在變量前后各加128字節,而byte類型的變量a分配完內存后這時起始地址應該是從17開始,因為byte類型占1字節,那么應該補齊到24,所以b的起始地址是24+128=152,而c的前面并不用加128字節,因為b和c被分為了同一組。

我們算一下c分配完內存后,這時的地址應該到了168,然后再加128字節,最后大小就是296。內存結構如下:

| d:12~16 | --- | a:16~17 | --- | 17~24 | --- | 24~152 | --- | b:152~160 | --- | c:160~168 | --- | 168~296 |

現在把b和c分配到不同的組中,代碼做如下修改:

/**
 * VM Options:
 * -javaagent:/Users/sangjian/dev/source-files/classmexer-0_03/classmexer.jar
 * -XX:-RestrictContended
 */
public class ContendedTest {

    byte a;
    @sun.misc.Contended("a")
    long b;
    @sun.misc.Contended("b")
    long c;
    int d;

    private static Unsafe UNSAFE;

    static {
        try {
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            UNSAFE = (Unsafe) f.get(null);
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws NoSuchFieldException {
        System.out.println("offset-a: " + UNSAFE.objectFieldOffset(ContendedTest.class.getDeclaredField("a")));
        System.out.println("offset-b: " + UNSAFE.objectFieldOffset(ContendedTest.class.getDeclaredField("b")));
        System.out.println("offset-c: " + UNSAFE.objectFieldOffset(ContendedTest.class.getDeclaredField("c")));
        System.out.println("offset-d: " + UNSAFE.objectFieldOffset(ContendedTest.class.getDeclaredField("d")));

        ContendedTest contendedTest = new ContendedTest();

        // 打印對象的shallow size
        System.out.println("Shallow Size: " + MemoryUtil.memoryUsageOf(contendedTest) + " bytes");
        // 打印對象的 retained size
        System.out.println("Retained Size: " + MemoryUtil.deepMemoryUsageOf(contendedTest) + " bytes");
    }

}

運行結果如下:

offset-a: 16
offset-b: 152
offset-c: 288
offset-d: 12
Shallow Size: 424 bytes
Retained Size: 424 bytes

可以看到,這時b和c中增加了128字節的padding,結構也就變成了:

| d:12~16 | --- | a:16~17 | --- | 17~24 | --- | 24~152 | --- | b:152~160 | --- | 160~288 | --- | c:288~296 | --- | 296~424 |

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 從三月份找實習到現在,面了一些公司,掛了不少,但最終還是拿到小米、百度、阿里、京東、新浪、CVTE、樂視家的研發崗...
    時芥藍閱讀 42,356評論 11 349
  • Java8張圖 11、字符串不變性 12、equals()方法、hashCode()方法的區別 13、...
    Miley_MOJIE閱讀 3,728評論 0 11
  • 1. Java基礎部分 基礎部分的順序:基本語法,類相關的語法,內部類的語法,繼承相關的語法,異常的語法,線程的語...
    子非魚_t_閱讀 31,754評論 18 399
  • Disruptor框架學習(2)--為啥這么快 在上一篇中,筆者闡述了Disruptor的代碼實現和數據結構。在說...
    賈博巖閱讀 2,236評論 0 11
  • 從小侄女出生到現在已經兩歲了,今天她好逗我嘴上有點東西,她嘟嘟的跑過去拿來衛生紙給我擦嘴,其實蠻感動的啦,這么小個...
    崴崴閱讀 144評論 0 0