今天,又是干货满满的一天。这是全网最硬核 JVM 系列的开篇,首先从 TLAB 开始。由于文章很长,每个人阅读习惯不同,所以特此拆成单篇版和多篇版
- 全网最硬核 JVM TLAB 分析(单篇版不包含额外加菜)
- 全网最硬核 JVM TLAB 分析 1. 内存分配思想引入
- 全网最硬核 JVM TLAB 分析 2. TLAB生命周期与带来的问题思考
- 全网最硬核 JVM TLAB 分析 3. JVM EMA期望算法与TLAB相关JVM启动参数
- 全网最硬核 JVM TLAB 分析 4. TLAB 基本流程全分析
- 全网最硬核 JVM TLAB 分析 5. TLAB 源代码全解析
- 全网最硬核 JVM TLAB 分析 6. TLAB 相关热门Q&A汇总
- 全网最硬核 JVM TLAB 分析(额外加菜) 7. TLAB 相关 JVM 日志解析
- 全网最硬核 JVM TLAB 分析(额外加菜) 8. 通过 JFR 监控 TLAB
11.1. 准备 Java WhiteBox API
首先需要准备好Java WhiteBox API
11.1.1. 什么是 WhiteBox API
WhiteBox API 是 HotSpot VM 自带的白盒测试工具,将内部的很多核心机制的 API 暴露出来,用于白盒测试 JVM,压测 JVM 特性,以及辅助学习理解 JVM 并调优参数。WhiteBox API 是 Java 7 引入的,目前 Java 8 LTS 以及 Java 11 LTS(其实是 Java 9+ 以后的所有版本,这里只关心 LTS 版本,Java 9 引入了模块化所以 WhiteBox API 有所变化)都是有的。但是默认这个 API 并没有编译在 JDK 之中,但是他的实现是编译在了 JDK 里面了。所以如果想用这个 API,需要用户自己编译需要的 API,并加入 Java 的 BootClassPath 并启用 WhiteBox API。
11.1.2. WhiteBox API 如何实现的
WhiteBox API 是一个 Java 类,位于 JDK 的测试包中,默认没有编译进标准发行版的 JDK 中。
test/lib/sun/hotspot/WhiteBox.java
1 | java复制代码package sun.hotspot; |
可以看出,其实里面的所有 API 都是 JNI 调用,具体实现是:
src/hotspot/share/prims/whitebox.cpp
1 | scss复制代码WB_ENTRY(void, WB_FullGC(JNIEnv* env, jobject o)) |
可以看出,JNI 调用实现直接调用了底层 JVM 的相关接口,相当于把 JVM 的一些关键机制暴露出来,用于白盒测试。但是如之前所说,JDK 发行版没有包括 test 下的测试代码,也就是 WhiteBox API 所在的 jar 包并没有打进默认的 JDK 中。这就需要我们自己编译一下这个代码。
11.1.3. 什么是 BootClassPath
Java 内有三种不同的类加载器:应用类加载器(application classloader),扩展类加载器(extension classloader)还有根类加载器(bootstrap classloader)
- 应用类加载器,加载我们classpath目录下的所有类文件
- 扩展类加载器,加载标准 Java 类库扩展的类,就是你的jre目录下的/lib/ext目录下的所有类
- 根类加载器(bootstrap classloader),扫描 BootClassPath 下的 标准 Java 类库的类加载器。标准 Java 类库限制了一些包路径的类,必须通过根类加载器加载。
对于 WhiteBox API,由于是他的包为sun.hotspot
,普通的类加载器是不能加载这个包路径的类的,需要通过根类加载器加载。
11.1.4. 怎么指定 BootClassPath
在 Java 8,通过 -Xbootclasspath:
或者 -Xbootclasspath/p:
指定,例如:
1 | ruby复制代码-Xbootclasspath:/home/project/whitebox.jar |
在 Java 9 之后的版本,这两个参数已经过期了,需要改成-Xbootclasspath/a:
,例如:
1 | bash复制代码-Xbootclasspath/a:/home/project/whitebox.jar |
否则会报错-Xbootclasspath is no longer a supported option.
这里对应的 JDK 源码是: src/hotspot/share/runtime/arguments.cpp
1 | php复制代码// -bootclasspath: |
11.1.5. 使用 WhiteBox API
1. 编译 WhiteBox API
将https://github.com/openjdk/jdk/tree/master/test/lib
路径下的sun
目录取出,编译成一个 jar 包,名字假设是 whitebox.jar
2. 编写测试程序
将 whitebox.jar
添加到你的项目依赖,之后写代码
1 | scss复制代码public static void main(String[] args) throws Exception { |
3. 启动程序查看效果
使用启动参数 -Xbootclasspath/a:/home/project/whitebox.jar -XX:+UnlockDiagnosticVMOptions -XX:+WhiteBoxAPI -Xlog:gc
启动程序。其中前三个 Flag 表示启用 WhiteBox API,最后一个表示打印 GC info 级别的日志到控制台。
我的输出:
1 | scss复制代码[0.025s][info][gc] Using G1 |
至此,我们就准备好了 WhiteBox 调试环境
11.2. 测试 TLAB 查看日志
编写测试代码:
1 | arduino复制代码//对于字节数组对象头占用16字节 |
之后,我们以如下的启动参数(前三个启动参数是我们前面章节提到的启用 WhiteBox API 需要的参数)启动这个程序,查看日志(关于日志配置,请参考之前的章节)。
1 | ruby复制代码-Xbootclasspath/a:./jdk-white-box-17.0-SNAPSHOT.jar |
可以看到下面类似的日志,我们来根据代码分析下,首先是运行到第一个 fullGC 结束之前的所有日志,首先是 JVM 启动的时候会输出用的是什么 GC 的日志,这里是默认的 G1:
1 | css复制代码[0.022s][info][gc] Using G1 |
还会输出 TLAB 的通用配置:
1 | css复制代码[0.030s][trace][gc,tlab] TLAB min: 328 initial: 60293 max: 65536 |
也就是这里 TLAB 最小为 328 MarkWordSize,初始为 60293 MarkWordSize,最大为 65536 MarkWordSize。默认的 64位 JVM 的 MarkWordSize 为 8 字节,也就是堆内存 8 字节对齐。
然后,由于 JVM 启动时,默认会初始化很多线程,包括:
- main 线程:执行 main 方法的线程
- Attach listener 线程:Attach Listener 线程是负责接收到外部的命令,而对该命令进行执行的并且把结果返回给发送者。通常我们会用一些命令去要求jvm给我们一些反馈信息,如:java -version、jmap、jstack等等。 如果该线程在jvm启动的时候没有初始化,那么,则会在用户第一次执行jvm命令时,得到启动。
- Signal Dispatcher线程:Attach Listener线程的职责是接收外部jvm命令,当命令接收成功后,会交给signal dispather 线程去进行分发到各个不同的模块处理命令,并且返回处理结果。 signal dispather线程也是在第一次接收外部jvm命令时,进行初始化工作。
- Reference Handler 线程:JVM在创建main线程后就创建Reference Handler线程,它主要用于处理引用对象本身(软引用、弱引用、虚引用)的垃圾回收问题 。
- Finalizer线程:这个线程也是在main线程之后创建的,主要用于在垃圾收集前,调用对象的finalize()方法。
- DestroyJavaVM线程:执行main()的线程在main执行完后调用JNI中的 jni_DestroyJavaVM() 方法唤起DestroyJavaVM 线程,它将在虚拟机中所有其它非守护线程全部结束后销毁虚拟机。
在运行过程中,根据你的JIT编译配置,GC参数,还会有:
- CompilerThread 线程:JIT编译相关线程,主要是负责 C1 C2 即时编译以及 OSR(On stack Replacement) 替换等任务
- GC 相关线程:执行GC任务的线程
除了这些之外,Java 8 之后 ForkJoinPool 还会创建一个默认大小为 cpu 核数 -1 的线程池:CommonForkJoinPool,是用来处理 ParallelStream 的默认线程池还有 Future 框架 CompletableFuture 的默认线程池。
这些线程中的一部分会在 JVM 初始化的时候创建一些对象使用,那么就肯定会涉及到 TLAB,所以会有如下日志:
1 | less复制代码[0.042s][trace][gc,tlab] ThreadLocalAllocBuffer::compute_size(2) returns 65536 |
其中,[0.042s][trace][gc,tlab] ThreadLocalAllocBuffer::compute_size(2) returns 65536
的对应的就是调用了compute_size
计算初始 TLAB 大小,传入的 2 就是当前这个线程分配的对象所需的大小(MarkWordSize),计算出初始大小为 65536,因为 MarkWordSize = 8 所以 就是 65536*8=524288 字节,也就是 512 KB。下一行日志,代表这个线程初始化申请一块内存作为 TLAB 了,[0.042s][trace][gc,tlab] TLAB: fill thread: 0x000002a66a471710 [id: 12916] desired_size: 512KB slow allocs: 0 refill waste: 8192B alloc: 1.00000 1024KB refills: 1 waste 0.0% gc: 0B slow: 0B fast: 0B
,这个 TLAB 的信息包括:
- 线程号
0x000002a66a471710 [id: 12916]
- 期望大小,就是刚刚计算出来的 512KB:
desired_size: 512KB
- 慢分配次数,就是不在当前 TLAB 直接分配的分配次数:
slow allocs: 0
- 当前浪费空间限制,也就是重新申请 TLAB 造成的浪费限制大小,refill waste: 8192B,也就是最多能浪费 8192 字节
- 当前 _allocation_fraction 相关信息,
alloc: 1.00000 1024KB
,代表当前 _allocation_fraction 是 1.00000,TLAB 一共用了 1024 KB - 发生 refills 重新申请 TLAB 的次数:
refills: 1
- 浪费比例:
waste 0.0%
- GC 回收造成的浪费大小:
gc: 0B
- 慢
refill
造成的浪费:slow: 0B
- 快
refill
造成的浪费:fast: 0B
我们这里来计算下为何当前浪费空间为 8192 字节,也就是 8KB。TLABRefillWasteFraction 我们并没有修改,也就是默认的 64,那么初始的最大浪费空间 = TLAB 大小 / TLABRefillWasteFraction,也就是 512KB / 64 = 8KB
第一次强制 FullGC 之后,看到如下相关日志:
1 | less复制代码//首先输出了每一个线程的当前 TLAB 的信息 |
首先是输出了每一个线程的当前 TLAB 的信息。与前面发生 refill 分配 TLAB 时相似。只不过多了 GC 全局序号,从 0 开始, GC(0)
代表的就是第一次 GC 相关的日志 然后是 GC TLAB 统计:[0.915s][debug][gc,tlab] GC(0) TLAB totals: thrds: 7 refills: 21 max: 15 slow allocs: 0 max 0 waste: 38.0% gc: 2974616B max: 524008B slow: 13880B max: 13880B fast: 0B max: 0B
:
- 一共有7个线程用了 TLAB:
thrds: 7
,也就是前面带GC(0)
的 TLAB 信息日志中,只有 7 个线程的 refills 是大于 0 的。 - 本次 GC 所有线程 refills 的次数
refills: 21
- 历史最大的某次 GC 内 refills 的次数
max: 15
- 本次 GC 所有线程慢分配的次数
slow allocs: 0
- 历史最大的某次 GC 内慢分配的次数
max: 0
- 本次 GC 所有线程 TLAB 内存浪费比例
waste: 38.0%
- 各种浪费内存大小:`gc: 2974616B max: 524008B slow: 13880B max: 13880B fast: 0B max: 0B``
接着打印了每个线程 TLAB 期望大小的变化:[0.979s][trace][gc,tlab] GC(0) TLAB new size: thread: 0x000002a66a471710 [id: 12916] refills 50 alloc: 1.000000 desired_size: 65536 -> 65536
,这里还是 MarkWordSize 而不是实际字节大小。 最后是本次 GC 信息:[0.980s][info ][gc ] GC(0) Pause Full (WhiteBox Initiated Full GC) 7M->0M(512M) 65.162ms
,代表是 FullGC,并且是 WhiteBox 触发的,堆内存使用从 7M 回收到了 0M,堆内存总大小是 512M,一共停顿时间是 65.162 ms。
之后我们的程序申请了 512 个大小为 1KB 的对象。为何new byte[OBJECT_SIZE - BYTE_ARRAY_OVERHEAD]
大小是 1KB 呢?因为数组对象头默认是 16 字节,所以再加上 1012 个 byte 就是 1KB。循环结束后,输出了下面两行日志:
1 | ini复制代码[0.989s][trace][gc,tlab] ThreadLocalAllocBuffer::compute_size(128) returns 65536 |
可以看出是发生了两次 refill,第一次是线程第一次创建对象时申请的,第二次是申请到第 512 个对象,TLAB 大小是 512 KB,之前的 511KB 已经被占用了,根据前一篇的 TLAB 原理分析,我们知道由于需要填充 dummy objects 所以要保留一个数组对象头的大小,所以剩下可分配的空间其实不足 1KB,所以需要 refill。并且,浪费的空间(1KB)小于当前浪费空间限制(8KB),所以可以重新申请新的 TLAB 进行分配。
然后我们的程序在 FullGC 之后,继续申请了 200 个大小为 100KB 的大对象。这里我们忽略 GC 相关日志,只看分配对象的时候产生的日志。
1 | yaml复制代码[3036.734s][trace][gc,tlab] ThreadLocalAllocBuffer::compute_size(12800) returns 65536 |
100KB 的对象,换算成 MarkWordSize 就是 12800,对应日志:[3036.734s][trace][gc,tlab] ThreadLocalAllocBuffer::compute_size(12800) returns 65536
,本次计算 TLAB 大小依然是 65536 MarkWordSize,也就是 512KB。在分配第五个对象开始, TLAB 的剩余内存就不够了。但是初始最大浪费空间是 8KB,所以只能直接在 Eden 区分配,并根据 TLABWasteIncrement
(默认为 4) 设置的值递增最大浪费空间,也就是每次递增 4 * MarkWordSize 也就是 32 字节。体现在了日志:
1 | yaml复制代码[3047.276s][trace][gc,tlab] TLAB: slow thread: 0x000002a66a471710 [id: 12916] obj: 12800 free: 1464 waste: 1028 |
可以看出,每次 TLAB 外分配都让最大浪费空间限制加 4。当剩余空间小于最大浪费空间限制时,线程 refill 申请了一块新的 TLAB 进行分配:
1 | yaml复制代码[3047.279s][trace][gc,tlab] TLAB: slow thread: 0x000002a66a471710 [id: 12916] obj: 12800 free: 1464 waste: 1456 |
至此,我们就分析了基本所有 TLAB 相关的日志。
本文转载自: 掘金