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) ; 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 停顿。与之相反,在不提供快速分配机制的内存管理器中肯定会隐藏内存回收性能问题。对比不同的内存管理器时,一定要理解问题的两个方面以及二者之间的联系。