天天看点

一篇文章搞定 TLAB 原理

全系列目录:通过 JFR 与日志深入探索 JVM - 总览篇

TLAB(Thread Local Allocation Buffer)线程本地分配缓存区,这是一个线程专用的内存分配区域。既然是一个内存分配区域,我们就先要搞清楚 Java 内存大概是如何分配的。

我们一般认为 Java 中 new 的对象都是在堆上分配,这个说法不够准确,应该是大部分对象在堆上的 TLAB分配,还有一部分在 栈上分配 或者是 堆上直接分配,可能 Eden 区也可能年老代。同时,对于一些的 GC 算法,还可能直接在老年代上面分配,例如 G1 GC 中的 humongous allocations(大对象分配),就是对象在超过 Region 一半大小的时候,直接在老年代的连续空间分配。

这里,我们先只关心 TLAB 分配。

对于单线程应用,每次分配内存,会记录上次分配对象内存地址末尾的指针,之后分配对象会从这个指针开始检索分配。这个机制叫做 bump-the-pointer (撞针)。

对于多线程应用来说,内存分配需要考虑线程安全。最直接的想法就是通过全局锁,但是这个性能会很差。为了优化这个性能,我们考虑可以每个线程分配一个线程本地私有的内存池,然后采用 bump-the-pointer 机制进行内存分配。这个线程本地私有的内存池,就是 TLAB。只有 TLAB 满了,再去申请内存的时候,需要扩充 TLAB 或者使用新的 TLAB,这时候才需要锁。这样大大减少了锁使用。

我们先来浏览下 TLAB 相关的 JVM 参数以及其含义,在下一小节会深入源码分析原理以及设计这个参数是为何。

以下参数与默认值均来自于 OpenJDK 11

说明:是否启用 TLAB,默认是启用的。

默认:true

举例:如果想关闭:<code>-XX:-UseTLAB</code>

说明:TLAB 是否是自适应可变的,默认为是。

举例:如果想关闭:<code>-XX:-ResizeTLAB</code>

说明:初始 TLAB 大小。单位是字节

默认:0, 0 就是不主动设置 TLAB 初始大小,而是通过 JVM 自己计算每一个线程的初始大小

举例:<code>-XX:TLABSize=65536</code>

说明:最小 TLAB 大小。单位是字节

默认:2048

举例:<code>-XX:TLABSize=4096</code>

说明:TLAB 的大小计算涉及到了 Eden 区的大小以及可以浪费的比率。TLAB 浪费占用 Eden 的百分比,这个参数的作用会在接下来的原理说明内详细说明

默认:1

举例:<code>-XX:TLABWasteTargetPercent=10</code>

说明: TLAB 大小计算和线程数量有关,但是线程是动态创建销毁的。所以需要基于历史线程个数推测接下来的线程个数来计算 TLAB 大小。一般 JVM 内像这种预测函数都采用了 EMA (Exponential Moving Average 指数平均数)算法进行预测,会在接下来的原理说明内详细说明。这个参数代表权重,权重越高,最近的数据占比影响越大。

默认:35

举例:<code>-XX:TLABAllocationWeight=70</code>

说明: 在一次 TLAB 再填充(refill)发生的时候,最大的 TLAB 浪费。至于什么是再填充(refill),什么是 TLAB 浪费,会在接下来的原理说明内详细说明

默认:64

举例:<code>-XX:TLABRefillWasteFraction=32</code>

说明: TLAB 缓慢分配时允许的 TLAB 浪费增量,什么是 TLAB 浪费,什么是 TLAB 缓慢分配,会在接下来的原理说明内详细说明。单位不是字节,而是<code>MarkWord</code>个数,也就是 Java 堆的内存最小单元

默认:4

举例:<code>-XX:TLABWasteIncrement=4</code>

说明: 是否将新创建的 TLAB 内的对象所有字段归零

默认:false

举例:<code>-XX:+ZeroTLAB</code>

TLAB 是从堆上 Eden 区的分配的一块线程本地私有内存。线程初始化的时候,如果 JVM 启用了 TLAB(默认是启用的, 可以通过 <code>-XX:-UseTLAB</code> 关闭),则会创建并初始化 TLAB。同时,在 GC 扫描对象发生之后,线程第一次尝试分配对象的时候,也会创建并初始化 TLAB

。在 TLAB 已经满了或者接近于满了的时候,TLAB 可能会被释放回 Eden。GC 扫描对象发生时,TLAB 会被释放回 Eden。TLAB 的生命周期期望只存在于一个 GC 扫描周期内。在 JVM 中,一个 GC 扫描周期,就是一个<code>epoch</code>。那么,可以知道,TLAB 内分配内存一定是线性分配的。

TLAB 的最小大小:通过<code>MinTLABSize</code>指定

TLAB 的最大大小:不同的 GC 中不同,G1 GC 中为大对象(humongous object)大小,也就是 G1 region 大小的一半。因为开头提到过,在 G1 GC 中,大对象不能在 TLAB 分配,而是老年代。ZGC 中为页大小的 8 分之一,类似的在大部分情况下 Shenandoah GC 也是每个 Region 大小的 8 分之一。他们都是期望至少有 8 分之 7 的区域是不用退回的减少选择 Cset 的时候的扫描复杂度。对于其他的 GC,则是 int 数组的最大大小,这个和为了填充 dummy object 表示 TLAB 的空区域有关。

一篇文章搞定 TLAB 原理

为何要填充 dummy object ?

由于 TLAB 仅线程内知道哪些被分配了,在 GC 扫描发生时返回 Eden 区,如果不填充的话,外部并不知道哪一部分被使用哪一部分没有,需要做额外的检查,如果填充已经确认会被回收的对象,也就是 dummy object, GC 会直接标记之后跳过这块内存,增加扫描效率。反正这块内存已经属于 TLAB,其他线程在下次扫描结束前是无法使用的。这个 dummy object 就是 int 数组。为了一定能有填充 dummy object 的空间,一般 TLAB 大小都会预留一个 dummy object 的 header 的空间,也是一个 <code>int[]</code> 的 header,所以 TLAB 的大小不能超过int 数组的最大大小,否则无法用 dummy object 填满未使用的空间。

TLAB 的大小: 如果指定了<code>TLABSize</code>,就用这个大小作为初始大小。如果没有指定,则按照如下的公式进行计算:

*`Eden 区大小 / (当前 epcoh 内会分配对象期望线程个数 每个 epoch 内每个线程 refill 次数配置)`**

当前 epcoh 内会分配对象期望线程个数,也就是会创建并初始化 TLAB 的线程个数,这个从之前提到的 EMA (Exponential Moving Average 指数平均数)算法采集预测而来。算法是:

可以看出 TLABAllocationWeight 越大,则最近的线程数量对于这个下个 epcoh 内会分配对象期望线程个数影响越大。

每个 epoch 内期望 refill 次数就是在每个 GC 扫描周期内,refill 的次数。那么什么是 refill 呢?

在 TLAB 内存充足的时候分配对象就是快分配,否则在 TLAB 内存不足的时候分配对象就是慢分配,慢分配可能会发生两种处理:

1.线程获取新的 TLAB。老的 TLAB 回归 Eden,之后线程获取新的 TLAB 分配对象。

一篇文章搞定 TLAB 原理

2.对象在 TLAB 外分配,也就 Eden 区。

一篇文章搞定 TLAB 原理

这两种处理主要由TLAB最大浪费空间决定,这是一个动态值。初始TLAB最大浪费空间 = TLAB 的大小 / TLABRefillWasteFraction。根据前面提到的这个 JVM 参数,默认为TLAB 的大小的 64 分之一。之后,伴随着每次慢分配,这个TLAB最大浪费空间会每次递增 TLABWasteIncrement 大小的空间。如果当前 TLAB 的剩余容量大于TLAB最大浪费空间,就不在当前TLAB分配,直接在 Eden 区进行分配。如果剩余容量小于TLAB最大浪费空间,就丢弃当前 TLAB 回归 Eden,线程获取新的 TLAB 分配对象。refill 指的就是这种线程获取新的 TLAB 分配对象的行为。

那么,也就好理解为何要尽量满足 TLAB 的大小 = <code>Eden 区大小 / (下个 epcoh 内会分配对象期望线程个数 * 每个 epoch 内每个线程 refill 次数配置)</code>了。尽量让所有对象在 TLAB 内分配,也就是 TLAB 可能要占满 Eden。在下次 GC 扫描前,refill 回 Eden 的内存别的线程是不能用的,因为剩余空间已经填满了 dummy object。所以所有线程使用内存大小就是 *`下个 epcoh 内会分配对象期望线程个数 每个 epoch 内每个线程 refill 次数配置`,对象一般都在 Eden 区由某个线程分配,也就所有线程使用内存大小就最好是整个 Eden。但是这种情况太过于理想,总会有内存被填充了 dummy object而造成了浪费,因为 GC 扫描随时可能发生。假设平均下来,GC 扫描的时候,每个线程当前的 TLAB 都有一半的内存被浪费,这个每个线程使用内存的浪费的百分比率(也就是 TLABWasteTargetPercent),也就是等于(注意,仅最新的那个 TLAB 有浪费,之前 refill 退回的假设是没有浪费的**):

<code>1/2 * (每个 epoch 内每个线程期望 refill 次数) * 100</code>

那么每个 epoch 内每个线程 refill 次数配置就等于 <code>50 / TLABWasteTargetPercent</code>, 默认也就是 50 次。

当 TLABResize 设置为 true 的时候,在每个 epoch 当线程需要分配对象的时候, TLAB 大小都会被重新计算,并用这个最新的大小去从 Eden 申请内存。如果没有对象分配则不重新计算,也不申请(废话~~~)。主要是为了能让线程 TLAB 的 refill 次数 接近于 每个 epoch 内每个线程 refill 次数配置。这样就能让浪费比例接近于用户配置的 TLABWasteTargetPercent.这个大小重新计算的公式为:

<code>TLAB 最新大小 * EMA refill 次数 / 每个 epoch 内每个线程 refill 次数配置</code>。

线程初始化的时候,如果 JVM 启用了 TLAB(默认是启用的, 可以通过 -XX:-UseTLAB 关闭),则会初始化 TLAB。

TLAB 包括如下几个 field (HeapWord* 可以理解为堆中的内存地址):

<code>src/hotspot/share/gc/shared/threadLocalAllocBuffer.cpp</code>

首先是 JVM 启动的时候,全局 TLAB 需要初始化:

每个线程维护自己的 TLAB,同时每个线程的 TLAB 大小不一。TLAB 的大小主要由 Eden 的大小,线程数量,还有线程的对象分配速率决定。

在 Java 线程开始运行时,会先分配 TLAB:

<code>src/hotspot/share/runtime/thread.cpp</code>

分配 TLAB 其实就是调用 ThreadLocalAllocBuffer 的 initialize 方法。

<code>src/hotspot/share/runtime/thread.hpp</code>

ThreadLocalAllocBuffer 的 initialize 方法初始化 TLAB 的上面提到的我们要关心的各种 field:

不同的 GC 方式,有不同的方式:

G1 GC 中为大对象(humongous object)大小,也就是 G1 region 大小的一半:<code>src/hotspot/share/gc/g1/g1CollectedHeap.cpp</code>

ZGC 中为页大小的 8 分之一,类似的在大部分情况下 Shenandoah GC 也是每个 Region 大小的 8 分之一。他们都是期望至少有 8 分之 7 的区域是不用退回的减少选择 Cset 的时候的扫描复杂度:

<code>src/hotspot/share/gc/shenandoah/shenandoahHeap.cpp</code>

<code>src/hotspot/share/gc/z/zHeap.cpp</code>

对于其他的 GC,则是 int 数组的最大大小,这个和为了填充 dummy object 表示 TLAB 的空区域有关。这个原因之前已经说明了。

当 new 一个对象时,需要调用<code>instanceOop InstanceKlass::allocate_instance(TRAPS)</code>

<code>src/hotspot/share/oops/instanceKlass.cpp</code>

其核心就是<code>heap()-&amp;gt;obj_allocate(this, size, CHECK_NULL)</code>从堆上面分配内存:

<code>src/hotspot/share/gc/shared/collectedHeap.inline.hpp</code>

使用全局的 <code>ObjAllocator</code> 实现进行对象内存分配:

<code>src/hotspot/share/gc/shared/memAllocator.cpp</code>

<code>src/hotspot/share/gc/shared/threadLocalAllocBuffer.inline.hpp</code>

TLAB最大浪费空间 <code>_refill_waste_limit</code> 初始值为 TLAB 大小除以 TLABRefillWasteFraction:

<code>src/hotspot/share/gc/shared/threadLocalAllocBuffer.hpp</code>

每次慢分配,调用<code>record_slow_allocation(size_t obj_size)</code>记录慢分配的同时,增加 TLAB 最大浪费空间的大小:

<code>_desired_size</code>是什么时候变得呢?怎么变得呢?

那是什么时候调用 <code>resize</code> 的呢?一般是每次 GC 完成的时候。大部分的 GC 都是在<code>gc_epilogue</code>方法里面调用,将每个线程的 TLAB 均 <code>resize</code> 掉。

TLAB 回收就是指线程将当前的 TLAB 丢弃回 Eden 区。TLAB 回收有两个时机:一个是之前提到的在分配对象时,剩余 TLAB 空间不足,在 TLAB 满但是浪费空间小于最大浪费空间的情况下,回收当前的 TLAB 并获取一个新的。另一个就是在发生 GC 时,其实更准确的说是在 GC 开始扫描时。不同的 GC 可能实现不一样,但是时机是基本一样的,这里以 G1 GC 为例:

<code>src/hotspot/share/gc/g1/g1CollectedHeap.cpp</code>

为何要确保堆内存是可以解析的呢?这样有利于更快速的扫描堆上对象。确保内存可以解析里面做了什么呢?

一篇文章搞定 TLAB 原理
一篇文章搞定 TLAB 原理
一篇文章搞定 TLAB 原理
一篇文章搞定 TLAB 原理

根据上面的原理以及源代码分析,可以得知 TLAB 是 Eden 区的一部分,主要用于线程本地的对象分配。在 TLAB 满的时候分配对象内存,可能会发生两种处理:

线程获取新的 TLAB。老的 TLAB 回归 Eden,Eden进行管理,之后线程通过新的 TLAB 分配对象。

对象在 TLAB 外分配,也就 Eden 区。

对于 线程获取新的 TLAB 这种处理,也就是 refill,按照 TLAB 设计原理,这个是经常会发生的,每个 epoch 内可能会都会发生几次。但是对象直接在 Eden 区分配,是我们要避免的。JFR 对于

JFR 针对这两种处理有不同的事件可以监控。分别是<code>jdk.ObjectAllocationOutsideTLAB</code>和<code>jdk.ObjectAllocationInNewTLAB</code>。<code>jdk.ObjectAllocationInNewTLAB</code>对应 refill,这个一般我们没有监控的必要(在你没有修改默认的 TLAB 参数的前提下),用这个测试并学习 TLAB 的意义比监控的意义更大。<code>jdk.ObjectAllocationOutsideTLAB</code>对应对象直接在 Eden 区分配,是我们需要监控的。至于怎么不影响线上性能安全的监控,怎么查看并分析,怎么解决,以及测试生成这两个事件,会在下一节详细分析。