本节目标:在 17.1 定位、17.2 压测之后,给出生产环境真正该设的那一小撮 JVM 参数——堆大小与容器感知、OOM 现场、G1 取舍与何时上 ZGC、统一 GC 日志、直接内存与线程栈,并教你在本机用
PrintFlagsFinal核实默认值,而不是照抄网上的老参数。
适用版本:Spring Boot 4.1.x(Java 21)
17.3 JVM 参数与运行时调优
17.1 教了怎么找瓶颈,17.2 教了怎么用可信负载验证。当瓶颈落在 GC 或内存上时,就轮到 JVM 参数了。
先泼一盆冷水:绝大多数「性能调优」贴里列的 JVM 参数是多余的,甚至有害。 有人从 Java 8 时代抄来一长串参数,其中一半在现代 JVM 上要么是默认值、要么已被移除、要么针对的是早就换掉的收集器。本节只讲生产上真正需要显式设置的那几个,其余交给默认值。
本节所有默认值都来自本机 JDK 21.0.12.1 实测(/tmp/springboot_book/jdk-21.0.12.1+1/Contents/Home),命令与输出在 17.3.9 给出。凡本机无法验证的参数,本节不写。
17.3.1 最小参数集:先只设这几项
对绝大多数 Spring Boot 服务,下面这一组就够了,其余参数在没测出问题之前不要加:
java \
-Xms2g -Xmx2g \
-XX:MaxRAMPercentage=75 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/loan-service/ \
-Xlog:gc*:file=/var/log/loan-service/gc.log:time,uptime,level,tags:filecount=5,filesize=50m \
-jar loan-service.jar
逐项说明(每一项的取舍在后续小节展开):
| 参数 | 作用 | 是否必须 |
|---|---|---|
-Xms / -Xmx | 固定堆大小,避免堆伸缩 | 容器外推荐设 |
-XX:MaxRAMPercentage | 按容器内存百分比定堆上限 | 容器内推荐 |
-XX:+HeapDumpOnOutOfMemoryError | OOM 时自动转储现场 | 强烈推荐 |
-XX:HeapDumpPath | 指定转储文件目录 | 与上一项配套 |
-Xlog:gc* | 统一 GC 日志 | 强烈推荐 |
注意 -Xms/-Xmx 与 MaxRAMPercentage 通常二选一:物理机或内存固定的容器用 -Xms/-Xmx 明确写死;容器内存弹性、想让堆随容器上限缩放时用 MaxRAMPercentage。两个一起用,后者可能被前者覆盖,容易混乱。
17.3.2 为什么 -Xms 与 -Xmx 要相等
默认情况下,JVM 的初始堆远小于最大堆——本机实测 InitialHeapSize 是 512 MB(约 512×1024×1024 字节),而 MaxHeapSize 是 8 GB。这意味着堆会从 512 MB 逐渐扩张到 8 GB,扩张过程会触发 GC、申请内存、影响延迟。
把 -Xms 和 -Xmx 设成相等,堆就一开始就分配到位、不再伸缩。本机实测:
java -Xms512m -Xmx512m -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E "InitialHeapSize|MaxHeapSize"
size_t InitialHeapSize = 536870912 {product} {command line}
size_t MaxHeapSize = 536870912 {product} {command line}
两者相等(536870912 字节 = 512 MB),堆不再变化。
代价:进程启动时就会占用全部堆内存(除非配 -XX:+AlwaysPreTouch,默认 false,即不预先触页)。对追求延迟稳定、内存预算明确的场景,这个代价是值得的;对内存紧张、多实例挤在一台机器上的场景,也可以只设 -Xmx 而不设 -Xms,用一点延迟换内存弹性。
17.3.3 容器里的正确姿势:MaxRAMPercentage
在 Kubernetes 里给 Pod 设了 memory: 2Gi 的 limit,如果 JVM 还是按物理机内存去算堆大小,就会超限被 OOMKill。现代 JVM 默认是容器感知的(UseContainerSupport 默认开启),但百分比默认值不一定符合你的期望。
本机实测 MaxRAMPercentage 默认值是 25.0,InitialRAMPercentage 是 1.5625:
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E "MaxRAMPercentage|InitialRAMPercentage"
double InitialRAMPercentage = 1.562500 {product} {default}
double MaxRAMPercentage = 25.000000 {product} {default}
含义:如果容器 limit 是 2 GB,默认只给堆 512 MB(25%),剩下的留给元空间、线程栈、直接内存、代码缓存等堆外部分。堆外内存也会计入容器 limit,所以堆不能把 limit 吃满。
显式调整(比如让堆占 75%):
java -XX:MaxRAMPercentage=75 -XX:+PrintFlagsFinal -version 2>/dev/null | grep MaxHeapSize
本机实测输出(该机容器/物理内存口径下):
size_t MaxHeapSize = 24058527744 {product} {ergonomic}
判读方法:堆上限 = 容器可用内存 × MaxRAMPercentage。 设 75% 是常见折中——给堆外留 25%,够元空间、线程栈、直接内存和 JVM 自身用。设太高(如 90%)容易在堆外增长时被 OOMKill,设太低则堆太小、GC 频繁。具体留多少要按堆外实际占用实测,别照抄。
17.3.4 OOM 现场:HeapDumpOnOutOfMemoryError
OOM 最痛苦的不是「挂了」,而是「挂完现场没了,无法分析」。默认 HeapDumpOnOutOfMemoryError 是 false(本机实测),也就是 OOM 时不会自动转储,你就永远失去了那一次的内存现场。
打开它,并指定目录:
java \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/loan-service/heapdump.hprof \
-jar loan-service.jar
注意事项:
- 转储文件可能和堆一样大,要确保目录有足够磁盘空间,并挂到容器外(否则容器一销毁转储也没了)。
- 转储本身会暂停应用,只应作为「反正已经 OOM 了」的兜底,不是常态开销。
- 拿到
.hprof后用 Eclipse MAT 或jhat类工具分析,找「谁持有最多对象、为什么没释放」。16 章讲的内存泄漏排查,最终就落到这份转储上。 - 想进一步在 OOM 时直接退出(交给编排重启),可加
-XX:+ExitOnOutOfMemoryError(默认 false,本机实测)。
17.3.5 G1:Java 21 的默认,以及三个可调的旋钮
G1 是 Java 21 的默认收集器,本机实测 UseG1GC = true({ergonomic},即由 JVM 自动选择):
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E "UseG1GC|UseParallelGC|UseSerialGC"
bool UseG1GC = true {product} {ergonomic}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = false {product} {default}
既然默认就是 G1,大多数服务不需要显式指定收集器。G1 上有三个值得理解的旋钮:
其一,MaxGCPauseMillis(默认 200)。 这是 G1 的目标停顿时间,不是硬保证。G1 会尽量把单次 Young GC 停顿压在 200 ms 内,为此它会调整年轻代大小。调小(如 100)会让 G1 更频繁地做更短的 GC,吞吐可能略降;调大则相反。本机实测默认值:
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep MaxGCPauseMillis
uintx MaxGCPauseMillis = 200 {product} {default}
其二,G1HeapRegionSize(本机 ergonomic 值 4 MB)。 G1 把堆切成等大的 region,region 大小影响大对象(humongous)的处理。默认由堆大小推导,通常不用手动设;只有当出现大量 humongous 分配(比如频繁分配接近 region 一半的大数组)时才考虑调大。
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep G1HeapRegionSize
size_t G1HeapRegionSize = 4194304 {product} {ergonomic}
其三,InitiatingHeapOccupancyPercent(默认 45)。 当堆占用达到这个百分比时,G1 启动并发标记周期。调低会更早开始标记(减少 Full GC 风险,但占用更多 CPU);调高则更晚(省 CPU,但 Full GC 风险上升)。
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep InitiatingHeapOccupancyPercent
uintx InitiatingHeapOccupancyPercent = 45 {product} {default}
调 G1 的前提是先看 GC 日志(17.3.7)。 没有 GC 日志就调这三个旋钮,和 17.1 说的「不做基线就调参」是同一个错误。
17.3.6 什么时候才考虑 ZGC
G1 适合绝大多数场景。只有同时满足「大堆」和「低延迟优先」时,才值得评估 ZGC:
- 大堆:几十 GB 甚至更大。G1 在大堆上单次停顿可能到几百毫秒,ZGC 的目标是亚毫秒级停顿。
- 低延迟优先:比如实时交易、在线交互,P99 停顿不可接受。
- 愿意付出吞吐代价:ZGC 的并发处理会占用更多 CPU,吞吐通常略低于 G1。
Java 21 里 ZGC 已经可用且支持分代,但注意本机实测 ZGenerational 默认是 false:
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E "UseZGC|ZGenerational"
bool UseZGC = false {product} {default}
bool ZGenerational = false {product} {default}
也就是说,在 Java 21 上要启用分代 ZGC,必须显式同时给两个开关(本机实测可正常启动):
java -XX:+UseZGC -XX:+ZGenerational -jar loan-service.jar
(后续 JDK 版本里分代 ZGC 才成为 ZGC 的默认形态;Java 21 上不写 -XX:+ZGenerational 得到的是非分代 ZGC。)评估 ZGC 必须用 17.2 的方法压测对比:用同一场景对比 G1 与 ZGC 的 P99 和吞吐,看延迟收益是否值得吞吐损失。
17.3.7 GC 日志:用 -Xlog:gc*,别用旧参数
GC 日志是判断「停顿从哪来」的唯一依据。现代 JVM 用统一日志框架 -Xlog,旧写法已废弃。
必须澄清一个流传很广的过时参数:-XX:+PrintGCDetails 在本机 JDK 21.0.12.1 上已废弃,运行会打印警告并自动改用 -Xlog:gc*:
java -XX:+PrintGCDetails -version
[0.002s][warning][gc] -XX:+PrintGCDetails is deprecated. Will use -Xlog:gc* instead.
不要在生产配置里写 -XX:+PrintGCDetails、-XX:+PrintGCTimeStamps 这类旧参数,它们属于 JDK 8 时代,已被统一日志取代。
正确的写法是 -Xlog:gc*(gc* 表示 gc 标签及其所有子标签)。先看最简形式:
java -Xlog:gc -Xmx64m -XX:+UseG1GC -jar loan-service.jar
本机实测(JDK 21.0.12.1,用一个分配压力的 demo 触发 GC)输出如下:
[0.014s][info][gc] Using G1
[0.508s][info][gc] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 47M->23M(64M) 4.442ms
[0.508s][info][gc] GC(1) Concurrent Undo Cycle
[0.511s][info][gc] GC(2) Pause Young (Concurrent Start) (G1 Humongous Allocation) 28M->3M(64M) 2.712ms
[0.513s][info][gc] GC(4) Pause Young (Concurrent Start) (G1 Humongous Allocation) 33M->8M(64M) 0.792ms
判读方法:每行是一次 GC 事件。Pause Young 是年轻代停顿,行尾的 4.442ms 就是这次停顿的耗时;47M->23M(64M) 是「回收前占用 -> 回收后占用(堆上限)」。关注的是停顿耗时和频率,不是单次回收了多少。
生产配置要带上输出文件、时间戳和轮转,避免日志无限增长:
-Xlog:gc*:file=/var/log/loan-service/gc.log:time,uptime,level,tags:filecount=5,filesize=50m
各段含义:gc* 选事件,file=... 指定输出文件(不写则输出到 stdout),time,uptime,level,tags 是每条日志的装饰器(时间、运行时长、级别、标签),filecount=5,filesize=50m 做轮转(最多 5 个文件、每个 50 MB)。本机实测带装饰器的形态:
[2026-10-09T17:42:47.623+0800][0.013s][info][gc] Using G1
[2026-10-09T17:42:47.941+0800][0.331s][info][gc] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 47M->23M(64M) 2.749ms
拿到日志后,用 jstat 或专门的 GC 日志分析工具(如 GCeasy)统计「每秒停顿时间、Full GC 次数、平均/最大停顿」,作为 17.1 基线里 GC 时间那一项的数据来源。
17.3.8 直接内存与 MaxDirectMemorySize
-XX:MaxDirectMemorySize 限制的是堆外直接内存(NIO、Netty、文件映射等)。本机实测默认值是 0,含义是不单独限制,默认取堆最大值(MaxHeapSize):
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep MaxDirectMemorySize
uint64_t MaxDirectMemorySize = 0 {product} {default}
实践含义:
- 直接内存不归 GC 管,堆里看到的很正常,直接内存却可能爆掉,且报错形态是
OutOfMemoryError: Direct buffer memory,和堆 OOM 不同。 - 堆 + 直接内存 + 元空间 + 线程栈 + 代码缓存共同计入容器 limit。用了 Netty、大文件上传、大量 NIO 的服务,要给直接内存留出预算,必要时显式设上限,让它在容器 limit 内可控。
- 16 章讲的文件上传(14 章)与 Netty 类组件,都是直接内存消耗大户,值得盯。
17.3.9 线程栈与虚拟线程
ThreadStackSize 决定每个平台线程的栈大小。本机实测默认值是 2048 KB(2 MB):
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E "^ *intx ThreadStackSize"
intx ThreadStackSize = 2048 {pd product} {default}
含义:每开一个平台线程,就要预留 2 MB 地址空间。线程数 × 栈大小是堆外内存的一部分——一个 500 线程的应用,光线程栈就是 1 GB 级别。因此:
- 线程池的线程数不是越大越好,除了上下文切换,栈内存也是实打实的成本。
- 递归很深、调用栈很深的场景,栈溢出(
StackOverflowError)可以通过-Xss(等价于设ThreadStackSize)调大缓解,但更该做的是改代码。
虚拟线程(Virtual Threads)不受 ThreadStackSize 约束。 虚拟线程由 JVM 调度、栈是可伸缩的(以堆对象形式存储,随用随扩),可以创建几十万个而不像平台线程那样受栈内存限制。Spring Boot 4.x 在 Java 21 上可以用 spring.threads.virtual.enabled=true 让 Tomcat 用虚拟线程处理请求。
但别把虚拟线程当万能药:它解决的是「大量阻塞式并发」的线程数瓶颈,不解决 CPU 密集或数据库连接池这类外部资源瓶颈。 连接池只有 20 个连接时,虚拟线程再多也得排队等连接——这又回到 17.2 的「连接池与并发不匹配」。
17.3.10 本机查默认值:可复现的方法
本节所有默认值都来自同一条命令,你在自己的机器上可以原样复现(把 java 换成你的 JDK 21 路径):
java -XX:+PrintFlagsFinal -version 2>/dev/null \
| grep -E "MaxHeapSize|InitialHeapSize|MaxRAMPercentage|MaxGCPauseMillis|G1HeapRegionSize|InitiatingHeapOccupancyPercent|HeapDumpOnOutOfMemoryError|MaxDirectMemorySize|UseG1GC|UseZGC|ZGenerational|ThreadStackSize"
本机 JDK 21.0.12.1 的实测结果(节选):
size_t InitialHeapSize = 536870912 {product} {ergonomic}
size_t MaxHeapSize = 8589934592 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
uintx MaxGCPauseMillis = 200 {product} {default}
size_t G1HeapRegionSize = 4194304 {product} {ergonomic}
uintx InitiatingHeapOccupancyPercent = 45 {product} {default}
bool HeapDumpOnOutOfMemoryError = false {manageable} {default}
uint64_t MaxDirectMemorySize = 0 {product} {default}
bool UseG1GC = true {product} {ergonomic}
bool UseZGC = false {product} {default}
bool ZGenerational = false {product} {default}
intx ThreadStackSize = 2048 {pd product} {default}
输出里 {default} 是编译期默认值,{ergonomic} 是 JVM 按机器(CPU 核数、内存)自动推导的值,{command line} 是你在命令行显式设的。判断一个参数该不该抄,先看它在你这台机器上是哪种来源——{ergonomic} 的值会随机器变化,不能照搬到别的机器。
纪律重申:没有 17.1 的剖析数据和 17.2 的压测基线,就不要改 JVM 参数。 参数是最后一步,不是第一步。
小结
- 生产上真正需要显式设的 JVM 参数很少:堆大小、容器百分比、OOM 转储、GC 日志;其余交给默认值。
-Xms与-Xmx相等可避免堆伸缩带来的延迟抖动;容器里则用-XX:MaxRAMPercentage按 limit 百分比定堆,堆外要留出 25% 左右预算。- 默认
HeapDumpOnOutOfMemoryError是 false,必须打开并指定HeapDumpPath,否则 OOM 现场丢失。 - G1 是 Java 21 默认;
MaxGCPauseMillis、G1HeapRegionSize、InitiatingHeapOccupancyPercent三个旋钮要基于 GC 日志调,别凭感觉。 - 大堆 + 低延迟优先才评估 ZGC;Java 21 上分代 ZGC 需显式
-XX:+UseZGC -XX:+ZGenerational,并用压测对比收益。 - GC 日志用
-Xlog:gc*统一日志写法;-XX:+PrintGCDetails已废弃(JDK 21 会警告并改用-Xlog:gc*),不要再用。 - 直接内存不归 GC 管、计入容器 limit;
MaxDirectMemorySize默认 0 表示取堆最大值。 - 平台线程栈默认 2 MB(本机实测),线程数受栈内存约束;虚拟线程不受此限制,但解决不了连接池等外部资源瓶颈。
- 默认值用
java -XX:+PrintFlagsFinal -version在本机核实,区分{default}/{ergonomic}/{command line};改参数前先有基线与压测。
阅读导航:上一节:17.2 压测与容量评估 · 下一节:18.1 需求到架构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。