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進行累加。會發生什麼情況呢?
- 第一步,新創建出來的對象被存儲到CPU1和CPU2的緩存cache line中。
- thread1使用CPU1對對象中的a進行累計。
- 根據CPU緩存之間的同步協議MESI(這個協議比較複雜,這裡就先不展開講解),因為CPU1對緩存中的cache line進行了修改,所以CPU2中的這個cache line的副本對象將會被標記為I(Invalid)無效狀態。
- 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
public void useJol() {
log.info("{}", ClassLayout.parseClass(CacheLine.class).toPrintable());
log.info("{}", 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 (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) 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 declared in module
java.base, which does not export it 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的問題呢?
有知道的小夥伴歡迎留言給我討論!
本文作者:flydean程序那些事
本文鏈接://www.flydean.com/jvm-contend-false-sharing/
本文來源:flydean的博客
歡迎關注我的公眾號:程序那些事,更多精彩等着您!