likes
comments
collection
share

「一览无余」手把手教你 JVM 调优路径——上篇(建议收藏)

作者站长头像
站长
· 阅读数 5

总所周知,JVM 是进行自动内存管理的,自有一套默认 JVM 参数生效,但是业务天差地别,所以 JVM 默认参数除了在项目初期比较适用以外,随着应用访问量的增加往往会具有很多问题,我们的 JVM 调优也往往是对这些参数进行改动,通过改动参数来让 JVM 更加适配自己的业务应用程序。

作为一个五年 Java 人,我基本上会在每一次面试中被问到 JVM 调优,每年也总有几次在工作中会需要用到 JVM 调优的相关知识,相信 JVM 调优已经是每一个 Java 人必不可少的知识储备了,但是如果现在突然让你对一个 JVM 程序进行调优,你有一套自己从头到尾的逐步流程吗?

鉴于此,本篇将系统化的分享我自己在 JVM 调优上的执行流程,帮助大家思考 JVM 调优这件事,也希望有更好流程的大佬可以评论区讨论。

当然,本文也会涉及到大量 JVM 相关自带命令,对这些命令并不熟悉的同学也可以借机学习,本文知识基于 JDK8,个人调试过程基于 JDK17。

1. JVM 必知必会

先来一个比较全面的 JVM 内存结构图,无论是否涉及 JVM 调优,下图中的内容也是每一个 JVM 平台开发人员必知必会的内容:

「一览无余」手把手教你 JVM 调优路径——上篇(建议收藏)

只有了解 JVM 的内存结构,我们才能比较好的判断 JVM 程序问题出在哪,而且通过通过内存结构我们也可以更好的判断整个 JVM 到底需要多少内存,我们的 pod 应该设置多大的内存。

大家通常在内存设置方面比较常用的设置是设置最大堆内存,但是整个 pod 需要多少内存其实是由:最大堆内存 + 最大元空间 + 线程占用内存 + 直接内存占用来决定的。

如果你的 pod 没有达到它的最小内存大小,就会出现 pod 莫名以 OOM 为原因 kill 掉了应用,拉取应用的堆转储文件和 gc 文件你会发现一切参数指标正常,从而找不到应用被 kill 的原因,这是发生在我司的真实案例,最后也是我解决了这个遗留了一年的问题。

通常来说,上面这张图我们比较关注的是堆内存、元空间和直接内存,相信很多人对堆内存和元空间比较了解,但是并不太了解直接内存。

直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的,我们比较常见的一个场景是在各个 web 服务器中都有用到的:NIO。

直接内存的分配不会受到 Java 堆的限制,而是受所在机器实例的可用内存限制。

一般来说,这三块区域出现OOM 的概率是堆 > 直接内存 > 元空间,通常在调优过程中,直接内存和元空间都可以看作整块内存,但是堆内存由于具有分代的特性,所以往往需要花更多的精力进行逐个分析,堆内存的内存结构图如下:

「一览无余」手把手教你 JVM 调优路径——上篇(建议收藏)

堆内存内部分为 Young 区和 Old 区,也就是大家常说的新生代和老年代,而 Young 区内存又分为 Eden 区和Survivor 区,这两个区域在 Young 区所占用的内存比例是 8:2。

除此之外,我们对于堆内存对象的晋升流程也应该有所了解,对象晋升流程如下

对象都会首先在 Eden 区域分配,在一次 Young GC 后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1(Eden 区 → Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。

在 G1 垃圾回收器出现之前,JVM 的堆内存结构基本上是上图所示,但是 G1 垃圾回收器下的堆内存结构并不是如此,而是这样的:

「一览无余」手把手教你 JVM 调优路径——上篇(建议收藏)

整个堆内存在默认设置下被整分为 2048 个 region,然后每个 region 代表一个 Young 区或者 Old 区,并且每个 Region 的分代并不是固定的,可以根据需要在 Young 区和 Old 区之间互相转换,但是 Young 区的内存结构和上图中是一致的,依然是分为:Eden 区和Survivor 区。

G1 这样费力的目的在于其可以通过这种 region 的方式对某一部分内存进行回收,比如它可以只回收一部分 Old 区域内存,这样在保证用户够用的情况下,减少了 STW 的时间,这也是 G1 和后续 ZGC 的发展方向,即:保持内存够用的情况下尽量减少应用停顿时间。

从 JDK9 开始,G1 开始成为默认垃圾回收器,所以在 JVM 调优的时候也要考虑所使用的垃圾回收器,因为 G1 的出现,JVM 参数中加入了一些与 G1 相关的可调参数。

JVM 中的 GC 可以分为以下四种:

  1. Young GC:对青年代进行回收。
  2. Old GC:对老年代进行回收,只有 CMS 具有这个模式。
  3. Mixed GC:混合回收,同时对青年代全部和部分老年代进行回收,G1 回收器会出现此类回收。
  4. Full GC:对全部青年代和全部老年代进行回收。

2. JVM 常用工具

在进行 JVM 调优的过程中,往往会用到 JDK 自带的一些工具,在这里我们先来讲讲这些经常会用到的命令。

主要常用的工具有以下五个:

  • jps (JVM Process Status): 类似 UNIX 的 ps 命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息;
  • jstat(JVM Statistics Monitoring Tool): 用于收集 HotSpot 虚拟机各方面的运行数据;
  • jinfo (Configuration Info for Java) : Configuration Info for Java,显示虚拟机配置信息;
  • jmap (Memory Map for Java) : 生成堆转储快照;
  • jstack (Stack Trace for Java) : 生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。

下面是一些示例:

# 输出主类的全名,如果进程执行的是 Jar 包,输出 Jar 路径。
jps -l
# 输出虚拟机进程启动时 JVM 参数
jps -v
# 输出传递给 Java 进程 main() 函数的参数
jps -m

# 监视虚拟机的各种运行状态信息
jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]

# 出当前 jvm 进程的全部参数和系统属性
jinfo vmid
# 输出对应名称的参数的具体值,比如:jinfo  -flag MaxHeapSize 17340
jinfo -flag name vmid
# 开启或者关闭对应名称的参数,比如:jinfo  -flag  PrintGC 17340
jinfo -flag [+|-]name vmid

# 打印生成虚拟机当前时刻的线程快照
jstack vmid

一般情况下,在我们登录进 JVM 宿主机之后,往往通过 jps -l 这个命令来找到我们的应用的 vmid,可以理解为 pid,在后续一些命令中都需要使用到这个 id 来告诉这些工具我们要操作的是具体哪个应用。

jinfo 命令通常用来查看我们应用的启动参数,但是如果直接通过 jinfo <vmid> 这个命令来查看应用参数,其返回结果过于冗长,所以一般可以通过 jcmd <vmid> VM.flags 这个命令进行查看,示例如下:

root@iv-yd0bg1ivb4k36d108qzz:~# jps -l
5143 BfErp-0.0.1-SNAPSHOT.jar
444652 rss-server-0.0.1-SNAPSHOT.jar
456011 jdk.jcmd/sun.tools.jps.Jps

root@iv-yd0bg1ivb4k36d108qzz:~# jcmd 5143 VM.flags
5143:
-XX:CICompilerCount=2 -XX:ConcGCThreads=1 -XX:G1ConcRefinementThreads=2 
-XX:G1EagerReclaimRemSetThreshold=8 -XX:G1HeapRegionSize=1048576 
-XX:GCDrainStackTargetSize=64 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/hprof/errorDump.hprof 
-XX:InitialHeapSize=65011712 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=1025507328 
-XX:MaxNewSize=614465536 -XX:MinHeapDeltaBytes=1048576 -XX:MinHeapSize=8388608 
-XX:NonNMethodCodeHeapSize=5826188 -XX:NonProfiledCodeHeapSize=122916026 
-XX:ProfiledCodeHeapSize=122916026 -XX:ReservedCodeCacheSize=251658240 
-XX:+SegmentedCodeCache -XX:SoftMaxHeapSize=1025507328 -XX:+UseCompressedClassPointers 
-XX:+UseCompressedOops -XX:+UseG1GC

但是无论是哪种方式返回的结果,都是标准的 JVM 参数结果,而我们常用的一些 JVM 参数配置都是缩写比如:-Xms、-Xmx、-Xmn,比如在上面这个例子中,-Xmx 其实对应 -XX:MaxHeapSize。

jinfo 命令还有一个比较有用的作用是能动态开关参数,比如在以上实例中,我可以在不重启应用的情况下,直接对这个应用设置JVM 参数:

root@iv-yd0bg1ivb4k36d108qzz:~# jinfo -flag HeapDumpOnOutOfMemoryError 5143
-XX:-HeapDumpOnOutOfMemoryError

这也有利于我们观测参数设置效果,但是根据我的尝试,在 JDK 17 中很多参数是无法动态更改的,即使使用 jcmd 命令也一样:

root@iv-yd0bg1ivb4k36d108qzz:~# jinfo -flag MetaspaceSize=24M 5143
Exception in thread "main" com.sun.tools.attach.AttachOperationFailedException: flag 'MetaspaceSize' cannot be changed
        at jdk.attach/sun.tools.attach.VirtualMachineImpl.execute(VirtualMachineImpl.java:229)
        at jdk.attach/sun.tools.attach.HotSpotVirtualMachine.executeCommand(HotSpotVirtualMachine.java:310)
        at jdk.attach/sun.tools.attach.HotSpotVirtualMachine.setFlag(HotSpotVirtualMachine.java:283)
        at jdk.jcmd/sun.tools.jinfo.JInfo.flag(JInfo.java:146)
        at jdk.jcmd/sun.tools.jinfo.JInfo.main(JInfo.java:127)

jstat 是一个很强大的实时监控命令,可以在命令行中实时查看当前应用程序的状态,但是其参数繁杂,所以我建议利用 ClaudeAI 通过自然语言生成命令,同为免费版的 AI 工具,它比谷歌的 Gemini 要更加准确:

「一览无余」手把手教你 JVM 调优路径——上篇(建议收藏)

「一览无余」手把手教你 JVM 调优路径——上篇(建议收藏)

jmap 是一个堆转存快照命令,得到转存文件后我们可以将其下载到本地电脑中,利用可视化分析工具分析此时的 JVM 应用状态。

jstack 是一个可以查看 JVM 应用线程情况的命令,这个命令往往会被用于查找死锁,因为出现死锁必定有一个应用线程的 CPU 占用急剧飙高。

3. 常用 JVM 参数

由于常用 JVM 参数实在没有太多可以讲解的,大家看我每个命令上的备注即可,内存参数设置单位为 “ g” (GB)、“ m”(MB)、“ k”(KB):

# 最小堆和最大堆
-Xms512m
-Xmx8g

# 新生代最大最小
-XX:NewSize=256m
-XX:MaxNewSize=1024m

# 设置新生代与老年代比例,1 就是 1:1
-XX:NewRatio=1

# 设置元空间触发 FullGC的大小,建议两者设置一样
# 可以稳定运行一段时间后通过jstat -gc pid确认这个值设置的大小
-XX:MetaspaceSize=128m
-XX:MaxMetaspaceSize=128m

# GC 日志必选
# 打印基本 GC 信息
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
# 打印对象分布
-XX:+PrintTenuringDistribution
# 打印堆数据
-XX:+PrintHeapAtGC
# 打印Reference处理信息
# 强引用/弱引用/软引用/虚引用/finalize 相关的方法
-XX:+PrintReferenceGC
# 打印STW时间
-XX:+PrintGCApplicationStoppedTime

# 可选
# 打印safepoint信息,进入 STW 阶段之前,需要要找到一个合适的 safepoint
-XX:+PrintSafepointStatistics
-XX:PrintSafepointStatisticsCount=1

# GC日志输出的文件路径
-Xloggc:/path/to/gc-%t.log
# 开启日志文件分割
-XX:+UseGCLogFileRotation
# 最多分割几个文件,超过之后从头文件开始写
-XX:NumberOfGCLogFiles=14
# 每个文件上限大小,超过就触发分割
-XX:GCLogFileSize=50M

## OOM 相关命令
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=./java_pid<pid>.hprof
-XX:OnOutOfMemoryError="< cmd args >;< cmd args >"
-XX:+UseGCOverheadLimit

# NMT 排查堆外内存
-XX:NativeMemoryTracking=summary|detail

再给一个常用的 JVM 参数设置:

-server -Xms36600m -Xmx36600m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+PrintReferenceGC
-XX:+ParallelRefProcEnabled
-XX:G1HeapRegionSize=16m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/opt/apps/errorDump.hprof
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintHeapAtGC
-XX:+PrintGCApplicationConcurrentTime
-verbose:gc
-Xloggc:/opt/apps/logs/${app_name}-gc.log

最后

最后,由于本次主题内容过于冗长,我将其分割成了两篇文章同时发布,本篇是六千字的基础知识篇,第二篇则是应对 JVM 抖动和 OOM 相关的思路篇,更是干货满满,第二篇的链接我在开头和结尾都已经附带。

感谢大家能看到这,同时也希望大家能先点个赞再去看第二篇,有任何问题都可以在评论区一块讨论,祝有好收获。

参考:Java 垃圾回收器之G1详解

参考:JVM垃圾回收详解