面试官:如何进行 JVM 调优(附真实案例)

 2022-09-03
原文地址:https://blog.csdn.net/v123411739/article/details/123778478

前言

面试官:在工作中做过 JVM 调优吗?讲讲做过哪些 JVM 调优?

我一个QPS不到10的项目,上次问我缓存穿透缓存雪崩,这次问我 JVM 调优,我是真滴难。

202209032231007741.png

不过大家别慌,热心的我给大家找来了几个满分回答,大家选择合适的使用。

回答1:听好了,下面将是我第一次 JVM 调优。

回答2:我一般面试的时候才调优。

回答3:我一般直接加机器、加内存。

回答4:老子直接用的 ZGC,调个蛇皮。

正文

1、JVM 究竟需不需要调优?

JVM 经过这么多年的发展和验证,整体是非常健壮的。个人认为99%的情况下,基本用不到 JVM 调优。

通常来说,我们的 JVM 参数配置大多还是会遵循 JVM 官方的建议,例如:

  • -XX:NewRatio=2,年轻代:老年代=1:2
  • -XX:SurvivorRatio=8,eden:survivor=8:1
  • 堆内存设置为物理内存的3/4左右
  • 等等

JVM 参数的默认(推荐)值都是经过 JVM 团队的反复测试和前人的充分验证得出的比较合理的值,因此通常来说是比较靠谱和通用的,一般不会出大问题。

当然,更重要的是,大部分的应用 QPS 都不到10,数据量不到几万,这种低压环境下,想让 JVM 出问题,说实话也挺难的。

202209032231019342.png

大部分同学更常遇到的应该是自己的代码 bug 导致 OOM、CPU load高、GC频繁啥的,这些场景也基本都是代码修复即可,通常不需要动 JVM。

当然,俗话说得好,凡事无绝对,还是有一小部分场景,是可能需要用到 JVM 调优的。具体哪些场景,我们在下面介绍。

值得一提的是,我们这边所说的 JVM 调优更多的是针对自己的业务场景对 JVM 参数进行优化调整,使其更适合我们的业务,而不是指对 JVM 源码的改动。

2、JVM 调优没有什么必要,使用性能更好的垃圾回收器就能解决问题了?

这是我在网上看到的一个说法,因为赞同的人比较多,我估计有不少同学也会有这个想法,因此在这边谈下自己的看法。

1)实战角度

不考虑应付面试的因素,升级垃圾回收器确实会是最有效的方式之一,例如:CMS 升级到 G1,甚至 ZGC。

这个很容易理解,更高版本的垃圾回收器相当于是 JVM 开发人员对 JVM 做的优化,人家毕竟是专门做这个的,所以通常来说升级高版本的性能会有不少的提升。

G1 目前已经有开始在逐渐应用开来,周围有不少团队在 JDK8 中使用了 G1,就我了解到的,还是存在不少问题的,不少同学在不断进行参数的调整,而在 JDK11 中能优化成啥样还有待验证。

ZGC 目前应用的还比较少,仅从对外公布的数据来看很好看,最大暂停时间不超过10ms,甚至是1ms,大家都抱有很高的期望。但是从目前我收集到的一些资料来看,ZGC 也并不是银弹,已知的明显问题有:

  • 吞吐量相较于 G1 会有所下降,官方称最大不超过15%
  • ZGC如果遇到非常高的对象分配速率(allocation rate)的话会跟不上,目前唯一有效的“调优”方式就是增大整个GC堆的大小来让ZGC有更大的喘息空间——R大与ZGC领队沟通后的原话

而且,随着后续 ZGC 应用开来,后续一定会不断出现更多问题的。

整体而言,个人觉得 JVM 调优在某些场景下还是有必要的,毕竟有句话叫:没有最好的,只有最合适的。

2)面试角度

如果你回答直接升级垃圾收集器,面试官可能也赞同,但是这个话题可能就这样结束了,面试官大概率没听到他想要的回答,你在这题的肯定拿不到加分,甚至可能会被扣分。

所以,在面试的时候,你可以回答升级垃圾收集器,但是你不能只回答升级垃圾收集器。

3、JVM 何时优化?

忌过早优化。《计算机程序设计艺术》的作者高德纳(Donald Ervin Knuth)曾说过一句经典的话:

The real problem is that programmers have spent far too much time worrying about efficiency in the wrong places and at the wrong times; premature optimization is the root of all evil (or at least most of it) in programming.

真正的问题是,程序猿在错误的地方和错误的时间花了太多的时间担心效率问题;过早的优化是编程中所有(或者至少是大部分)罪恶的根源。

忌过早并不是说就完全不管,比较正确的做法应该是给核心服务的一些重要 JVM 指标配上监控告警,当指标出现波动或者异常时,能及时介入排查。

面试官:JVM 有哪些核心指标?合理范围应该是多少?

这个问题没有统一的答案,因为每个服务对AVG/TP999/TP9999等性能指标的要求是不同的,因此合理的范围也不同。

为了防止面试官追问,对于普通的 Java 后端应用来说,我这边给出一份相对合理的范围值。以下指标都是对于单台服务器来说:

  • jvm.gc.time:每分钟的GC耗时在1s以内,500ms以内尤佳
  • jvm.gc.meantime:每次YGC耗时在100ms以内,50ms以内尤佳
  • jvm.fullgc.count:FGC最多几小时1次,1天不到1次尤佳
  • jvm.fullgc.time:每次FGC耗时在1s以内,500ms以内尤佳

通常来说,只要这几个指标正常,其他的一般不会有问题,如果其他地方出了问题,一般都会影响到这几个指标。

4、JVM 优化步骤?

4.1、分析和定位当前系统的瓶颈

对于JVM的核心指标,我们的关注点和常用工具如下:

1)CPU指标

  • 查看占用CPU最多的进程
  • 查看占用CPU最多的线程
  • 查看线程堆栈快照信息
  • 分析代码执行热点
  • 查看哪个代码占用CPU执行时间最长
  • 查看每个方法占用CPU时间比例

常见的命令:

    // 显示系统各个进程的资源使用情况
    top
    // 查看某个进程中的线程占用情况
    top -Hp pid
    // 查看当前 Java 进程的线程堆栈信息
    jstack pid

常见的工具:JProfiler、JVM Profiler、Arthas等。

2)JVM 内存指标

  • 查看当前 JVM 堆内存参数配置是否合理
  • 查看堆中对象的统计信息
  • 查看堆存储快照,分析内存的占用情况
  • 查看堆各区域的内存增长是否正常
  • 查看是哪个区域导致的GC
  • 查看GC后能否正常回收到内存

常见的命令:

    // 查看当前的 JVM 参数配置
    ps -ef | grep java
    // 查看 Java 进程的配置信息,包括系统属性和JVM命令行标志
    jinfo pid
    // 输出 Java 进程当前的 gc 情况
    jstat -gc pid
    // 输出 Java 堆详细信息
    jmap -heap pid
    // 显示堆中对象的统计信息
    jmap -histo:live pid
    // 生成 Java 堆存储快照dump文件
    jmap -F -dump:format=b,file=dumpFile.phrof pid

常见的工具:Eclipse MAT、JConsole等。

3)JVM GC指标

  • 查看每分钟GC时间是否正常
  • 查看每分钟YGC次数是否正常
  • 查看FGC次数是否正常
  • 查看单次FGC时间是否正常
  • 查看单次GC各阶段详细耗时,找到耗时严重的阶段
  • 查看对象的动态晋升年龄是否正常

JVM 的 GC指标一般是从 GC 日志里面查看,默认的 GC 日志可能比较少,我们可以添加以下参数,来丰富我们的GC日志输出,方便我们定位问题。

GC日志常用 JVM 参数:

    // 打印GC的详细信息
    -XX:+PrintGCDetails
    // 打印GC的时间戳
    -XX:+PrintGCDateStamps
    // 在GC前后打印堆信息
    -XX:+PrintHeapAtGC
    // 打印Survivor区中各个年龄段的对象的分布信息
    -XX:+PrintTenuringDistribution
    // JVM启动时输出所有参数值,方便查看参数是否被覆盖
    -XX:+PrintFlagsFinal
    // 打印GC时应用程序的停止时间
    -XX:+PrintGCApplicationStoppedTime
    // 打印在GC期间处理引用对象的时间(仅在PrintGCDetails时启用)
    -XX:+PrintReferenceGC

以上就是我们定位系统瓶颈的常用手段,大部分问题通过以上方式都能定位出问题原因,然后结合代码去找到问题根源。

4.2、确定优化目标

定位出系统瓶颈后,在优化前先制定好优化的目标是什么,例如:

  • 将FGC次数从每小时1次,降低到1天1次
  • 将每分钟的GC耗时从3s降低到500ms
  • 将每次FGC耗时从5s降低到1s以内
  • ...

4.3、制订优化方案

针对定位出的系统瓶颈制定相应的优化方案,常见的有:

  • 代码bug:升级修复bug。典型的有:死循环、使用无界队列。
  • 不合理的JVM参数配置:优化 JVM 参数配置。典型的有:年轻代内存配置过小、堆内存配置过小、元空间配置过小。

4.4、对比优化前后的指标,统计优化效果

4.5、持续观察和跟踪优化效果

4.6、如果还需要的话,重复以上步骤

5、调优案例: metaspace导致频繁FGC问题

以下案例来源于网络或本人真实经验,皆能自圆其说,理解掌握后同学们皆可拿来与面试官对线。

服务环境: ParNew + CMS + JDK8

问题现象 :服务频繁出现FGC

原因分析

1)首先查看GC日志,发现出现FGC的原因是metaspace空间不够

对应GC日志:

    Full GC (Metadata GC Threshold)

2)进一步查看日志发现元空间存在内存碎片化现象

对应GC日志:

    Metaspace       used 35337K, capacity 56242K, committed 56320K, reserved 1099776K

这边简单解释下这几个参数的意义

  • used :已使用的空间大小
  • capacity:当前已经分配且未释放的空间容量大小
  • committed:当前已经分配的空间大小
  • reserved:预留的空间大小

这边 used 比较容易理解,reserved 在本例不重要可以先忽略,主要是 capacity 和 committed 这2个容易搞混。

结合下图来看更容易理解,元空间的分配以 chunk 为单位,当一个 ClassLoader 被垃圾回收时,所有属于它的空间(chunk)被释放,此时该 chunk 称为 Free Chunk,而 committed chunk 就是 capacity chunk 和 free chunk 之和。

202209032231041843.png

之所以说内存存在碎片化现象就是根据 used 和 capacity 的数据得来的,上面说了元空间的分配以 chunk 为单位,即使一个 ClassLoader 只加载1个类,也会独占整个 chunk,所以当出现 used 和 capacity 两者之差较大的时候,说明此时存在内存碎片化的情况。

GC日志demo如下:

    {Heap before GC invocations=0 (full 0):
     par new generation   total 314560K, used 141123K [0x00000000c0000000, 0x00000000d5550000, 0x00000000d5550000)
      eden space 279616K,  50% used [0x00000000c0000000, 0x00000000c89d0d00, 0x00000000d1110000)
      from space 34944K,   0% used [0x00000000d1110000, 0x00000000d1110000, 0x00000000d3330000)
      to   space 34944K,   0% used [0x00000000d3330000, 0x00000000d3330000, 0x00000000d5550000)
     concurrent mark-sweep generation total 699072K, used 0K [0x00000000d5550000, 0x0000000100000000, 0x0000000100000000)
     Metaspace       used 35337K, capacity 56242K, committed 56320K, reserved 1099776K
      class space    used 4734K, capacity 8172K, committed 8172K, reserved 1048576K
    1.448: [Full GC (Metadata GC Threshold) 1.448: [CMS: 0K->10221K(699072K), 0.0487207 secs] 141123K->10221K(1013632K), [Metaspace: 35337K->35337K(1099776K)], 0.0488547 secs] [Times: user=0.09 sys=0.00, real=0.05 secs] 
    Heap after GC invocations=1 (full 1):
     par new generation   total 314560K, used 0K [0x00000000c0000000, 0x00000000d5550000, 0x00000000d5550000)
      eden space 279616K,   0% used [0x00000000c0000000, 0x00000000c0000000, 0x00000000d1110000)
      from space 34944K,   0% used [0x00000000d1110000, 0x00000000d1110000, 0x00000000d3330000)
      to   space 34944K,   0% used [0x00000000d3330000, 0x00000000d3330000, 0x00000000d5550000)
     concurrent mark-sweep generation total 699072K, used 10221K [0x00000000d5550000, 0x0000000100000000, 0x0000000100000000)
     Metaspace       used 35337K, capacity 56242K, committed 56320K, reserved 1099776K
      class space    used 4734K, capacity 8172K, committed 8172K, reserved 1048576K
    }
    {Heap before GC invocations=1 (full 1):
     par new generation   total 314560K, used 0K [0x00000000c0000000, 0x00000000d5550000, 0x00000000d5550000)
      eden space 279616K,   0% used [0x00000000c0000000, 0x00000000c0000000, 0x00000000d1110000)
      from space 34944K,   0% used [0x00000000d1110000, 0x00000000d1110000, 0x00000000d3330000)
      to   space 34944K,   0% used [0x00000000d3330000, 0x00000000d3330000, 0x00000000d5550000)
     concurrent mark-sweep generation total 699072K, used 10221K [0x00000000d5550000, 0x0000000100000000, 0x0000000100000000)
     Metaspace       used 35337K, capacity 56242K, committed 56320K, reserved 1099776K
      class space    used 4734K, capacity 8172K, committed 8172K, reserved 1048576K
    1.497: [Full GC (Last ditch collection) 1.497: [CMS: 10221K->3565K(699072K), 0.0139783 secs] 10221K->3565K(1013632K), [Metaspace: 35337K->35337K(1099776K)], 0.0193983 secs] [Times: user=0.03 sys=0.00, real=0.02 secs] 
    Heap after GC invocations=2 (full 2):
     par new generation   total 314560K, used 0K [0x00000000c0000000, 0x00000000d5550000, 0x00000000d5550000)
      eden space 279616K,   0% used [0x00000000c0000000, 0x00000000c0000000, 0x00000000d1110000)
      from space 34944K,   0% used [0x00000000d1110000, 0x00000000d1110000, 0x00000000d3330000)
      to   space 34944K,   0% used [0x00000000d3330000, 0x00000000d3330000, 0x00000000d5550000)
     concurrent mark-sweep generation total 699072K, used 3565K [0x00000000d5550000, 0x0000000100000000, 0x0000000100000000)
     Metaspace       used 17065K, capacity 22618K, committed 35840K, reserved 1079296K
      class space    used 1624K, capacity 2552K, committed 8172K, reserved 1048576K
    }

元空间主要适用于存放类的相关信息,而存在内存碎片化说明很可能创建了较多的类加载器,同时使用率较低。

因此,当元空间出现内存碎片化时,我们会着重关注是不是创建了大量的类加载器。

3)通过 dump 堆存储文件发现存在大量 DelegatingClassLoader

通过进一步分析,发现是由于反射导致创建大量 DelegatingClassLoader。其核心原理如下:

在 JVM 上,最初是通过 JNI 调用来实现方法的反射调用,当 JVM 注意到通过反射经常访问某个方法时,它将生成字节码来执行相同的操作,称为膨胀(inflation)机制。如果使用字节码的方式,则会为该方法生成一个 DelegatingClassLoader,如果存在大量方法经常反射调用,则会导致创建大量 DelegatingClassLoader。

反射调用频次达到多少才会从 JNI 转字节码?

默认是15次,可通过参数 -Dsun.reflect.inflationThreshold 进行控制,在小于该次数时会使用 JNI 的方式对方法进行调用,如果调用次数超过该次数就会使用字节码的方式生成方法调用。

分析结论 :反射调用导致创建大量 DelegatingClassLoader,占用了较大的元空间内存,同时存在内存碎片化现象,导致元空间利用率不高,从而较快达到阈值,触发 FGC。

优化策略:

1)适当调大 metaspace 的空间大小。

2)优化不合理的反射调用。例如最常见的属性拷贝工具类 BeanUtils.copyProperties 可以使用 mapstruct 替换。

总结

当被面试官问到 JVM 调优时,完全可以按照本文的脉络回答:

  • 首先表态如果使用合理的 JVM 参数配置,在大多数情况应该是不需要调优的——对应本文第1题
  • 其次说明可能还是存在少量场景需要调优,我们可以对一些 JVM 核心指标配置监控告警,当出现波动时人为介入分析评估——对应本文第3题
  • 最后举一个实际的调优例子来加以说明——对应本文第5题

如果面试官反问怎么分析排查的,则可以使用本文第4题的常用命令和工具来与之对线。

这一套流程下来,我相信大部分面试官都会对你印象不错。

最后

我是囧辉, 一个坚持分享原创技术干货的程序员 ,如果觉得本文对你有帮助,记得点赞关注,我们下期再见。

推荐阅读

Java 基础高频面试题(2021年最新版)

Java 集合框架高频面试题(2021年最新版)

面试必问的 Spring,你懂了吗?

面试必问的 MySQL,你懂了吗?