(給ImportNew加星標,提高Java技能)
編譯:唐尤華,
連結:
shipilev.net/jvm/anatomy-quarks/4-tlab-allocation/
1. 寫在前面
“[JVM 解剖公園]”是一個持續更新的系列迷你部落格,閱讀每篇文章一般需要5到10分鐘。限於篇幅,僅對某個主題按照問題、測試、基準程式、觀察結果深入講解。因此,這裡的資料和討論可以當軼事看,不做寫作風格、句法和語意錯誤、重覆或一致性檢查。如果選擇採信文中內容,風險自負。
[1]:https://shipilev.net/jvm-anatomy-park
[2]:http://twitter.com/shipilev
[3]:aleksey@shipilev.net
2. 問題
TLAB 分配是什麼?Pointer-bump 分配含義又是什麼?到底由誰負責分配物件?
3. 理論
當執行 `new MyClass()` 陳述句時,大多數情況由執行時環境分配儲存空間。教科書式的 GC 介面像下麵這樣:
```cpp
ref Allocate(T type);
ref AllocateArray(T type, int size);
```
當然,由於記憶體管理器通常用不同的程式語言實現,這樣的介面讓人捉摸不透(Java 執行平臺是 JVM,但 HotSpot JVM 採用 C++ 編寫)。分配物件的開銷很大嗎?也許是。記憶體管理器需要處理多執行緒請求記憶體嗎?答案是肯定的。
為了最佳化記憶體分配,允許執行緒根據需要分配整塊記憶體且只在 VM 中分配新記憶體塊。在 Hotspot 虛擬機器中,這些記憶體塊被稱作**執行緒本地分配緩衝區(TLAB)**,配有一套複雜的機制提供支援。請註意,從時間上看 TLAB 是執行緒本地記憶體,這意味著它們可以看做物件分配快取。雖然 TLAB 也是 Java 堆的一部分,執行緒仍然可以將新分配的物件取用寫入 TLAB 之外的欄位。
所有現存的 OpenJDK GC 都支援 TLAB 分配。虛擬機器中有關這部分的程式碼在各 GC 中實現了共享。所有 Hotspot 編譯器都支援 TLAB 分配,因此你會看到物件分配會生成類似下麵這樣的程式碼:
```shell
0x00007f3e6bb617cc: mov 0x60(%r15),%rax ; TLAB "current"
0x00007f3e6bb617d0: mov %rax,%r10 ; tmp = current
0x00007f3e6bb617d3: add $0x10,%r10 ; tmp += 16 (物件大小)
0x00007f3e6bb617d7: cmp 0x70(%r15),%r10 ; tmp > tlab_size?
0x00007f3e6bb617db: jae 0x00007f3e6bb61807 ; TLAB 完成,跳轉並請求下一個
0x00007f3e6bb617dd: mov %r10,0x60(%r15) ; current = tmp (TLAB準備就緒,執行 alloc!)
0x00007f3e6bb617e1: prefetchnta 0xc0(%r10) ; ...
0x00007f3e6bb617e9: movq $0x1,(%rax) ; essay-header 存到 (obj+0)
0x00007f3e6bb617f0: movl $0xf80001dd,0x8(%rax) ; klass 存到 (obj+8)
0x00007f3e6bb617f7: mov %r12d,0xc(%rax) ; 物件其它部分置為0
```
分配物件的地址就在上面生成的程式碼中,不需要呼叫 GC 分配物件。如果 TLAB 無法容納請求分配的物件或者物件大小超過 TLAB,分配過程會進入“慢通道”。要麼等待 TLAB 具備分配條件,要麼傳回一個新的 TLAB。請註意,“常見的物件分配地址”等於 TLAB 當前指標加上物件大小,然後指標前移。
這就是為什麼這種分配機制有時也稱為“Pointer bump 分配”。Pointer bump 分配需要一段連續的記憶體空間而且會帶來堆壓縮。請註意 CMS 在老年代如何進行 free-list 分配啟動併發清掃,CMS 對“年輕代”採取萬物靜止式回收併進行壓縮,這個過程會從 Pointer bump 分配中受益!少部分年輕代回收遺留的物件會進入 free-list 分配。
出於實驗目的,我們用 `-XX:-UseTLAB` 引數關閉 TLAB 機制,所有記憶體分配呼叫 native 方法,像下麵這樣:
```shell
- 17.12% 0.00% org.openjdk.All perf-31615.map
- 0x7faaa3b2d125
- 16.59% OptoRuntime::new_instance_C
- 11.49% InstanceKlass::allocate_instance
2.33% BlahBlahBlahCollectedHeap::mem_allocate
0.35% AllocTracer::send_allocation_outside_tlab_event
```
但正如你在下麵看到的實驗結果,這是一個糟糕的主意。
4. 實驗
與往常一樣,讓我們設計一個實驗來觀察 TLAB 的分配過程。由於所有 GC 實現都支援 TLAB 機制,可以透過 Epsilon GC 來減少執行時中其他部分的影響。Epsilon GC 只實現了記憶體分配,因此提供了一個很好的研究平臺。
讓我們快速構建工作負載:分配5000萬個物件。讓 JMH 在 SingleShot 樣式下執行,統計並分析結果。當然也可以單獨構建一個測試,而 SingleShot 在這裡是一種非常方便的選擇。
```java
@Warmup(iterations = 3)
@Measurement(iterations = 3)
@Fork(3)
@BenchmarkMode(Mode.SingleShotTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class AllocArray {
@Benchmark
public Object test() {
final int size = 50_000_000;
Object[] objects = new Object[size];
for (int c = 0; c < size; c++) {
objects[c] = new Object();
}
return objects;
}
}
```
測試程式在一個執行緒中分配了5000萬個物件,根據經驗 20GB 堆空間會進行至少6次迭代。`-XX:EpsilonTLABSize` (實驗性)引數能夠精確控制 TLAB 大小。其他 OpenJDK GC 也支援 [TLAB 大小自適應策略][4],根據記憶體分配請求和其他相關因素選擇大小。這樣我們的效能測試就可以更容易固定 TLAB 大小。
[4]:https://blogs.oracle.com/daviddetlefs/entry/tlab_sizing_an_annoying_little
言歸正傳,下麵是測試結果:
```shell
Benchmark Mode Cnt Score Error Units
# 次數,數值越小越好 # TLAB size
AllocArray.test ss 9 548.462 ± 6.989 ms/op # 1 KB
AllocArray.test ss 9 268.037 ± 10.966 ms/op # 4 KB
AllocArray.test ss 9 230.726 ± 4.119 ms/op # 16 KB
AllocArray.test ss 9 223.075 ± 2.267 ms/op # 256 KB
AllocArray.test ss 9 225.404 ± 17.080 ms/op # 1024 KB
# 分配速率,數值越大越好
AllocArray.test:·gc.alloc.rate ss 9 1816.094 ± 13.681 MB/sec # 1 KB
AllocArray.test:·gc.alloc.rate ss 9 2481.909 ± 35.566 MB/sec # 4 KB
AllocArray.test:·gc.alloc.rate ss 9 2608.336 ± 14.693 MB/sec # 16 KB
AllocArray.test:·gc.alloc.rate ss 9 2635.857 ± 8.229 MB/sec # 256 KB
AllocArray.test:·gc.alloc.rate ss 9 2627.845 ± 60.514 MB/sec # 1024 KB
```
從上面的結果可以知道,我們能夠在單個執行緒中達到2.5GB/秒的分配速度。當物件大小為16位元組,意味著每秒鐘分配了1億6千萬個物件。在多執行緒條件下,分配速率可以達到每秒數十GB。當然,一旦 TLAB 變小,會造成分配開銷增加同時分配速率降低。不幸的是,因為 Hotspot 機制要求儲存一些預留空間,所以不能把 TLAB 降到1KB以內。但我們可以徹底關掉 TLAB 機制,看看對效能會有怎樣的影響:
```shell
Benchmark Mode Cnt Score Error Units
# -XX:-UseTLAB
AllocArray.test ss 9 2784.988 ± 18.925 ms/op
AllocArray.test:·gc.alloc.rate ss 9 580.533 ± 3.342 MB/sec
```
哇哦,分配速率下降了至少5倍,執行時間上升為原來的10倍!這個結果還沒有考慮回收器必須完成的工作,比如在多執行緒條件下記憶體分配可能遇到的原子操作競爭,以及查詢從哪裡分配記憶體(比如從 free list 快速分配)。由於採用 pointer bump,Epsilon GC 只要一次 compare-and-set 操作即可完成記憶體分配。如果再加入一個執行緒,即2個執行緒都不啟用 TLAB,測試效果會繼續變差。
```shell
Benchmark Mode Cnt Score Error Units
# TLAB = 4M (Epsilon 預設值)
AllocArray.test ss 9 407.729 ± 7.672 ms/op
AllocArray.test:·gc.alloc.rate ss 9 4190.670 ± 45.909 MB/sec
# -XX:-UseTLAB
AllocArray.test ss 9 8490.585 ± 410.518 ms/op
AllocArray.test:·gc.alloc.rate ss 9 422.960 ± 19.320 MB/sec
```
從結果中可以看出,效能下降了20倍!執行緒越多執行越慢。
5. 觀察
TLAB 是記憶體分配機制的主力:憑藉自身快速低開銷特點,擺脫了併發分配記憶體的效能瓶頸,提升了整體效能。有意思的是,由於分配記憶體的開銷很小,使用 TLAB 會經歷頻繁的 GC 停頓。與之相反,在不提供快速分配機制的記憶體管理器中肯定會隱藏記憶體回收效能問題。對比不同的記憶體管理器時,一定要理解問題的兩個方面以及二者之間的聯絡。
朋友會在“發現-看一看”看到你“在看”的內容