《Spring Boot 实战》17.3 JVM 参数与运行时调优

JVM 参数不是越多越好,生产上真正该调的只有一小撮。本节用本机 JDK 21 实测默认值讲清 -Xms/-Xmx、容器里的 MaxRAMPercentage、OOM 现场转储、G1 的取舍与何时上 ZGC、-Xlog:gc* 统一日志写法、直接内存与线程栈,并给出可复现的查默认值方法。

本节目标:在 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:+HeapDumpOnOutOfMemoryErrorOOM 时自动转储现场强烈推荐
-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 需求到架构 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计