全网最硬核 JVM TLAB 分析(单篇版不包含额外加菜)(上)

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 全网最硬核 JVM TLAB 分析(单篇版不包含额外加菜)(上)
今天,又是干货满满的一天。这是全网最硬核 JVM 系列的开篇,首先从 TLAB 开始。由于文章很长,每个人阅读习惯不同,所以特此拆成单篇版和多篇版


1. 观前提醒


本期内容比较硬核,非常全面,涉及到了设计思想到实现原理以及源码,并且还给出了相应的日志以及监控方式,如果有不清楚或者有疑问的地方,欢迎留言。

其中涉及到的设计思想主要为个人理解,实现原理以及源码解析也是个人整理,如果有不准确的地方,非常欢迎指正!提前感谢~~


2. 分配内存实现思路


我们经常会 new 一个对象,这个对象是需要占用空间的,第一次 new 一个对象占用的空间如 图00 所示,


image.png


我们这里先只关心堆内部的存储,元空间中的存储,我们会在另一个系列详细讨论。堆内部的存储包括对象头,对象体以及内存对齐填充,那么这块空间是如何分配的呢?

首先,对象所需的内存,在对象的类被解析加载进入元空间之后,就可以在分配内存创建前计算出来。假设现在我们自己来设计堆内存分配,一种最简单的实现方式就是线性分配,也被称为撞针分配(bump-the-pointer)。


image.png


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


image.png


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

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


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


微信图片_20220625115219.jpg


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

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

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


4. TLAB 的生命周期


微信图片_20220625115239.jpg


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 没有用完,没有分配的内存也会成为孔隙。


image.png


如果不管这些孔隙,由于 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 指数平均数) 算法:


微信图片_20220625115319.jpg


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


微信图片_20220625115341.jpg


可以看出,最小权重越大,变化得越快,受历史数据影响越小。根据应用设置合适的最小权重,可以让你的期望更加理想。

这块对应的源代码:gcUtil.hppAdaptiveWeightedAverage 类。


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 空间不足以分配的时候),会增加最大浪费空间限制。这个参数就是 TLAB 缓慢分配时允许的 TLAB 浪费增量。单位不是字节,而是 MarkWord 个数,也就是 Java 堆的内存最小单元,64 位虚拟机的情况下,MarkWord 大小为 3 字节。

默认:4

举例-XX:TLABWasteIncrement=4


8.TLAB 基本流程


8.0. 如何设计每个线程的 TLAB 大小

之前我们提到了引入 TLAB 要面临的问题以及解决方式,根据这些我们可以这么设计 TLAB。

首先,TLAB 的初始大小,应该和每个 GC 内需要对象分配的线程个数相关。但是,要分配的线程个数并不一定是稳定的,可能这个时间段线程数多,下个阶段线程数就不那么多了,所以,需要用 EMA 的算法采集每个 GC 内需要对象分配的线程个数来计算这个个数期望

接着,我们最理想的情况下,是每个 GC 内,所有用来分配对象的内存都处于对应线程的 TLAB 中。每个 GC 内用来分配对象的内存从 JVM 设计上来讲,其实就是 Eden 区大小。在 最理想的情况下,最好只有Eden 区满了的时候才会 GC,不会有其他原因导致的 GC,这样是最高效的情况。Eden 区被用光,如果全都是 TLAB 内分配,也就是 Eden 区被所有线程的 TLAB 占满了,这样分配是最快的。

然后,每轮 GC 分配内存的线程个数以及大小是不一定的,如果一下子分配一大块会造成浪费,如果太小则会频繁从 Eden 申请 TLAB,降低效率。这个大小比较难以控制,但是我们可以限制每个线程究竟在一轮 GC 内,最多从 Eden 申请多少次 TLAB,这样对于用户来说更好控制。

最后,每个线程分配的内存大小,在每轮 GC 并不一定稳定,只用初始大小来指导之后的 TLAB 大小,显然不够。我们换个思路,每个线程分配的内存和历史有一定关系因此我们可以从历史分配中推测,所以每个线程也需要采用 EMA 的算法采集这个线程每次 GC 分配的内存,用于指导下次期望的 TLAB 的大小。

综上所述,我们可以得出这样一个近似的 TLAB 计算公式

每个线程 TLAB 初始大小 = Eden区大小 / (线程单个 GC 轮次内最多从 Eden 申请多少次 TLAB * 当前 GC 分配线程个数 EMA)

GC 后,重新计算 TLAB 大小 = Eden区大小 / (线程单个 GC 轮次内最多从 Eden 申请多少次 TLAB * 当前 GC 分配线程个数 EMA)

接下来,我们来详细分析 TLAB 的整个生命周期的每个流程。



相关文章
|
2月前
|
监控 算法 Java
jvm-48-java 变更导致压测应用性能下降,如何分析定位原因?
【11月更文挑战第17天】当JVM相关变更导致压测应用性能下降时,可通过检查变更内容(如JVM参数、Java版本、代码变更)、收集性能监控数据(使用JVM监控工具、应用性能监控工具、系统资源监控)、分析垃圾回收情况(GC日志分析、内存泄漏检查)、分析线程和锁(线程状态分析、锁竞争分析)及分析代码执行路径(使用代码性能分析工具、代码审查)等步骤来定位和解决问题。
|
7月前
|
缓存 Java
《JVM由浅入深学习九】 2024-01-15》JVM由简入深学习提升分(生产项目内存飙升分析)
《JVM由浅入深学习九】 2024-01-15》JVM由简入深学习提升分(生产项目内存飙升分析)
59 0
|
3月前
|
小程序 Oracle Java
JVM知识体系学习一:JVM了解基础、java编译后class文件的类结构详解,class分析工具 javap 和 jclasslib 的使用
这篇文章是关于JVM基础知识的介绍,包括JVM的跨平台和跨语言特性、Class文件格式的详细解析,以及如何使用javap和jclasslib工具来分析Class文件。
63 0
JVM知识体系学习一:JVM了解基础、java编译后class文件的类结构详解,class分析工具 javap 和 jclasslib 的使用
|
3月前
|
存储 Java PHP
【JVM】垃圾回收机制(GC)之引用计数和可达性分析
【JVM】垃圾回收机制(GC)之引用计数和可达性分析
90 0
|
6月前
|
Java
jmap 查看jvm内存大小并进行dump文件内存分析
jmap 查看jvm内存大小并进行dump文件内存分析
144 3
|
6月前
|
运维 监控 Java
(十)JVM成神路之线上故障排查、性能监控工具分析及各线上问题排错实战
经过前述九章的JVM知识学习后,咱们对于JVM的整体知识体系已经有了全面的认知。但前面的章节中,更多的是停留在理论上进行阐述,而本章节中则更多的会分析JVM的实战操作。
147 1
|
6月前
|
Arthas 监控 Java
JVM内存问题之使用gperftools分析JNI Memory泄漏的具体步骤是什么
JVM内存问题之使用gperftools分析JNI Memory泄漏的具体步骤是什么
147 2
|
5月前
|
监控 JavaScript Java
JVM源码级别分析G1发生FullGC元凶的是什么
线上系统遭遇频繁Old GC问题,监控显示出现多次“to-space exhausted”日志,这表明垃圾回收过程中因年轻代 Survivor 区或老年代空间不足导致对象晋升失败。通过 JVM 源码分析,此问题源于对象转移至老年代失败时,JVM 无法找到足够的空间存放存活对象。进一步排查发现大对象分配占用了预留空间,加剧了空间不足的情况。使用 JFR 分析工具定位到定期报表序列化导致大量大对象生成,通过改用堆外内存进行序列化输出,最终解决了频繁 Old GC 问题。
159 0
|
6月前
|
监控 Java 开发者
Java面试题:如何使用JVM工具(如jconsole, jstack, jmap)来分析内存使用情况?
Java面试题:如何使用JVM工具(如jconsole, jstack, jmap)来分析内存使用情况?
238 2
|
6月前
|
人工智能 Java
JVM内存问题之当老年代缓慢增加且Full GC无法清除时,应如何使用MAT进行分析
JVM内存问题之当老年代缓慢增加且Full GC无法清除时,应如何使用MAT进行分析
298 0