4. JVM层性能调优实战

Spring Boot 应用的 JVM 调优,难点不在于记住某个参数,而在于让 JVM 的运行时结构与应用的负载特征对齐。一个以短连接 HTTP 请求为主、对象朝生夕灭的 Web 服务,和一个持有大量长生命周期缓存、批量处理大对象的任务型应用,即使部署在同一台机器上,最优的堆划分、GC 策略和线程配置也完全不同。本节围绕堆内存、GC 策略、线程栈和 JIT 编译四个层面,说明如何根据 Spring Boot 的实际负载特征做出匹配选择,并给出可观察、可判断的调优路径。

先建立负载画像,再谈参数

调优前必须先回答三个问题:对象存活时间分布如何、单次请求的分配速率有多高、并发线程规模有多大。Spring Boot 的典型 Web 负载中,Controller 到 Service 到 DAO 的调用链会产生大量临时对象——DTO、JSON 序列化中间对象、MyBatis 结果映射对象,它们绝大多数在新生代就死亡。这类负载的特征是分配速率高、存活率低,适合把新生代调大、让对象在 Young GC 中快速回收。相反,如果应用在启动时加载了本地缓存、规则引擎或大字典,这些对象会晋升到老年代并长期存活,此时老年代容量和 Full GC 频率才是关键。

可以用一段简单的启动参数先采集画像,而不是凭感觉调:

java -Xms2g -Xmx2g \
     -Xlog:gc*,gc+heap=info:file=gc.log:time,uptime,level,tags \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/opt/dump \
     -jar app.jar

运行一段时间后,从 gc.log 中读取三个量:Young GC 间隔、每次 Young GC 后存活对象大小、Full GC 是否出现。如果 Young GC 后存活对象稳定在新生代容量的 10% 以内,说明分代假设成立,可以继续加大新生代;如果存活对象接近新生代一半,说明有大量对象过早晋升,需要检查是否有大对象直接进入老年代,或 Survivor 空间过小导致动态年龄判定提前晋升。

堆内存划分与 GC 策略的匹配

堆内存不是越大越好。堆越大,单次 GC 的停顿通常越长,而 Spring Boot 应用对尾延迟敏感,一次几百毫秒的停顿就可能触发上游超时。因此堆大小的选择要在“减少 GC 频率”和“控制单次停顿”之间取平衡。对于 4 核 8G 的容器,一个常见的起点是 -Xms4g -Xmx4g,新生代占 1/3 到 1/2,即 -Xmn1g~2g,并固定堆大小避免运行时扩缩容带来的抖动。

GC 策略的选择取决于停顿目标和堆规模。G1 在 4G 到 16G 堆、要求停顿在 100~200ms 的场景下是稳妥选择,通过 -XX:MaxGCPauseMillis=150 设定目标,G1 会自动调整新生代大小和混合回收节奏。如果堆小于 4G 且追求吞吐量,Parallel GC 的吞吐表现更好,但停顿不可控。如果堆超过 16G 且停顿要求极严,ZGC 或 Shenandoah 可以把停顿压到 10ms 以内,代价是更高的 CPU 和内存开销。选择时不要只看“哪个先进”,而要看应用的 SLA 是吞吐优先还是延迟优先。

Spring Boot 负载特征 → JVM 参数匹配关系 负载特征 高分配速率 短生命周期对象多 DTO / JSON 中间对象 长存活对象 本地缓存 / 字典 规则引擎常驻数据 高并发线程 Tomcat 工作线程 异步任务线程池 热点方法集中 序列化 / 加解密 频繁调用小方法 尾延迟敏感 JVM 参数匹配 -Xmn 调大 / Survivor 充足 -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 老年代容量 + 混合回收 -XX:InitiatingHeapOccupancyPercent -XX:G1MixedGCLiveThresholdPercent 线程栈与线程数 -Xss512k(默认 1M 偏大) server.tomcat.threads.max JIT 分层编译与内联 -XX:TieredStopAtLevel=1(短命进程) -XX:MaxInlineSize / FreqInlineSize -XX:MaxGCPauseMillis=150 可观察结果与判断 Young GC 间隔拉长 存活对象 < 新生代 10% 晋升速率下降 Full GC 消失或极少 老年代占用曲线平稳 混合回收周期可控 线程创建不再受限 栈内存总量 = 线程数 × Xss 避免 OutOfMemory: unable to create 热点方法编译生效 -XX:+PrintCompilation 观察 去优化次数少 P99 延迟稳定在目标内

线程栈:被忽视的内存与并发约束

每个 Java 线程默认占用约 1MB 栈空间,这个值在 64 位 HotSpot 上由 -Xss 控制。Spring Boot 的 Tomcat 默认最大线程数是 200,加上异步线程池、定时任务线程、连接池维护线程,实际线程数可能达到 300 以上。如果按 1MB 计算,仅栈内存就接近 300MB,而且这些内存不计入堆,容易被忽略。对于调用层次不深的 Web 请求,把 -Xss 降到 512KB 甚至 256KB 是安全的,可以显著降低线程创建失败的风险。

但要注意,-Xss 调小会增加 StackOverflowError 的概率,尤其是使用递归解析 JSON、深层嵌套对象映射的场景。判断依据是:如果压测中出现 StackOverflowError,先检查是否有递归调用,再考虑把 -Xss 调回 512KB 以上。线程数本身也要与下游资源匹配——数据库连接池只有 20 个连接时,Tomcat 开 200 个线程只会让请求在连接池上排队,反而拉长响应时间。合理的做法是让 Tomcat 最大线程数与连接池容量、下游服务承载能力保持同一量级。

JIT 编译:让热点代码真正跑起来

JIT 的调优空间比堆和 GC 小,但在两类场景下值得关注。第一类是短生命周期进程,比如定时触发的批处理任务,进程只运行几十秒,分层编译还没来得及把方法编译到 C2 就结束了。此时可以用 -XX:TieredStopAtLevel=1 只做 C1 编译,减少编译开销,让进程更快进入稳定执行。第二类是热点方法因超过内联阈值而无法内联,比如一个被频繁调用但方法体较大的序列化方法。可以通过 -XX:+PrintCompilation 和 -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 观察哪些方法被内联、哪些被拒绝,再决定是否调整 -XX:MaxInlineSize 或重构方法。

需要强调的是,JIT 调优不应作为首选手段。绝大多数 Spring Boot 应用的性能瓶颈在 GC 停顿、线程竞争或下游 IO,而不是 JIT 编译质量。只有在确认 CPU 火焰图中热点方法已经编译、且内联失败是明确瓶颈时,才值得动这些参数。

一套可执行的调优检查清单

把上述内容落到操作层面,可以按以下顺序推进,每一步都以观察数据为依据,而不是一次性堆砌参数:

步骤动作观察指标判断标准
1固定 -Xms/-Xmx,开启 GC 日志gc.log 中 Young GC 间隔与耗时间隔稳定,无频繁 Full GC
2调整新生代与 Survivor 比例Young GC 后存活对象大小存活对象 < 新生代 10%
3选择 GC 策略并设定停顿目标P99 GC 停顿停顿在 SLA 允许范围内
4下调 -Xss,核对线程总数线程数与栈内存总量无创建失败,无 StackOverflow
5按需检查 JIT 内联与编译级别PrintCompilation / PrintInlining热点方法已编译且内联
调优过程中每次只改一个变量,改完至少观察一个完整的业务周期(例如包含高峰和低谷的 30 分钟),再决定是否保留该参数。多个参数同时调整会让因果关系无法判断。
不要在生产环境直接使用网上流传的“万能参数组合”。堆大小、GC 策略、线程栈都与具体应用的负载特征强相关,脱离负载画像的参数迁移往往带来更差的尾延迟或更高的内存占用。

总结来看,JVM 层调优的核心逻辑是:先用 GC 日志和线程数据建立负载画像,再让堆划分、GC 策略、线程栈和 JIT 配置分别匹配“对象存活特征、停顿目标、并发规模、热点分布”这四个维度。参数只是手段,匹配才是目的。