1. 程式人生 > 實用技巧 >JVM系列之:Contend註解和false-sharing

JVM系列之:Contend註解和false-sharing

現代CPU為了提升效能都會有自己的快取結構,而多核CPU為了同時正常工作,引入了MESI,作為CPU快取之間同步的協議。MESI雖然很好,但是不當的時候用也可能導致效能的退化。

到底怎麼回事呢?一起來看看吧。

false-sharing的由來

為了提升處理速度,CPU引入了快取的概念,我們先看一張CPU快取的示意圖:

CPU快取是位於CPU與記憶體之間的臨時資料交換器,它的容量比記憶體小的多但是交換速度卻比記憶體要快得多。

CPU的讀實際上就是層層快取的查詢過程,如果所有的快取都沒有找到的情況下,就是主記憶體中讀取。

為了簡化和提升快取和記憶體的處理效率,快取的處理是以Cache Line(快取行)為單位的。

一次讀取一個Cache Line的大小到快取。

在mac系統中,你可以使用sysctl machdep.cpu.cache.linesize來檢視cache line的大小。 在linux系統中,使用getconf LEVEL1_DCACHE_LINESIZE來獲取cache line的大小。

本機中cache line的大小是64位元組。

考慮下面一個物件:

public class CacheLine {
    public  long a;
    public  long b;
}

很簡單的物件,通過之前的文章我們可以指定,這個CacheLine物件的大小應該是12位元組的物件頭+8位元組的long+8位元組的long+4位元組的補全,總共應該是32位元組。

因為32位元組< 64位元組,所以一個cache line就可以將其包括。

現在問題來了,如果是在多執行緒的環境中,thread1對a進行累加,而thread2對b進行累加。會發生什麼情況呢?

  1. 第一步,新創建出來的物件被儲存到CPU1和CPU2的快取cache line中。
  2. thread1使用CPU1對物件中的a進行累計。
  3. 根據CPU快取之間的同步協議MESI(這個協議比較複雜,這裡就先不展開講解),因為CPU1對快取中的cache line進行了修改,所以CPU2中的這個cache line的副本物件將會被標記為I(Invalid)無效狀態。
  4. thread2使用CPU2對物件中的b進行累加,這個時候因為CPU2中的cache line已經被標記為無效了,所以必須重新從主記憶體中同步資料。

大家注意,耗時點就在第4步。 雖然a和b是兩個不同的long,但是因為他們被包含在同一個cache line中,最終導致了雖然兩個執行緒沒有共享同一個數值物件,但是還是傳送了鎖的關聯情況。

怎麼解決?

那怎麼解決這個問題呢?

在JDK7之前,我們需要使用一些空的欄位來手動補全。

public class CacheLine { 
     public  long actualValue; 
     public  long p0, p1, p2, p3, p4, p5, p6, p7; 
     }

像上面那樣,我們手動填充一些空白的long欄位,從而讓真正的actualValue可以獨佔一個cache line,就沒有這些問題了。

但是在JDK8之後,java檔案的編譯期會將無用的變數自動忽略掉,那麼上面的方法就無效了。

還好,JDK8中引入了sun.misc.Contended註解,使用這個註解會自動幫我們補全欄位。

使用JOL分析

接下來,我們使用JOL工具來分析一下Contended註解的物件和不帶Contended註解的物件有什麼區別。

[@Test](https://my.oschina.net/azibug)
public void useJol(www.shentuylgw.cn) {
        log.info("{www.chuancenpt.com}", ClassLayout.parseClass(CacheLine.class).toPrintable());
        log.info("{www.jintianxuesha.com}", ClassLayout.parseInstance(new CacheLine()).toPrintable());
        log.info("{}", ClassLayout.parseClass(CacheLinePadded.class).toPrintable());
        log.info("{}", ClassLayout.parseInstance(new CacheLinePadded()).toPrintable());
    }

注意,在使用JOL分析Contended註解的物件時候,需要加上 -XX:-RestrictContended引數。

同時可以設定-XX:ContendedPaddingWidth 來控制padding的大小。

INFO com.flydean.CacheLineJOL - com.flydean.CacheLine object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           d0 29 17 00 (11010000 00101001 00010111 00000000) (1518032)
     12     4        (alignment/padding gap)                  
     16     8   long CacheLine.valueA                          0
     24     8   long CacheLine.valueB                          0
Instance size: 32 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
INFO com.flydean.CacheLineJOL - com.flydean.CacheLinePadded object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4       www.gaodeyulept.cn (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)
      4     4       www.jujinyule.com  (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4       www.youy2zhuce.cn (object header)                           d2 5d 17 00 (11010010 01011101 00010111 00000000) (1531346)
     12     4        (alignment/padding gap)                  
     16     8   long CacheLinePadded.b                         0
     24   128        (alignment/padding gap)                  
    152     8   long CacheLinePadded.a                         0
Instance size: 160 bytes
Space losses: 132 bytes internal + 0 bytes external = 132 bytes total

我們看到使用了Contended的物件大小是160位元組。直接填充了128位元組。

Contended在JDK9中的問題

sun.misc.Contended是在JDK8中引入的,為了解決填充問題。

但是大家注意,Contended註解是在包sun.misc,這意味著一般來說是不建議我們直接使用的。

雖然不建議大家使用,但是還是可以用的。

但如果你使用的是JDK9-JDK14,你會發現sun.misc.Contended沒有了!

因為JDK9引入了JPMS(Java Platform Module System),它的結構跟JDK8已經完全不一樣了。

經過我的研究發現,sun.misc.Contended, sun.misc.Unsafe,sun.misc.Cleaner這樣的類都被移到了jdk.internal.**中,並且是預設不對外使用的。

那麼有人要問了,我們換個引用的包名是不是就行了?

import jdk.internal.vm.annotation.Contended;

抱歉還是不行。

error: package jdk.internal.vm.annotation is not visible
  @jdk.internal.vm.annotation.Contended
                  ^
  (package jdk.internal.vm.annotation is www.ued3zc.cn declared in module
    java.base, which does not export www.javachenglei.com to the unnamed module)

好,我們找到問題所在了,因為我們的程式碼並沒有定義module,所以是一個預設的“unnamed” module,我們需要把java.base中的jdk.internal.vm.annotation使unnamed module可見。

要實現這個目標,我們可以在javac中新增下面的flag:

--add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED

好了,現在我們可以正常通過編譯了。

padded和unpadded效能對比

上面我們看到padded物件大小是160位元組,而unpadded物件的大小是32位元組。

物件大了,執行的速度會不慢呢?

實踐出真知,我們使用JMH工具在多執行緒環境中來對其進行測試:

@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(value = 1, jvmArgsPrepend = "-XX:-RestrictContended")
@Warmup(iterations = 10)
@Measurement(iterations = 25)
@Threads(2)
public class CacheLineBenchMark {

    private CacheLine cacheLine= new CacheLine();
    private CacheLinePadded cacheLinePadded = new CacheLinePadded();

    @Group("unpadded")
    @GroupThreads(1)
    @Benchmark
    public long updateUnpaddedA() {
        return cacheLine.a++;
    }

    @Group("unpadded")
    @GroupThreads(1)
    @Benchmark
    public long updateUnpaddedB() {
        return cacheLine.b++;
    }

    @Group("padded")
    @GroupThreads(1)
    @Benchmark
    public long updatePaddedA() {
        return cacheLinePadded.a++;
    }

    @Group("padded")
    @GroupThreads(1)
    @Benchmark
    public long updatePaddedB() {
        return cacheLinePadded.b++;
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(CacheLineBenchMark.class.getSimpleName())
                .build();
        new Runner(opt).run();
    }
}

上面的JMH程式碼中,我們使用兩個執行緒分別對A和B進行累計操作,看下最後的執行結果:

從結果看來雖然padded生成的物件比較大,但是因為A和B在不同的cache line中,所以不會出現不同的執行緒去主記憶體取資料的情況,因此要執行的比較快。

Contended在JDK中的使用

其實Contended註解在JDK原始碼中也有使用,不算廣泛,但是都很重要。

比如在Thread中的使用:

比如在ConcurrentHashMap中的使用:

其他使用的地方:Exchanger,ForkJoinPool,Striped64。

感興趣的朋友可以仔細研究一下。

總結

Contented從最開始的sun.misc到現在的jdk.internal.vm.annotation,都是JDK內部使用的class,不建議大家在應用程式中使用。

這就意味著我們之前使用的方式是不正規的,雖然能夠達到效果,但是不是官方推薦的。那麼我們還有沒有什麼正規的辦法來解決false-sharing的問題呢?

有知道的小夥伴歡迎留言給我討論!

本文連結:http://www.flydean.com/jvm-contend-false-sharing/

本文來源:flydean的部落格

歡迎關注我的公眾號:程式那些事,更多精彩等著您!