JVM解剖公园4:TLAB分配

问题

什么是TLAB分配?什么指针碰撞分配?谁负责分配对象呢?

理论

在大多数情况下new MyClass(),运行环境必须为相关实例分配内存,伪代码如下:

 ref Allocate(T type);
 ref AllocateArray(T type, int size);

由于内存管理器本身通常是用不同的语言编写(例如Java中,HotSpot JVM是用C++编写的),因此接口信息变得难以预料了。例如,Java程序需要转换为本地VM代码。它的成本是多少?内存管理器是否必须处理多个需要内存的线程?
为了优化这一点,我们可能会允许线程根据需要分配整个内存块。在Hotspot中,这些块称为线程本地分配缓冲区:Thread Local Allocation Buffers (TLAB),并且有一个复杂的机制来支持它们。
请注意,TLAB是线程范畴的,就像缓冲区一样接受当前线程的分配。它们仍然是Java堆的一部分,线程仍可以将对新分配的对象的引用写入TLAB之外的字段。
所有已知的OpenJDK GC都支持TLAB分配。这部分VM代码在它们之间能共享。所有Hotspot编译器都支持TLAB,通常生成的对象分配代码如下:

0x00007f3e6bb617cc: mov    0x60(%r15),%rax        ; TLAB "current"
0x00007f3e6bb617d0: mov    %rax,%r10              ; tmp = current
0x00007f3e6bb617d3: add    $0x10,%r10             ; tmp += 16 (object size)
0x00007f3e6bb617d7: cmp    0x70(%r15),%r10        ; tmp > tlab_size?
0x00007f3e6bb617db: jae    0x00007f3e6bb61807     ; TLAB is done, jump and request another one
0x00007f3e6bb617dd: mov    %r10,0x60(%r15)        ; current = tmp (TLAB is fine, alloc!)
0x00007f3e6bb617e1: prefetchnta 0xc0(%r10)        ; ...
0x00007f3e6bb617e9: movq   $0x1,(%rax)            ; store header to (obj+0)
0x00007f3e6bb617f0: movl   $0xf80001dd,0x8(%rax)  ; store klass to (obj+8)
0x00007f3e6bb617f7: mov    %r12d,0xc(%rax)        ; zero out the rest of the object

分配方式是在生成的代码中内联,因此不需要调用GC来分配对象。
如果我们要求分配耗尽TLAB的对象,或者对象足够大以至于不适合TLAB,那么我们采用“慢速方式”,并在那里满足分配,或者返回新的TLAB。
最常见的方式是,将TLAB当前指针位置移动与对象大小相等的距离,然后继续执行。
这就是为什么这种方式有时被称为“指针碰撞”的原因。然而,指针碰撞需要一块连续的内存来分配-这会带来对堆压缩的需求。
CMS在老年代中进行自由列表分配,从而实现并发扫描。这之前已经收集过所有受益于指针碰撞分配的“年轻代”对象,幸存下来的对象很少了,还是有自由列表的分配开销。

译者注:
1. 自由列表(free list)是一种数据结构,用来实现动态内存分配方案。通过把一系列未分配的内存区域连接起来形成一个链表,利用每块未分配区域的第一个字长(即一个word)来存储指向next的指针。
指针碰撞。堆是绝对规整的,意味着其中用过的内存放在一边,其他未使用的内存放在另外一边,分界线由一个指针作为指示器。这种情况下,分配内存的行为仅仅就是把指针向空闲内存一边移动相应距离,这种分配方式也被形象地成为“指针碰撞”(Bump the Pointer)。

为了实验,我们可以关闭TLAB机器-XX:-UseTLAB。然后,所有分配都将采用本地方法,如下所示:

-   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  <---- entry point to GC
              0.35% AllocTracer::send_allocation_outside_tlab_event

......但是,正如你将看到的那样,这通常是一个坏主意。

实验

让我们​​尝试构建一个实验来查看TLAB分配的效果。由于机器由所有GC实例共享,因此通过使用实验性的Epsilon GC来最小化运行时减少干扰。事实上,它的分配实现是唯一的,是绝佳的小白鼠。
快速写下测试代码:分配50M对象,并使用SingleShot模式运行JMH以获取统计信息和分析。您也可以使用独立测试来完成此操作,但SingleShot在这里太方便了。

@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万对象,-XX:EpsilonTLABSize参数用于精确控制TLAB尺寸。其他OpenJDK GC的调整策略为自适应,该策略根据分配压力和其他因素选择大小。
对于我们的性能测试,Epsilon GC更容易确定TLAB尺寸。
不用多说,结果如下:

Benchmark                     Mode  Cnt     Score    Error   Units

# Times, lower is better                                            # 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

# Allocation rates, higher is better
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.5 GB /秒。如果使用16字节对象,这意味着每秒1.6亿个对象。在多线程应用中,分配速度可能达到每秒数十GB。当然,一旦TLAB变小,分配成本就会上升,分配速度也会下降。
我们无法让TLAB低于1 KB,因为Hotspot机制需要浪费一些空间,但我们可以完全关闭TLAB机器,以查看性能影响:

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倍!如果还是多线程的话,当它们请求内存(可能是原子操作)时,或者它需要查找从哪里分配内存(如自由列表)时(对于Epsilon GC中的分配方式是通过CAS的指针碰撞分配),这甚至都没有涉及到收集器 ,会怎么样呢?
让我们添加一个额外的线程 - 总共只有两个正在运行的线程,去掉TLAB,一场灾难:

Benchmark                            Mode  Cnt           Score       Error   Units

# TLAB = 4M (default for 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倍性能差距!

结论

TLAB是分配机制的主力:它们消除了分配器的并发瓶颈,提供廉价的分配速度,并提高性能。
有趣的是,正因为分配如此的便宜,使用TLAB是反而会导致更频繁的GC。
但是,在任何内存管理器实现中,没有快速分配策略肯定会隐含性能问题。在比较内存管理器时,要了解故事的两个部分,以及它们之间的关系。

Comments
Write a Comment