Java虚拟机-TLAB

Java虚拟机-TLAB

因为堆是全局共享的,因此在同一时间,可能有多个线程在堆上申请空间,那么,在并发场景中,如果两个线程先后把对象引用指向了同一个内存区域,那么对象的内存分配过程就必须进行同步控制。无论是使用哪种同步方案,都会影响内存的分配效率。

而Java对象的分配是Java中的高频操作,所有,人们想到另外一个办法(TLAB)来提升效率。

TLAB是什么

每个线程在Java堆中预先分配一小块内存,然后再给对象分配内存的时候,直接在自己这块”私有”内存中分配(快速分配),当这部分区域用完之后,再在eden中分配新的”私有”内存(慢速分配)。这种方案被称之为TLAB分配,即Thread Local Allocation Buffer。这部分Buffer是从堆中划分出来的, 本地线程独享的。

TLAB是线程专属的。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

  • 这里值得注意的是,我们说TLAB是线程独享的,但是只是在“分配”这个动作上是线程独占的,至于在读取、垃圾回收等动作上都是线程共享的。而且在使用上也没有什么区别。也就是说,虽然每个线程在初始化时都会去堆内存中申请一块TLAB,并不是说这个TLAB区域的内存其他线程就完全无法访问了,其他线程的读取还是可以的,只不过无法在这个区域中分配内存而已。
  • 并且,在TLAB分配之后,并不影响对象的移动和回收,也就是说,虽然对象刚开始可能通过TLAB分配内存,存放在Eden区,但是还是会被垃圾回收或者被移到Survivor Space、Old Gen等。
  • TLAB是在eden区分配的,且TLAB的大小是eden中的一小部分。所以,必然存在一些大对象是无法在TLAB直接分配。遇到TLAB中无法分配的大对象,对象还是可能在eden区或者老年代等进行分配的,但是这种分配就需要进行同步控制,这也是为什么我们经常说:小的对象比大的对象分配起来更加高效。

TLAB的依据是一般线程分配内存的特性是比较稳定的。这里的比较稳定指的是,每次分配对象的大小,每轮 GC 分配区间内的分配对象的个数以及总大小。所以,我们可以考虑每个线程分配内存后,就将这块内存保留起来,用于下次分配,这样就不用每次从主内存中分配了。如果能估算每轮 GC 内每个线程使用的内存大小,则可以提前分配好内存给线程,这样就更能提高分配效率。

最大浪费空间

虽然在一定程度上,TLAB大大的提升了对象的分配速度,但是TLAB并不是就没有任何问题的。因为TLAB内存区域并不是很大,所以,有可能会经常出现不够的情况。

比如一个线程的TLAB空间有100KB,其中已经使用了80KB,当需要再分配一个30KB的对象时,就无法直接在TLAB中分配,遇到这种情况时,有两种处理方案:

  1. 如果一个对象需要的空间大小超过TLAB中剩余的空间大小,则直接在堆内存中对该对象进行内存分配。
  2. 如果一个对象需要的空间大小超过TLAB中剩余的空间大小,则废弃当前TLAB,重新申请TLAB空间再次进行内存分配。

以上两个方案各有利弊,如果采用方案1,那么就可能存在着一种极端情况,就是TLAB只剩下1KB,就会导致后续需要分配的大多数对象都需要在堆内存直接分配。

如果采用方案2,也有可能存在频繁废弃TLAB,频繁申请TLAB的情况,而我们知道,虽然在TLAB上分配内存是线程独享的,但是TLAB内存自己从堆中划分出来的过程确实可能存在冲突的,所以,TLAB的分配过程其实也是需要并发控制的。而频繁的TLAB分配就失去了使用TLAB的意义。

为了解决这两个方案存在的问题,虚拟机定义了一个refill_waste的值,这个值可以翻译为“最大浪费空间”。

当请求分配的内存大于refill_waste的时候,会选择在堆内存中分配。若小于refill_waste值,则会废弃当前TLAB,重新创建TLAB进行对象内存分配。

前面的例子中,TLAB总空间100KB,使用了80KB,剩余20KB,如果设置的refill_waste的值为25KB,那么如果新对象的内存大于25KB,则直接堆内存分配,如果小于25KB,则会废弃掉之前的那个TLAB,重新分配一个TLAB空间,给新对象分配内存。

总结下: 当分配一个对象堆内存空间时,

  • 首先都会检查是否启用了 TLAB,如果启用了,则会尝试 TLAB 分配;
  • 如果当前线程的 TLAB 大小足够,那么从线程当前的 TLAB 中分配;
  • 如果不够,但是当前 TLAB 剩余空间小于最大浪费空间限制,则从堆上(一般是eden)重新申请一个新的 TLAB 进行分配。
  • 否则,直接在 TLAB 外进行分配(此时使用jvm的分配规则,例如大对象直接分配到老年代)。

内存间隙

TLAB避免从堆上直接分配内存从而避免频繁的锁争用,其思路大体上可以认为是"空间换时间"。那么TLAB也会带来空间上的副作用(内存空隙)。

  • 当前 TLAB 不够分配时,如果剩余空间小于最大浪费空间限制,那么这个 TLAB 会被退回 Eden,重新申请一个新的。这个剩余空间就会成为空隙。
  • 当发生 GC 的时候,TLAB 没有用完,没有分配的内存也会成为空隙。

由于 TLAB 仅线程内知道哪些被分配了,在 GC 扫描发生时返回 Eden 区,如果不填充的话,外部并不知道哪一部分被使用哪一部分没有,需要做额外的检查,那么会影响 GC 扫描效率。所以 TLAB 回归 Eden 的时候,会将剩余可用的空间用一个 dummy object 填充满。如果填充已经确认会被回收的对象,也就是 dummy object, GC 会直接标记之后跳过这块内存,增加扫描效率。

TLAB动态分配

前面介绍过TLAB期望GC内的内存分配是相对稳定的。但是实际上每个线程在每一轮 GC 的分配情况可能都是不一样的:

  • 不同的线程业务场景不同导致分配对象大小不同。
  • 不同时间段内线程压力并不均匀。
  • 同一时间段同一线程池内的线程的业务压力也不一定不能做到很均匀。

所以,综合考虑以上情况,TLAB的大小是动态变化的。 TLAB 的大小受 GC轮次内会分配对象的线程的期望个数线程TLAB期望申请次数的影响。

EMA

JVM 计算 TLAB 经常用到 EMA(Exponential Moving Average 指数平均数) 算法,根据历史值和采样值得出最新的期望值。

EMA 算法的核心在于设置合适的权重:

1
2
3
4
5
6
7
采样次数小于等于 100 时,每次采样:
1. 次数权重 = 100 / 次数
2. 计算权重 = max(次数权重,TLABAllocationWeight)
3. 新的平均值 = (100% - 计算权重%) * 之前的平均值 + 计算权重% * 当前采样值

采样次数大于 100 时,每次采样:
新的平均值 = (100% - TLABAllocationWeight %) * 之前的平均值 + TLABAllocationWeight % * 当前采样值

权重越大,变化得越快,受历史数据影响越小。

TLAB初始大小

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

1
TLAB期望大小=堆给TLAB的空间总大小/(当前有效分配线程个数期望*重填次数配置)
  • 堆给TLAB的空间总大小一般是eden大小,即最理想的情况下,Eden 区全部用于TLAB分配。
  • 当前有效分配线程个数期望:有效分配线程个数 EMA 的最小权重是 TLABAllocationWeight。有效分配线程个数 EMA 在有线程进行第一次有效对象分配的时候进行采集,在 TLAB 初始化的时候读取这个值计算 TLAB 期望大小。
  • TLAB 重填次数配置(refills time):根据 TLABWasteTargetPercent 计算的次数,假设平均下来,GC 扫描的时候,每个线程当前的 TLAB 都有一半的内存被浪费,这个每个线程使用内存的浪费的百分比率(也就是 TLABWasteTargetPercent),也就是:1/2 * (每个 epoch 内每个线程期望 refill 次数) * 100, 所以 refills 次数 = 100 / (2 * TLABWasteTargetPercent)

TLAB初始分配比例

1
TLAB初始分配比例 = 初始期望大小*重填次数配置/堆给TLAB的空间总大小

在TLAB初始化的时候,分配比例其实就是等于 1/当前有效分配线程个数(不指定TLABSize)。

期望计算

首先为了保证本次计算具有参考意义,需要先判断是否堆上 TLAB 空间被用了一半以上,假设不足,那么认为本轮 GC 的数据没有参考意义。

如果被用了一半以上,那么计算新的分配比例,新的分配比例 = 线程本轮 GC 分配空间的大小 / 堆上所有线程 TLAB 使用的空间,这么计算主要因为分配比例描述的是当前线程占用堆上所有给 TLAB 的空间的比例,每个线程不一样,通过这个比例动态控制不同业务线程的 TLAB 大小。线程本轮 GC 分配空间的大小包含 TLAB 中分配的和 TLAB 外分配的。

1
2
期望大小 = 堆给TLAB的空间总大小 * 当前分配比例 EMA / 重填次数配置
最大浪费空间限制 = 期望大小 / TLABRefillWasteFraction。

JVM参数控制

UseTLAB

TLAB功能是可以选择开启或者关闭的,可以通过设置-XX:+/-UseTLAB参数来指定是否开启TLAB分配。默认是启用的。

ZeroTLAB

是否将新创建的 TLAB 内的所有字节归零。默认false。

ResizeTLAB

默认情况下,TLAB的空间会在运行时不断调整(EMA算法),使系统达到最佳的运行状态。如果需要禁用自动调整TLAB的大小,可以使用-XX:-ResizeTLAB来禁用,并且使用-XX:TLABSize来手工指定TLAB的大小。 默认开启。

TLABSize

TLABSize是初始 TLAB 大小。单位是字节。默认为0,就是不主动设置 TLAB 初始大小,而是通过 JVM 自己计算每一个线程的初始大小。

MinTLABSize

MinTLABSize,最小 TLAB 大小。单位是字节。默认2048。

TLABAllocationWeight

TLAB 初始大小计算和线程数量有关,但是线程是动态创建销毁的。所以需要基于历史线程个数推测接下来的线程个数来计算 TLAB 大小。TLAB 重新计算大小是根据分配比例,分配比例也是采用了 EMA 算法,最小权重是 TLABAllocationWeight。默认值35。

TLABWasteTargetPercent

TLAB 的大小计算涉及到了 Eden 区的大小以及可以浪费的比率。TLAB 浪费指的是上面提到的重新申请新的 TLAB 的时候老的 TLAB 没有分配的空间。这个参数其实就是 TLAB 浪费占用 Eden 的百分比,默认值1。

TLABRefillWasteFraction

初始最大浪费空间限制计算参数,初始最大浪费空间限制 = 当前期望 TLAB 大小 / TLABRefillWasteFraction。 默认值64。

TLABWasteIncrement

最大浪费空间限制并不是不变的,在发生 TLAB 缓慢分配的时候(也就是当前 TLAB 空间不足以分配的时候),会增加最大浪费空间限制,这样在一定次数的直接堆上分配之后,当前最大浪费空间限制一直增大会导致当前 TLAB 剩余空间小于当前最大浪费空间限制,从而申请新的 TLAB 进行分配。

单位不是字节,而是 MarkWord 个数,也就是 Java 堆的内存最小单元,64 位虚拟机的情况下,MarkWord 大小为 8 字节。默认值是4。

监控

TLAB没有对应的JMX指标,只能通过java mission control 使用jfr事件dump分析。高版本的JVM(14+)还支持Stream方式监控,下面就是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class JfrStream
{
public static void main(String[] args)
throws IOException, ParseException
{
Configuration config = Configuration.getConfiguration("default");
try (var es = new RecordingStream(config)) {
//启用 TLAB 事件监控
es.enable("jdk.ObjectAllocationOutsideTLAB");
es.enable("jdk.ObjectAllocationInNewTLAB");

es.onEvent("jdk.ObjectAllocationOutsideTLAB", System.out::println);
es.onEvent("jdk.ObjectAllocationInNewTLAB", System.out::println);
es.setMaxAge(Duration.ofSeconds(10));
es.start();
}
}
}