跳至主要內容

⑤JVM JVM调优

holic-x...大约 17 分钟JAVA基础

⑤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 区比例
  • (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

image-20240601170037435

单个Region大小的设置问题分析

​ 如果是大对象(超出单个Region存储大小阈值),除了直接分配在Old域,G1会将超过region 50%大小的对象(例如应用中的byte、char数组等)归类为Humongous对象,并将其放置在对应的Region中(从逻辑上理解,Humongous也算是老年代Old的一部分,因为于年轻代GC的复制算法而言,大对象的复制是一个非常昂贵的操作)

​ 此处则可衍生思考一个问题:region大小的设计很难和大对象需求保持一致?

​ 因为每个region的大小都是一样的,但是如果出现大对象超出单个region阈值(即单个的Region放不下一个大对象),又没有足够连续的空间分配给这个大对象,这是一个长期存在的情况(可以看作是JVM的bug,可以参考OpenJDK社区讨论open in new window

其解决思路可以有两个方向:

  • 尽量避免大量的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:G1MixedGCLiveThresholdPercentXX: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

image-20240601191529984

​ 一些很短的GC日志可以通过文本查看,但是如果是长时间的GC日志,可以借助GC日志查看工具-GCViewer(图形化界面查看整体的GC性能)

image-20240601191552146

​ 还可借助一些GC日志分析工具-GCeasy,将日志文件压缩之后,上传到 GCeasy 官网查看 GC 日志分析结果

image-20240601191607515

​ 通过工具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 的内存分配性能

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3