吴斌华(白泽)
从Java对象空间分配策略说起
JVM在为对象分配内存空间的时候,考虑性能问题,并不会直接在堆中开辟内存空间,而是会尝试 栈上分配 和 TLAB分配
栈上分配
- 通过逃逸分析判断变量的引用范围,尝试将未逃逸的变量分配到栈空间(聚合量会先进行 标量替换 )
- 分配在栈空间的对象,会在离开方法栈后,随之销毁,不需要等待GC,降低GC压力
TLAB分配
- 多线程同时申请内存空间时,会出现内存争用的现象,此时JVM会使用CAS加锁,大大降低内存分配效率,影响多线程性能,并行的线程越多,性能影响越大
- TLAB (Thread Local Allocation Buffer,线程本地分配缓冲区)即为每个线程在eden区开辟独立专用的内存区域,线程申请内存时会优先在自己对应的TLAB区申请,避免内存争用,提高多线程性能
- 当TLAB区域将被填满(TLAB内分配失败,剩余空间小于申请空间),且剩余空间小于 最大浪费空间,会触发refill开辟新的TLAB区域,退回原有TLAB区域,refill过程是同步的
- 当TLAB区域将被填满(TLAB内分配失败,剩余空间小于申请空间),且剩余空间大于 最大浪费空间,会触发TLAB外分配,TLAB外分配策略根据垃圾收集器的不同也有差异
分配流程
JVM默认开启栈上分配和TLAB分配,分配流程如下:
TLAB外分配策略根据垃圾收集器的不同也有差异,图中的流程并不覆盖所有情况
TLAB和应用性能的关系
- 上文提到,TLAB可以避免内存开辟阻塞的问题,可以大大地提高多线程应用的性能
- 换言之,TLAB外的内存分配会降低性能
- 应用TLAB外的内存分配占比越大,应用的并行性能也就越低,TLAB外分配过多往往意味着应用中存在大对象
- 频繁地TLAB refill同样会影响性能,正常情况下,在长时间运行后,基于EMA算法,TLAB refill和GC会达到一定的平衡,重分配次数下降。重分配过频,说明线程创建的对象存在不规则伸缩的情况
感知应用的TLAB行为
感知应用TLAB分配行为的手段比较有限,值得庆幸的是JFR默认提供了TLAB事件的记录
JFR(Java Flight Recorder)简述
什么是JFR
- JFR(Java Flight Recordings),从名字上就可以看出,这是一套类似于飞机黑匣子的解决方案
- 定义上是一个用于收集关于正在运行的Java应用程序的诊断和分析数据的工具。它集成到Java虚拟机(JVM)中,几乎不会导致任何性能开销,因此即使在负载沉重的生产环境中也可以使用它。当使用默认设置时,内部测试和客户反馈都表明性能影响小于1%。对于某些应用程序,它可能要低得多。--摘自官方文档
- 本质上是一套记录和收集指定时间段内应用,JVM以及OS行为事件的解决方案,配合JFR分析工具(比如JMC)可以用于问题排查以及性能分析
- JFR本来是一项商业特性,需要商业许可,前几年已经开放授权。从JDK11开始已经内置了JFR,JDK14更是增加了JFR事件流进一步增加了易用性。并且慢慢在低版本的OpenJDK也增加了这一特性
- 海拍客生成环境使用的JDK是Tencent Kona 8,Kona 8在OpenJDK 8 的基础上增加了JFR特性
与内存dump的区别
- 内存dump记录的是单一时刻的内存快照,而JFR记录的是某一时间区间
- 内存dump关注内存相关的问题,而JFR全面地分析包括OS,内存,CPU,线程在内的几乎所有信息
- 内存dump对应用影响较大,基本需要关闭流量路口再进行操作,JFR记录资源占用在1%-2%左右,可以直接在提供服务的节点上开启记录
- 内存dump记录了整个内存的(存活)对象信息,文件往往较大,而短时间段内的JFR文件往往不超过100M
- 由于内存dump记录的是当前的对象信息,当内存分析一些占比较小的内存问题时,排查会比较困难,有时候需要对比两个dump文件来发现那些持续增长的对象,而JFR可以记录指定时间内内存的分配量,是一个增量的信息统计,可以大大地降低内存问题排查的难度
如何收集JFR
收集JFR一般有三种方式:
- 使用JCMD命令
- 利用JMC工具远程收集
- 使用arthas dump收集
JFR中的TLAB事件
jdk.ObjectAllocationOutsideTLAB事件:即TLAB外分配事件,在线程申请内存空间发生TLAB外分配时触发
属性 | 说明 |
---|---|
startTime | 事件开始时间 |
objectClass | 触发本次事件的对象的类 |
allocationSize | 分配对象大小 |
eventThread | 事件发生所在线程 |
stackTrace | 事件发生所在堆栈 |
jdk.ObjectAllocationInNewTLAB事件:即TLAB重分配事件,在线程申请内存空间TLAB refill时触发
属性 | 说明 |
---|---|
startTime | 事件开始时间 |
objectClass | 触发本次事件的对象的类 |
allocationSize | 分配对象大小 |
tlabSize | 当前线程的 TLAB 大小 |
eventThread | 事件发生所在线程 |
stackTrace | 事件发生所在堆栈 |
通过JFR TLAB事件分析应用性能
以detail中台为例
-
detail中台是一个并行性能要求比较高的应用,也就是说TLAB对detail的影响也比较大,下面以detail应用为例分析内存分配性能
-
采集TLAB以及线程事件,时间区间为两分钟,导出detail的jfr文件
-
自动分析结果中提到了一些优化点,简单说明下:
- 空闲物理内存过少:提示可用内存太少可能出现内存swap,影响性能
- DebugNonSafepoints:JVM没有开启DebugNonSafepoints选项,分析数据可能会不太精确
- 命令行选项检查:JVM参数中配置了一些非正式的和废弃的选项,会影响JVM的稳定性
- 堆栈深度设置:JFR采集过程中设置了最大堆栈深度,主要是为了避免JFR对应用的影响,这里提示了这个堆栈深度的设置会影响深度最终分析
- 结果中出现的一些占位符考虑是jmc bug或者是配置的影响,目前没有深究
-
总的来说yt-detail的性能表现还可以的,没有提示明显严重的问题
-
重点关注JVM内部-TLAB分配的部分
- 可以看到在JFR记录期间有9个线程出现了TLAB外内存分配
- 出现TLAB外分配的线程较少,说明整体内存分配的性能表现尚可,但三个业务线程池线程TLAB外分配占比较高需要重点关注下
- 切换到方法tab,发现TLAB外分配集中在char[] java.util.Arrays.copyOfRange(char[], int, int)方法上,说明有集中的性能问题
-
切换到事件浏览器页面
- 搜索TLAB事件,TLAB重分配681次,TLAB外分配21次
- 重点关注三个业务相关的线程的外分配也就是ThreadPool-*
- 该线程池主要负责模块构造
- 选中线程查看堆栈可以发现,几次TLAB外部内存分配都是由.BeanMap.of(Object)触发,这一块可能性能不太好
- 翻一下代码,的确是一个值得优化的点
拓展思考
- 为什么应用在执行一段时间后性能会提高?
- java应用在执行一段时间后变快的原因是方方面面的,契合本文的主题,TLAB也影响因素之一
- 上文提到TLAB大小由EMA算法来决定,EMA需要进行一定次数的采样后才能趋于稳定,所以,应用刚启动时的TLAB大小往往变化频繁,运行一段时间后趋于稳定
- 面向栈上分配和TLAB,如何提高我们的代码质量
- 尽量避免大对象的使用,大对象无法利用栈上分配和TLAB的JVM优化,会带来显著的性能下降,当然对GC也十分不友好
- 尽量避免变量逃逸,让变量处于合适的作用域中,这将有利于栈上分配以及锁消除