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 是吞吐优先还是延迟优先。
线程栈:被忽视的内存与并发约束
每个 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 | 热点方法已编译且内联 |
总结来看,JVM 层调优的核心逻辑是:先用 GC 日志和线程数据建立负载画像,再让堆划分、GC 策略、线程栈和 JIT 配置分别匹配“对象存活特征、停顿目标、并发规模、热点分布”这四个维度。参数只是手段,匹配才是目的。