1. 寫在前面
“[JVM 解剖公園]”是一個持續更新的系列迷你博客,閱讀每篇文章一般需要5到10分鐘。限於篇幅,僅對某個主題按照問題、測試、基準程序、觀察結果深入講解。因此,這裏的數據和討論可以當軼事看,不做寫作風格、句法和語義錯誤、重複或一致性檢查。如果選擇採信文中內容,風險自負。
[1]:https://shipilev.net/jvm-anatomy-park
[2]:http://twitter.com/shipilev
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) ; 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 <---- GC 入口
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 停頓。與之相反,在不提供快速分配機制的內存管理器中肯定會隱藏內存回收性能問題。對比不同的內存管理器時,一定要理解問題的兩個方面以及二者之間的聯繫。