全网最硬核 JVM TLAB 分析

本文详细探讨了JVM的Thread Local Allocate Buffer(TLAB)机制,解释了TLAB如何提高内存分配效率,避免多线程竞争。内容涵盖了TLAB的生命周期、初始化、内存分配流程,以及在GC前后的操作。文章还分析了相关参数对TLAB性能的影响,并解释了为何需要填充dummy object以优化GC扫描。
摘要由CSDN通过智能技术生成

1. 观前提醒
本期内容比较硬核,非常全面,涉及到了设计思想到实现原理以及源码,并且还给出了相应的日志以及监控方式,如果有不清楚或者有疑问的地方,欢迎留言。
其中涉及到的设计思想主要为个人理解,实现原理以及源码解析也是个人整理,如果有不准确的地方,非常欢迎指正!提前感谢~~


2. 分配内存实现思路
我们经常会 new 一个对象,这个对象是需要占用空间的,第一次 new 一个对象占用的空间如 图00 所示,

我们这里先只关心堆内部的存储,元空间中的存储,我们会在另一个系列详细讨论。堆内部的存储包括对象头,对象体以及内存对齐填充,那么这块空间是如何分配的呢?
首先,对象所需的内存,在对象的类被解析加载进入元空间之后,就可以在分配内存创建前计算出来。假设现在我们自己来设计堆内存分配,一种最简单的实现方式就是线性分配,也被称为撞针分配(bump-the-pointer)。

每次需要分配内存时,先计算出需要的内存大小,然后 CAS 更新如 图01 中所示的内存分配指针,标记分配的内存。但是内存一般不是这么整齐的,可能有些内存在分配有些内存就被释放回收了。所以一般不会只靠撞针分配。一种思路是在撞针分配的基础上,加上一个 FreeList。

简单的实现是将释放的对象内存加入 FreeList,下次分配对象的时候,优先从 FreeList 中寻找合适的内存大小进行分配,之后再在主内存中撞针分配。


这样虽然一定程度上解决了问题,但是目前大多数应用是多线程的,所以内存分配是多线程的,都从主内存中分配,CAS 更新重试过于频繁导致效率低下。目前的应用,一般根据不同业务区分了不同的线程池,在这种情况下,一般每个线程分配内存的特性是比较稳定的。这里的比较稳定指的是,每次分配对象的大小,每轮 GC 分配区间内的分配对象的个数以及总大小。所以,我们可以考虑每个线程分配内存后,就将这块内存保留起来,用于下次分配,这样就不用每次从主内存中分配了。如果能估算每轮 GC 内每个线程使用的内存大小,则可以提前分配好内存给线程,这样就更能提高分配效率。这种内存分配的实现方式,在 JVM 中就是 TLAB (Thread Local Allocate Buffer)。


3. JVM 对象堆内存分配流程简述

我们这里不考虑栈上分配,这些会在 JIT 的章节详细分析,我们这里考虑的是无法栈上分配需要共享的对象


对于 HotSpot JVM 实现,所有的 GC 算法的实现都是一种对于堆内存的管理,也就是都实现了一种堆的抽象,它们都实现了接口 CollectedHeap。当分配一个对象堆内存空间时,在 CollectedHeap 上首先都会检查是否启用了 TLAB,如果启用了,则会尝试 TLAB 分配;如果当前线程的 TLAB 大小足够,那么从线程当前的 TLAB 中分配;如果不够,但是当前 TLAB 剩余空间小于最大浪费空间限制(这是一个动态的值,我们后面会详细分析),则从堆上(一般是 Eden 区) 重新申请一个新的 TLAB 进行分配。否则,直接在 TLAB 外进行分配。TLAB 外的分配策略,不同的 GC 算法不同。例如G1:

  • 如果是 Humongous 对象(对象在超过 Region 一半大小的时候),直接在 Humongous 区域分配(老年代的连续区域)。
  • 根据 Mutator 状况在当前分配下标的 Region 内分配

4. TLAB 的生命周期

TLAB 是线程私有的,线程初始化的时候,会创建并初始化 TLAB。同时,在 GC 扫描对象发生之后,线程第一次尝试分配对象的时候,也会创建并初始化 TLAB。 TLAB 生命周期停止(TLAB 声明周期停止不代表内存被回收,只是代表这个 TLAB 不再被这个线程私有管理)在:

  • 当前 TLAB 不够分配,并且剩余空间小于最大浪费空间限制,那么这个 TLAB 会被退回 Eden,重新申请一个新的
  • 发生 GC 的时候,TLAB 被回收。

5. TLAB 要解决的问题以及带来的问题与解决方案的思考
TLAB 要解决的问题很明显,尽量避免从堆上直接分配内存从而避免频繁的锁争用。
引入 TLAB 之后,TLAB 的设计上,也有很多值得考虑的问题。
5.1. 引入 TLAB 后,会有内存孔隙问题,还可能影响 GC 扫描性能
出现孔隙的情况:

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

如果不管这些孔隙,由于 TLAB 仅线程内知道哪些被分配了,在 GC 扫描发生时返回 Eden 区,如果不填充的话,外部并不知道哪一部分被使用哪一部分没有,需要做额外的检查,那么会影响 GC 扫描效率。所以 TLAB 回归 Eden 的时候,会将剩余可用的空间用一个 dummy object 填充满。如果填充已经确认会被回收的对象,也就是 dummy object, GC 会直接标记之后跳过这块内存,增加扫描效率。但是同时,由于需要填充这个 dummy object,所以需要预留出这个对象的对象头的空间
5.2. 某个线程在一轮 GC 内分配的内存并不稳定
如果我们能提前知道在这一轮内每个线程会分配多少内存,那么我们可以直接提前分配好。但是,这简直是痴人说梦。每个线程在每一轮 GC 的分配情况可能都是不一样的:

  • 不同的线程业务场景不同导致分配对象大小不同。我们一般会按照业务区分不同的线程池,做好线程池隔离。对于用户请求,每次分配的对象可能比较小。对于后台分析请求,每次分配的对象相对大一些。
  • 不同时间段内线程压力并不均匀。业务是有高峰有低谷的,高峰时间段内肯定分配对象更多。
  • 同一时间段同一线程池内的线程的业务压力也不一定不能做到很均匀。很可能只有几个线程很忙,其他线程很闲。

所以,综合考虑以上情况,我们应该这么实现 TLAB:

  • 不能一下子就给一个线程申请一个比较大的 TLAB,而是考虑这个线程 TLAB 分配满之后再申请新的,这样更加灵活。
  • 每次申请 TLAB 的大小是变化的,并不是固定的。
  • 每次申请 TLAB 的大小需要考虑当前 GC 轮次内会分配对象的线程的个数期望
  • 每次申请 TLAB 的大小需要考虑所有线程期望 TLAB 分配满重新申请新的 TLAB 次数

6. JVM 中的期望计算 EMA


在上面提到的 TLAB 大小设计的时候,我们经常提到期望。这个期望是根据历史数据计算得出的,也就是每次输入采样值,根据历史采样值得出最新的期望值。不仅 TLAB 用到了这种期望计算,GC 和 JIT 等等 JVM 机制中都用到了。这里我们来看一种 TLAB 中经常用到的 EMA(Exponential Moving Average 指数平均数) 算法:

EMA 算法的核心在于设置合适的最小权重,我们假设一个场景:首先采样100个 100(算法中的前 100 个是为了排除不稳定的干扰,我们这里直接忽略前 100 个采样),之后采样 50 个 2,最后采样 50 个 200,对于不同的最小权重,来看一下变化曲线。

可以看出,最小权重越大,变化得越快,受历史数据影响越小。根据应用设置合适的最小权重,可以让你的期望更加理想。
这块对应的源代码:gcUtil.hpp 的 AdaptiveWeightedAverage 类。
7. TLAB 相关的 JVM 参数
这里仅仅是列出来,并附上简介,看不懂没关系,之后会有详细分析,帮助你理解每一个参数。等你理解后,这个小章节就是你的工具书啦~~ 以下参数以及默认值基于 OpenJDK 17

7.1. TLABStats(已过期)
从 Java 12 开始已过期,目前已经没有相关的逻辑了。之前是用于 TLAB 统计数据从而更好地伸缩 TLAB 但是性能消耗相对较大,但是现在主要通过 EMA 计算了。
7.2. UseTLAB
说明:是否启用 TLAB,默认是启用的。
默认:true
举例:如果想关闭:-XX:-UseTLAB
7.3. ZeroTLAB
说明:是否将新创建的 TLAB 内的所有字节归零。我们创建一个类的时候,类的 field 是有默认值的,例如 boolean 是 false,int 是 0 等等,实现的方式就是对分配好的内存空间赋 0。设置 ZeroTLAB 为 true 代表在 TLAB 申请好的时候就赋 0,否则会在分配对象并初始化的时候赋 0.讲道理,由于 TLAB 分配的时候会涉及到 Allocation Prefetch 优化 CPU 缓存,在 TLAB 分配好之后立刻更新赋 0 对于 CPU 缓存应该是更友好的,并且,如果 TLAB 没有用满,填充的 dummy object 其实依然是 0 数组,相当于大部分不用改。这么看来,开启应该更好。但是ZeroTLAB 默认还是不开启的。
默认:false
举例-XX:+ZeroTLAB
7.4. ResizeTLAB
说明:TLAB 是否是可变的,默认为是,也就是会根据线程历史分配数据相关 EMA 计算出每次期望 TLAB 大小并以这个大小为准申请 TLAB。
默认:true
举例:如果想关闭:-XX:-ResizeTLAB
7.5. TLABSize
说明:初始 TLAB 大小。单位是字节
默认:0, 0 就是不主动设置 TLAB 初始大小,而是通过 JVM 自己计算每一个线程的初始大小
举例-XX:TLABSize=65536
7.6. MinTLABSize
说明:最小 TLAB 大小。单位是字节
默认:2048
举例-XX:TLABSize=4096
7.7. TLABAllocationWeight
说明: TLAB 初始大小计算和线程数量有关,但是线程是动态创建销毁的。所以需要基于历史线程个数推测接下来的线程个数来计算 TLAB 大小。一般 JVM 内像这种预测函数都采用了 EMA 。这个参数就是 图06 中的最小权重,权重越高,最近的数据占比影响越大。TLAB 重新计算大小是根据分配比例,分配比例也是采用了 EMA 算法,最小权重也是 TLABAllocationWeight
默认:35
举例-XX:TLABAllocationWeight=70
7.8. TLABWasteTargetPercent
说明:TLAB 的大小计算涉及到了 Eden 区的大小以及可以浪费的比率。TLAB 浪费指的是上面提到的重新申请新的 TLAB 的时候老的 TLAB 没有分配的空间。这个参数其实就是 TLAB 浪费占用 Eden 的百分比,这个参数的作用会在接下来的原理说明内详细说明
默认:1
举例-XX:TLABWasteTargetPercent=10
7.9. TLABRefillWasteFraction
说明: 初始最大浪费空间限制计算参数,初始最大浪费空间限制 = 当前期望 TLAB 大小 / TLABRefillWasteFraction
默认:64
举例-XX:TLABRefillWasteFraction=32
7.10. TLABWasteIncrement
说明: 最大浪费空间限制并不是不变的,在发生 TLAB 缓慢分配的时候(也就是当前 TLAB 空间不足以分配的时候),会增加最大浪费空间限制。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

倾听铃的声

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值