⑤JVM JVM调优
⑤JVM JVM调优
学习核心
- JVM调优
GC调优
- GC调优思路(结合G1分析)
- G1机制分析(版本升级分析)
- 优化思路参考
内存分配调优
学习资料
GC调优
✨GC优化核心方向
JVM 内存调优通常和 GC 调优是互补的,协调合作完成JVM优化
(1)GC指标分析:首先要确认GC效率的核心指标(STW、吞吐量、内存占比/垃圾回收频率)
(2)GC信息收集:配置GC日志参数,借助GC日志工具(查看:GCViewer、分析:GCeasy)分析GC现存问题
(3)确认GC优化方案
- GC调优相关(针对性的话有些点需要结合G1机制去细化理解)
- 选择适合的GC策略(GC收集器的选择)
- JDK版本升级(结合不同版本的G1机制分析)
- 内存分配调优相关
- 降低 Minor GC 频率(增大年轻代的分配)
- 降低 Full GC 频率(减少创建大对象、增大堆空间)
- 调整 Eden、Survivor 区比例
- GC调优相关(针对性的话有些点需要结合G1机制去细化理解)
(4)实施并验证、反复复盘确认
1.GC调优概念
在 Java 开发中,一般情况下开发人员是无需过度关注对象的回收与释放的,JVM 的垃圾回收机制可以减轻不少工作量。但完全交由 JVM 回收对象,也会增加回收性能的不确定性。在一些特殊的业务场景下,不合适的垃圾回收算法以及策略,都有可能导致系统性能下降。
面对不同的业务场景,垃圾回收的调优策略也不一样。例如,在对内存要求苛刻的情况下,需要提高对象的回收效率;在 CPU 使用率高的情况下,需要降低高并发时垃圾回收的频率。垃圾回收的调优是性能调优的一项重要应用
GC性能衡量指标(评价垃圾收集器的性能好坏)
**吞吐量:**此处指应用程序所花费的时间和系统总运行时间的比值
- 系统总运行时间 = 应用程序耗时 +GC 耗时。如果系统运行了 100 分钟,GC 耗时 1 分钟,则系统吞吐量为 99%。GC 的吞吐量一般不能低于 95%。
**停顿时间(STW时间):**指垃圾收集器正在运行时,应用程序的暂停时间
- 对于串行回收器而言,停顿时间可能会比较长;
- 并发回收器,由于垃圾收集器和应用程序交替运行,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低
**垃圾回收频率:**多久发生一次垃圾回收呢?(其和内存占用相关,也可理解为内存占用的设定是衡量GC性能的指标)
- 通常垃圾回收的频率越低越好,增大堆内存空间可以有效降低垃圾回收发生的频率,但同时也意味着堆积的回收对象越多,最终也会增加回收时的停顿时间
- 适当地增大堆内存空间,保证正常的垃圾回收频率即可
GC调优核心思路:结合JVM和GC概念分析
于GC调优而言,实现要清楚调优的目标是什么:
- 性能角度:通常关注三个方面,内存占用(footprint)、延时(latency)和吞吐量(throughput),结合实际情况进行侧重或者兼顾
- 场景角度:考虑其他GC相关的场景,例如OOM可能与不合理的GC相关参数有关、如有应用启动速度方面的需求则GC也会是个考虑的方面
基本的调优思路可以总结为:梳理需求、确定目标=》分析状态、定位问题(分析GC选型、参数配置)=》确认调整方案=》验证&复盘
- 理解应用需求和问题,确定调优目标
- 问题场景:假设开发了一个应用服务,但发现偶尔会出现性能抖动,出现较长的服务停顿
- 需求分析:评估用户可接受的响应时间和业务量,将目标简化为,希望GC暂停尽量控制在200ms以内,并且保证一定标准的吞吐量
- 掌握JVM和GC的状态,定位具体的问题,确定GC调优的必要性
- 跟踪方法:通过jstat等工具查看GC等相关状态,可以开启GC日志,或者是利用操作系统提供的诊断工具等
- 实现目标:通过追踪GC日志,就可以查找是不是GC在特定时间发生了长时间的暂停,进而导致了应用响应不及时
- GC收集器选型:思考GC类型是否符合应用特征,确认垃圾收集器的选择适配性
- 如果选型合适,则分析具体问题表现在哪里,是Minor GC过长,还是Mixed GC等出现异常停顿情况;
- 如果选型不合适,考虑切换到什么类型,如CMS和G1都是更侧重于低延迟的GC选项
- 通过分析确定具体调整的参数或者软硬件配置
- 验证&反复复盘:
- 验证是否达到调优目标,如果达到目标,即可以考虑结束调优;否则,重复完成分析、调整、验证这个过程。
2.G1的GC内部结构和机制(原理分析)
G1的内部是类似棋盘结构,其将内部区域划分为同等大小的Region域(JVM会尽量划分为2048个左右、大小同等的Region),每个Region可以是年轻代(1个Eden空间、2个survivor空间)、老年代(Old)、Humongous
单个Region大小的设置问题分析
如果是大对象(超出单个Region存储大小阈值),除了直接分配在Old域,G1会将超过region 50%大小的对象(例如应用中的byte、char数组等)归类为Humongous对象,并将其放置在对应的Region中(从逻辑上理解,Humongous也算是老年代Old的一部分,因为于年轻代GC的复制算法而言,大对象的复制是一个非常昂贵的操作)
此处则可衍生思考一个问题:region大小的设计很难和大对象需求保持一致?
因为每个region的大小都是一样的,但是如果出现大对象超出单个region阈值(即单个的Region放不下一个大对象),又没有足够连续的空间分配给这个大对象,这是一个长期存在的情况(可以看作是JVM的bug,可以参考OpenJDK社区讨论)
其解决思路可以有两个方向:
尽量避免大量的Humongous对象分配
如果不可避免,则适当增大堆的大小或者尽量将Region的值设置得大一些:
-XX:G1HeapRegionSize=<N, 例如16>M
复合算法下对象的回收机制
G1采取的GC算法是复合算法(复制算法、标记-整理算法)
- 新生代:采用复制算法,会发生STW
- 老年代:大部分是并发标记,整理则是和新生代GC时捎带进行(不是整体性的整理,而是增量进行的)
传统习惯上会将新生代GC(Young GC)称为Minor GC、老年代GC(Old GC)成为Major GC,用于区别整体性的Full GC。但是在现代GC中,这种概念又有了进一步进化:
- 新生代GC:Minor GC仍然存在,会涉及到Remember Set(用于记录和维护region之间对象的引用关系)等相关处理
- 老年代GC:依靠的是Mixed GC(可以理解为不存粹是针对老年代的GC,是混合操作),并发标记结束后,JVM就有足够的信息进行垃圾收集,Mixed GC不仅同时会清理Eden、Survivor区域,而且还会清理部分Old区域
可以通过设置下面的参数,指定触发阈值,并且设定最多被包含在一次Mixed GC中的region比例
–XX:G1MixedGCLiveThresholdPercent
–XX:G1OldCSetRegionThresholdPercent
老年代中的对象回收(版本升级优化)
老年代对象回收,基本要等待并发标记结束。这意味着,如果并发标记结束不及时,导致堆已满,但老年代空间还没完成回收,就会触发Full GC,所以触发并发标记的时机很重要。早期的G1调优中,通常会设置下面参数,但是很难给出一个普适的数值,往往要根据实际运行结果调整
-XX:InitiatingHeapOccupancyPercent
在JDK 9之后的G1实现中,这种调整需求会少很多,因为JVM只会将该参数作为初始值,会在运行时进行采样,获取统计数据,然后据此动态调整并发标记启动时机。对应的JVM参数如下,默认已经开启:
-XX:+G1UseAdaptiveIHOP
在现有的资料中,大多指出G1的Full GC是最差劲的单线程串行GC。其实,如果采用的是最新的JDK,会发现Full GC也是并行进行,在通用场景中的表现还优于Parallel GC的Full GC实现
Humongous中的对象回收(版本升级优化)
针对Humongous中的对象回收,如果将其理解为Old的一部分,则一般情况下会认为其会在并发标记结束之后才进行回收。但是在新版的G1中,Humongous对象回收采取了更加激进的策略。
G1记录了老年代region间的对象引用,因为Humongous对象数量有限,所以可以快速确认是否有老年代对象引用它,如果不存在则还需确认新生代中是否有对象引用它,这个信息可以在Young GC的时候就可以确认,因此可以在Young GC的时候就对Humongous对象进行回收,而不需要像其他老年代对象那样等待并发标记结束后才执行GC
字符串排重特性(版本升级优化)
在8u20以后字符串排重的特性,在垃圾收集过程中,G1会把新创建的字符串对象放入队列中,然后在Young GC之后,并发地(不会STW)将内部数据(char数组,JDK 9以后是byte数组)一致的字符串进行排重,也就是将其引用同一个数组。可以使用下面参数激活:
-XX:+UseStringDeduplication
注意,这种排重虽然可以节省不少内存空间,但这种并发操作会占用一些CPU资源,也会导致Young GC稍微变慢
G1的类型卸载改进(版本升级优化)
很多资料中都谈到,G1只有在发生Full GC时才进行类型卸载,但这显然不是想要的预期。可以加上下面的参数查看类型卸载:
-XX:+TraceClassUnloading
8u40以后,G1增加并默认开启下面的选项,设定在并发标记阶段结束后,JVM即进行类型卸载
-XX:+ClassUnloadingWithConcurrentMark
3.优化建议
基于上述对G1内部机制的剖析和版本升级优化对比,可以整体上得出一些调优的思路:
- 选择合适的GC策略:根据场景选择合适的GC收集器
- JDK版本升级:尽量升级到较新的JDK版本,可以解决上述的大部分问题
- 掌握GC调优信息收集途径:掌握尽量全面、详细、准确的信息,是各种调优的基础,基于这些信息来确认调优方案
优化思路
优化思路1:选择合适的 GC 回收器
假设有这样一个需求,要求每次操作的响应时间必须在 500ms 以内。这个时候一般会选择响应速度较快的 GC 回收器,CMS(Concurrent Mark Sweep)回收器和 G1 回收器都是不错的选择
当需求对系统吞吐量有要求时,就可以选择 Parallel Scavenge 回收器来提高系统的吞吐量
# 查看JVM默认使用的垃圾收集器
java -XX:+PrintCommandLineFlags -version
// 以1.8.0_151为例:此处使用的是并行收集器
// output
-XX:InitialHeapSize=535747968 -XX:MaxHeapSize=8571967488 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
java version "1.8.0_151"
Java(TM) SE Runtime Environment (build 1.8.0_151-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.151-b12, mixed mode)
优化思路2:JDK版本升级
结合上述对G1机制的分析,可以看到升级到较新的JDK版本,可以解决上述的大部分问题(但还是要考虑实际业务场景的JDK版本兼容性)
日志调优配置
常用GC日志选项(许多特定问题诊断需要依赖于这些选项)
// 常用选项
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
- G1调优的一个基本建议就是避免进行大量的Humongous对象分配,如果Ergonomics信息说明发生了这一点,那么就可以考虑要么增大堆的大小,要么直接将region大小提高
-XX:+PrintAdaptiveSizePolicy // 打印G1 Ergonomics相关信息
- 如果是怀疑出现引用清理不及时的情况,则可以打开下面选项,分析到底是哪里出现了堆积
-XX:+PrintReferenceGC
建议开启选项下面的选项进行并行引用处理。
-XX:+ParallelRefProcEnabled
JDK 9中JVM和GC日志机构进行了重构,前面说到的常用选项PrintGCDetails已经被标记为废弃,而PrintGCDateStamps已经被移除,指定它会导致JVM无法启动。可以使用下面的命令查询新的配置参数。
java -Xlog:help
通用实践(结合内部结构和机制分析)
- 如果发现Young GC非常耗时,这很可能就是因为新生代太大了,可以考虑减小新生代的最小比例
-XX:G1NewSizePercent
降低其最大值同样对降低Young GC延迟有帮助
-XX:G1MaxNewSizePercent
如果直接为G1设置较小的延迟目标值,也会起到减小新生代的效果,虽然会影响吞吐量
- 如果是Mixed GC延迟较长,应该怎么做呢?
// 思路1:因为部分Old region会被包含进Mixed GC,可以减少一次处理的region个数(控制最大值:G1OldCSetRegionThresholdPercent)
–XX:G1OldCSetRegionThresholdPercent
// 思路2:利用下面参数提高Mixed GC的个数,当前默认值是8,Mixed GC数量增多,意味着每次被包含的region减少
-XX:G1MixedGCCountTarget
内存分配调优(实践)(✨)
结合性能衡量指标,随后可通过工具查询GC相关日志,统计各项指标的信息。通过 JVM 参数预先设置 GC 日志,通常有以下几种 JVM 参数设置
# 常见GC日志输出配置
-XX:+PrintGC 输出GC日志
-XX:+PrintGCDetails 输出GC的详细日志
-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
-Xloggc:../logs/gc.log 日志文件的输出路径
# GC日志打印参数配置参考
-XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:./gclogs
一些很短的GC日志可以通过文本查看,但是如果是长时间的GC日志,可以借助GC日志查看工具-GCViewer(图形化界面查看整体的GC性能)
还可借助一些GC日志分析工具-GCeasy,将日志文件压缩之后,上传到 GCeasy 官网查看 GC 日志分析结果
通过工具GC问题,随后可进一步确认GC调优策略
思路1:降低 Minor GC 频率
通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此可以通过增大新生代空间来降低 Minor GC 的频率
针对上述方案:可能会有这样的疑问,扩容 Eden 区虽然可以减少 Minor GC 的次数,但也会增加单次 Minor GC 的时间可能也很难达到预期优化效果
结合上述问题进行分析:可以结合这句话去理解【通常在虚拟机中,复制对象的成本要远高于扫描成本】
单次 Minor GC 时间是由两部分组成:T1(扫描新生代)和 T2(复制存活对象)。假设一个对象在 Eden 区的存活时间为 500ms,Minor GC 的时间间隔是 300ms,那么正常情况下,Minor GC 的时间为 :T1+T2。当增大新生代空间,Minor GC 的时间间隔可能会扩大到 600ms,此时一个存活 500ms 的对象就会在 Eden 区中被回收掉,此时就不存在复制存活对象了,所以再发生 Minor GC 的时间为:两次扫描新生代,即 2T1。结合分析可知,扩容后Minor GC 时增加了 T1,但省去了 T2 的时间
如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加 Minor GC 的时间。如果堆中的短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。因此,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。
思路2:降低 Full GC 的频率
通常情况下,由于堆内存空间不足或老年代对象太多,会触发 Full GC,频繁的 Full GC 会带来上下文切换,增加系统的性能开销,因此降低Full GC 的频率是一个很好的优化方向
**减少创建大对象:**在平常的业务场景中,习惯一次性从数据库中查询出一个大对象用于 web 端显示
例如一次性查询出 60 个字段的业务操作,这种大对象如果超过年轻代最大对象阈值,会被直接创建在老年代;即使被创建在了年轻代,由于年轻代的内存空间有限,通过 Minor GC 之后也会进入到老年代,这种大对象很容易触发Full GC。解决方案:分次查询 =》将大对象拆解出来,首次只查询一些比较重要的字段,如果还需要其它字段辅助查看,再通过第二次查询显示剩余的字段。
**增大堆内存空间:**在堆内存不足的情况下,增大堆内存空间,且设置初始化堆内存为最大堆内存,也可以降低 Full GC 的频率
思路3:调整Eden、Survivor 区比例
在 JVM 中,如果开启 AdaptiveSizePolicy,则每次 GC 后都会重新计算 Eden、From Survivor 和 To Survivor 区的大小,计算依据是 GC 过程中统计的 GC 时间、吞吐量、内存占用量。这个时候 SurvivorRatio 默认设置的比例会失效
在 JDK1.8 中,默认是开启 AdaptiveSizePolicy 的,可以通过 -XX:-UseAdaptiveSizePolicy 关闭该项配置,或显示运行 -XX:SurvivorRatio=8 将 Eden、Survivor 的比例设置为 8:2。大部分新对象都是在 Eden 区创建的,可以固定 Eden 区的占用比例,来调优 JVM 的内存分配性能