全系列目录: 通过 JFR 与日志深入探索 JVM - 总览篇
什么是 TLAB?
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 参数详解
我们先来浏览下 TLAB 相关的 JVM 参数以及其含义,在下一小节会深入源码分析原理以及设计这个参数是为何。
以下参数与默认值均来自于 OpenJDK 11
1. UseTLAB
说明:是否启用 TLAB,默认是启用的。
默认:true
举例:如果想关闭:-XX:-UseTLAB
2. ResizeTLAB
说明:TLAB 是否是自适应可变的,默认为是。
默认:true
举例:如果想关闭:-XX:-ResizeTLAB
3. TLABSize
说明:初始 TLAB 大小。单位是字节
默认:0, 0 就是不主动设置 TLAB 初始大小,而是通过 JVM 自己计算每一个线程的初始大小
举例:-XX:TLABSize=65536
4. MinTLABSize
说明:最小 TLAB 大小。单位是字节
默认:2048
举例:-XX:TLABSize=4096
5. TLABWasteTargetPercent
说明:TLAB 的大小计算涉及到了 Eden 区的大小以及可以浪费的比率。TLAB 浪费占用 Eden 的百分比,这个参数的作用会在接下来的原理说明内详细说明
默认:1
举例:-XX:TLABWasteTargetPercent=10
6. TLABAllocationWeight
说明: TLAB 大小计算和线程数量有关,但是线程是动态创建销毁的。所以需要基于历史线程个数推测接下来的线程个数来计算 TLAB 大小。一般 JVM 内像这种预测函数都采用了 EMA (Exponential Moving Average 指数平均数)算法进行预测,会在接下来的原理说明内详细说明。这个参数代表权重,权重越高,最近的数据占比影响越大。
默认:35
举例:-XX:TLABAllocationWeight=70
7. TLABRefillWasteFraction
说明: 在一次 TLAB 再填充(refill)发生的时候,最大的 TLAB 浪费。至于什么是再填充(refill),什么是 TLAB 浪费,会在接下来的原理说明内详细说明
默认:64
举例:-XX:TLABRefillWasteFraction=32
8. TLABWasteIncrement
说明: TLAB 缓慢分配时允许的 TLAB 浪费增量,什么是 TLAB 浪费,什么是 TLAB 缓慢分配,会在接下来的原理说明内详细说明。单位不是字节,而是MarkWord
个数,也就是 Java 堆的内存最小单元
默认:4
举例:-XX:TLABWasteIncrement=4
9. ZeroTLAB
说明: 是否将新创建的 TLAB 内的对象所有字段归零
默认:false
举例:-XX:+ZeroTLAB
TLAB 生命周期与原理详解
TLAB 是从堆上 Eden 区的分配的一块线程本地私有内存。线程初始化的时候,如果 JVM 启用了 TLAB(默认是启用的, 可以通过 -XX:-UseTLAB
关闭),则会创建并初始化 TLAB。同时,在 GC 扫描对象发生之后,线程第一次尝试分配对象的时候,也会创建并初始化 TLAB 。在 TLAB 已经满了或者接近于满了的时候,TLAB 可能会被释放回 Eden。GC 扫描对象发生时,TLAB 会被释放回 Eden。TLAB 的生命周期期望只存在于一个 GC 扫描周期内。在 JVM 中,一个 GC 扫描周期,就是一个epoch
。那么,可以知道,TLAB 内分配内存一定是线性分配的。
TLAB 的最小大小:通过MinTLABSize
指定
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 的空区域有关。
为何要填充 dummy object ?
由于 TLAB 仅线程内知道哪些被分配了,在 GC 扫描发生时返回 Eden 区,如果不填充的话,外部并不知道哪一部分被使用哪一部分没有,需要做额外的检查,如果填充已经确认会被回收的对象,也就是 dummy object, GC 会直接标记之后跳过这块内存,增加扫描效率。反正这块内存已经属于 TLAB,其他线程在下次扫描结束前是无法使用的。这个 dummy object 就是 int 数组。为了一定能有填充 dummy object 的空间,一般 TLAB 大小都会预留一个 dummy object 的 header 的空间,也是一个 int[]
的 header,所以 TLAB 的大小不能超过int 数组的最大大小,否则无法用 dummy object 填满未使用的空间。
TLAB 的大小: 如果指定了TLABSize
,就用这个大小作为初始大小。如果没有指定,则按照如下的公式进行计算: Eden 区大小 / (当前 epcoh 内会分配对象期望线程个数 * 每个 epoch 内每个线程 refill 次数配置)
当前 epcoh 内会分配对象期望线程个数,也就是会创建并初始化 TLAB 的线程个数,这个从之前提到的 EMA (Exponential Moving Average 指数平均数)算法采集预测而来。算法是:
采样次数小于等于 100 时,每次采样: 1. 次数权重 = 100 / 次数 2. 计算权重 = 次数权重 与 TLABAllocationWeight 中大的那个 3. 新的平均值 = (100% - 计算权重%) * 之前的平均值 + 计算权重% * 当前采样值 采样次数大于 100 时,每次采样: 新的平均值 = (100% - TLABAllocationWeight %) * 之前的平均值 + TLABAllocationWeight % * 当前采样值
可以看出 TLABAllocationWeight 越大,则最近的线程数量对于这个下个 epcoh 内会分配对象期望线程个数影响越大。
每个 epoch 内期望 refill 次数就是在每个 GC 扫描周期内,refill 的次数。那么什么是 refill 呢?
在 TLAB 内存充足的时候分配对象就是快分配,否则在 TLAB 内存不足的时候分配对象就是慢分配,慢分配可能会发生两种处理:
1.线程获取新的 TLAB。老的 TLAB 回归 Eden,之后线程获取新的 TLAB 分配对象。
2.对象在 TLAB 外分配,也就 Eden 区。
这两种处理主要由TLAB最大浪费空间决定,这是一个动态值。初始TLAB最大浪费空间 = TLAB 的大小 / TLABRefillWasteFraction。根据前面提到的这个 JVM 参数,默认为TLAB 的大小的 64 分之一。之后,伴随着每次慢分配,这个TLAB最大浪费空间会每次递增 TLABWasteIncrement 大小的空间。如果当前 TLAB 的剩余容量大于TLAB最大浪费空间,就不在当前TLAB分配,直接在 Eden 区进行分配。如果剩余容量小于TLAB最大浪费空间,就丢弃当前 TLAB 回归 Eden,线程获取新的 TLAB 分配对象。refill 指的就是这种线程获取新的 TLAB 分配对象的行为。
那么,也就好理解为何要尽量满足 TLAB 的大小 = Eden 区大小 / (下个 epcoh 内会分配对象期望线程个数 * 每个 epoch 内每个线程 refill 次数配置)
了。尽量让所有对象在 TLAB 内分配,也就是 TLAB 可能要占满 Eden。在下次 GC 扫描前,refill 回 Eden 的内存别的线程是不能用的,因为剩余空间已经填满了 dummy object。所以所有线程使用内存大小就是 下个 epcoh 内会分配对象期望线程个数 * 每个 epoch 内每个线程 refill 次数配置
,对象一般都在 Eden 区由某个线程分配,也就所有线程使用内存大小就最好是整个 Eden。但是这种情况太过于理想,总会有内存被填充了 dummy object而造成了浪费,因为 GC 扫描随时可能发生。假设平均下来,GC 扫描的时候,每个线程当前的 TLAB 都有一半的内存被浪费,这个每个线程使用内存的浪费的百分比率(也就是 TLABWasteTargetPercent),也就是等于(注意,仅最新的那个 TLAB 有浪费,之前 refill 退回的假设是没有浪费的):
1/2 * (每个 epoch 内每个线程期望 refill 次数) * 100
那么每个 epoch 内每个线程 refill 次数配置就等于 50 / TLABWasteTargetPercent
, 默认也就是 50 次。
当 TLABResize 设置为 true 的时候,在每个 epoch 当线程需要分配对象的时候, TLAB 大小都会被重新计算,并用这个最新的大小去从 Eden 申请内存。如果没有对象分配则不重新计算,也不申请(废话~~~)。主要是为了能让线程 TLAB 的 refill 次数 接近于 每个 epoch 内每个线程 refill 次数配置。这样就能让浪费比例接近于用户配置的 TLABWasteTargetPercent.这个大小重新计算的公式为: TLAB 最新大小 * EMA refill 次数 / 每个 epoch 内每个线程 refill 次数配置
。