Spring Boot 应用 JVM 性能调优实战:GC 日志分析、堆内存规划与容器环境避坑

做 Java 服务端开发的,基本都逃不过和 JVM 打交道。尤其 Spring Boot 应用跑在 K8s 里,堆怎么给、GC 日志怎么调、容器内存限制怎么匹配,处处是坑。这里分享一次实战调优过程,顺带把一些基础概念串起来。


1. 内存模型和垃圾回收器,得先把底子打好

1.1 JVM 内存区域

JVM 内存分线程私有和线程共享两块。堆是主要战场,对象和数组都在这分配;逻辑上分新生代(Eden、Survivor From/To)和老年代。Metaspace 替代了 JDK8 之前的永久代,存放类元数据,默认用本地内存,建议用 -XX:MaxMetaspaceSize 卡个上限,别让它无限制涨。线程栈默认 1MB(64 位 Linux),可以用 -Xss 调,里面放局部变量、操作数栈、方法调用帧。另外还有直接内存,NIO 和 Buffer 用,受 -XX:MaxDirectMemorySize 限制,默认是等于堆上限的,但 Spring Boot 里 Netty、Kafka 这些框架都会吃直接内存,所以这个默认值往往不够,得显式给。

堆别设太大,不然 Full GC 暂停时间长,堆外内存也被压缩,到头来要么频繁 Full GC,要么直接 OOM,甚至被容器杀。

1.2 垃圾回收器怎么选

JDK9 之后默认 G1,适合 4GB~32GB 的多核大堆场景。G1 把堆分成很多 Region,通过维护优先级列表,优先回收垃圾最多的 Region。它支持设定 -XX:MaxGCPauseMillis 目标暂停时间(默认 200ms),动态调整各代大小。Region 大小可以通过 -XX:G1HeapRegionSize 指定,默认根据堆大小算。新生代占比范围用 G1NewSizePercentG1MaxNewSizePercent 控制,默认 5% 到 60%。

ZGC 是 JDK11 引入的实验性收集器,JDK15 转正,目标是 GC 停顿控制在 10ms 以内。靠着色指针和读屏障实现,非常适合大堆和低延迟场景。代价是 CPU 占用比 G1 高一些,首次分配可能慢半拍。JDK21 的 ZGC 已经支持分代收集了,性能进一步提升。

如果只问吞吐量,Parallel Scavenge + Parallel Old 是最高的,但暂停时间长,不适合在线服务。Spring Boot 应用默认用 G1,绝大多数场景也够用,除非你对延迟有变态要求,否则不用换。


2. 我常用的 JVM 参数模板

2.1 基础模板(4C8G 容器,JDK17)

bash 复制代码
java -jar app.jar \
  -XX:+UseG1GC \
  -Xms4g \
  -Xmx4g \
  -XX:MaxMetaspaceSize=512m \
  -XX:MaxDirectMemorySize=1g \
  -XX:+AlwaysPreTouch \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/opt/logs/app.hprof \
  -XX:+ExitOnOutOfMemoryError \
  -Xlog:gc*:file=/opt/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=50m

解释下几个关键点:

  • -Xms-Xmx 设成一样,避免运行时堆伸缩造成性能抖动。
  • -XX:+AlwaysPreTouch 在 JVM 启动时就把内存申请好并清零,后面访问更快,但启动会慢。
  • -XX:+HeapDumpOnOutOfMemoryError 配合 -XX:+ExitOnOutOfMemoryError,OOM 时先 dump 堆快照,然后进程直接退出,交给容器调度重启。这比半死不活地挂着强。
  • -Xlog 是 JDK9+ 的统一日志语法。注意,JDK9 以后 -XX:+PrintGCDetails-XX:+PrintGCDateStamps 这些老参数已经删了,别再用,会和 -Xlog 冲突。

2.2 堆大小两种玩法

固定堆:进程独占容器,-Xms4g -Xmx4g 直接写死。

自适应:如果容器里混部其他进程,或者内存弹性要求高,用 -XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0。JVM 会自动按容器限制算堆大小,但必须配 -XX:MaxMetaspaceSize-XX:MaxDirectMemorySize 控制整体内存,否则堆外内存照样爆。

2.3 G1 调优参数

bash 复制代码
-XX:MaxGCPauseMillis=100 \
-XX:G1HeapRegionSize=4m \
-XX:G1ReservePercent=15 \
-XX:InitiatingHeapOccupancyPercent=45 \
-XX:+ParallelRefProcEnabled

-XX:InitiatingHeapOccupancyPercent(IHOP)是老年代占比多少时触发并发标记,默认 45%。如果老年代涨得快,可以适当调低,比如 35% 左右,让并发标记提前启动,避免空间满时触发 Full GC。反过来,如果并发标记太频繁,CPU 开销大,可以调高,但调高容易造成 Full GC。没有万能值,得看日志调整。

-XX:G1ReservePercent 默认 10%,是预留堆空间防止晋升失败用的。如果日志里出现转移失败导致 Full GC,可以调到 15-20%。

2.4 如果用 ZGC

bash 复制代码
-XX:+UseZGC \
-XX:MaxRAMPercentage=75 \
-XX:ZAllocationSpikeTolerance=2.0

ZGC 参数简单,主要盯堆大小和并发线程数(-XX:ConcGCThreads)就行。


3. 压测结果怎么看:不是堆越大越好,也不是停顿越小越好

之前做压测,模拟订单交易接口,4 核 8G 容器,JDK17。分别试了 2G G1、4G G1(目标停顿 100ms)、4G G1(目标停顿 200ms)、6G ZGC。数据大致是:

  • 2G G1 的 QPS 大约 1850,平均 GC 暂停 35ms,最大 120ms,TP99 89ms。
  • 4G G1 目标 100ms 时 QPS 上到 2100,平均 20ms,最大 90ms,TP99 73ms。
  • 4G G1 目标 200ms 时 QPS 2300,平均 50ms,最大 240ms,TP99 95ms。
  • 6G ZGC 的 QPS 2050,平均 3ms,最大 8ms,TP99 81ms。

结论很明显:堆太小虽然省内存,但 GC 次数翻倍,老年代 GC 增多,吞吐量反而上不去。2G 到 4G 提升明显,4G 到 6G 就没太大变化了,说明 4G 接近这个场景的甜点区。

G1 的停顿目标别设太激进。目标 100ms 时,G1 为了满足这个目标会频繁做 young GC,吞吐反而下来。假设业务对 TP99 不是特别敏感,目标设 200ms 吞吐更好。但如果你要求 TP99 小于 50ms,那 ZGC 优势就体现出来了------它的暂停时间很稳定,几乎不受堆大小影响。不过 ZGC 的 CPU 开销略高,QPS 比 G1 低一点。

所以我的建议是:普通微服务堆给容器内存的 50%-75%,G1 是安全牌;如果延迟敏感而且堆大于 4G,切 ZGC 试试。


4. GC 日志怎么分析

下面是一段典型的 G1 GC 日志(JDK17):

复制代码
[2025-04-01T10:11:13.502+0000][gc,info] GC(9) Pause Young (Normal) (G1 Evacuation Pause) 1024M->512M(4096M) 25.312ms
[2025-04-01T10:11:17.882+0000][gc,info] GC(11) Pause Full (Allocation Failure) 3021M->2890M(4096M) 86.101ms
[2025-04-01T10:11:18.001+0000][gc,info] GC(13) Pause Young (Concurrent Start) (G1 Humongous Allocation) 2890M->2650M(4096M) 20.018ms
[2025-04-01T10:11:19.001+0000][gc,info] GC(12) Concurrent Mark Cycle 100ms

看几个关键点:

  • Pause Young (Normal) 是普通的 young GC,正常。
  • Pause Full (Allocation Failure) 是 Full GC,原因是分配失败。频繁出现说明堆不够或者晋升率太高。
  • Concurrent Mark Cycle 是并发标记循环,不阻塞业务线程,但时间长反映老年代回收压力大。
  • G1 Humongous Allocation 是大对象分配------超过 Region 一半大小的对象会直接进 Humongous Region。如果日志里经常出现,说明大对象太多了,可能会有碎片和 GC 压力。

线上分析我一般用 GCeasy,在线传日志就能生成报告,重点看吞吐量(业务线程运行占比)、最大 GC 暂停、内存分配速率、对象晋升速率。它会直接给你推荐参数,但别盲从,先在小流量环境验证。


5. 容器环境下 JVM 调优的坑,几乎是必需品

5.1 -Xmx 直接指定大堆,完全忽略容器限制

这个太常见了。K8s Deployment 里 resources.limits.memory: 4Gi,启动参数却写 -Xmx4g。JVM 一共吃的内存是堆 + Metaspace + Thread Stack + Direct Buffer + JVM 内部结构(Code Cache、GC 结构等),4G 堆加上这些轻松超过 4Gi,然后内核 OOM Killer 直接把进程干掉,K8s 报 OOMKilled

虽然新版 JDK 默认容器感知,但你显式写了 -Xmx,JVM 就只认你的 -Xmx,不管 Cgroup 限制。

5.2 用了 MaxRAMPercentage,但堆外没留预算

-XX:MaxRAMPercentage=75.0 表示堆占容器内存的 75%,比如容器限制 4Gi,堆最大 3Gi。但剩下的 1Gi 要覆盖 Metaspace、线程栈、直接内存。如果应用线程多、用了 Netty,这 1Gi 根本不够,最后还是会 OOM。

所以公式是:堆给 75%,堆外预留 25%。同时显式限制 Metaspace 和 DirectMemory。比如:

bash 复制代码
-XX:MaxRAMPercentage=75.0 \
-XX:MaxMetaspaceSize=512m \
-XX:MaxDirectMemorySize=512m \
-Xss512k

这样堆约 3Gi,堆外 1Gi,整体可控。注意 MaxRAMPercentage 读的是 cgroup 的 memory.limit_in_bytes,也就是 limits。如果 request 比 limits 小,JVM 会按 limits 算,所以 limits 和 request 最好设成一样。

5.3 显式调 -Xmn,破坏了 G1 的动态调整

G1 和传统分代 GC 不一样,它自己会根据目标和停顿动态调新生代大小。你非要用 -Xmn 固定,G1 就少了灵活性,停顿目标容易失控。如果非要控制,用 -XX:NewRatio 设定比例。

5.4 JDK 版本和 Cgroup 支持对不上号

老规矩:JDK8u131 之前,JVM 完全不认容器内存,只能用 Native Memory Tracking 手动控制。8u131 到 8u191 之间,要手动加 -XX:+UseCGroupMemoryLimitForHeap。8u191+ 才默认开启容器感知,并且支持 MaxRAMPercentage。如果你还在用旧版 JDK8,赶紧升到 8u191+,或者直接换 JDK17。

5.5 OOMKilled 后 CrashLoopBackOff

应用被 OOMKilled 后,K8s 会自动重启 Pod。但如果堆参数一直不对,就会 CrashLoopBackOff,指数退避重启,服务长时间不可用。正确做法是让 JVM 在 OOM 时先 dump 堆快照,再退出(上面说过的两个参数组合)。K8s 检测到退出就会重启,同时堆快照还能保留下来分析。

5.6 健康检查和自愈

调 JVM 参数不能只盯着 GC,还得配合可观测性和自愈机制。Spring Boot 加 spring-boot-starter-actuator,暴露健康端点,然后在 Deployment 里配置探针:

yaml 复制代码
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5

livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 3

你可以通过自定义 HealthIndicator 检查堆内存使用率,超过 90% 就返回不健康,readinessProbe 摘流量,给 GC 一个喘息时间。如果 GC 暂停时间过长,livenessProbe 超时,K8s 会重启 Pod。

另外,Prometheus + Grafana 告警更靠谱:jvm_gc_pause_seconds_max > 0.5 持续 5 分钟,堆内存使用率超 90%,容器 working set 超 limits 85%------这些指标在 OOMKilled 之前就能发现问题。


6. 实战案例:排查一次 OOMKilled

有个服务高峰期频繁重启,kubectl describe pod 显示:

复制代码
Last State: Terminated
Reason: OOMKilled
Exit Code: 137

排查步骤:

  1. 看启动参数,-Xmx4g,但容器 limits 只有 3Gi。这就已经不对了。
  2. 看 GC 日志,老年代 GC 频繁,堆使用率长期 95%+,说明堆设置太小(其实按容器限制算,可用的堆最多 3G,却配了 4G,不炸才怪)。
  3. 调整参数:把 -Xmx4g 改成 -XX:MaxRAMPercentage=75,加上 -XX:MaxMetaspaceSize=256m-XX:MaxDirectMemorySize=256m,同时把容器 limits 调成 4Gi。
  4. 顺便用 GCeasy 看了一眼日志,发现约 20% 的堆被 byte[] 占用,查代码发现有个接口一次性从数据库查了大量数据,改成流式分页。
  5. 结果:内存占用从接近 4G 降到 2.2G,GC 停顿从 200ms 降到 30ms,OOMKilled 消失。

7. 最后几句

JVM 调优不是一锤子买卖。给几个我平时遵守的底线:

  • 堆内存永远不要超过容器限制的 75%。
  • 开启 HeapDumpOnOutOfMemoryErrorExitOnOutOfMemoryError
  • GC 日志保留 5 个文件、每个 50MB,至少覆盖业务高峰。
  • 定期分析线上 GC 日志,关注暂停、分配速率、晋升率。
  • 容器探针和自愈是配套手段,不能只靠 JVM 参数。

没有银弹,但把基础参数设置对、日志分析熟、容器限制搞清楚,绝大多数问题都不会等到线上才爆发。


🎁 福利时间

如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。

知识库地址:https://farerboy.com/


相关推荐
计算机毕设定制辅导-无忧学长1 小时前
《基于Vue的流浪动物救助中心管理系统的设计与实现》
java·vue.js·spring boot·流浪动物救助中心管理系统
huaweichenai2 小时前
spring boot操作PDF
java·spring boot·pdf
guo_wen_qiang3 小时前
springboot如何自定义一个启动器starter
java·spring boot·intellij-idea
RuoyiOffice4 小时前
2026最推荐基于SpringBoot+Vue3的项目管理经营一体化:从商机、合同、任务与工时走到回款和利润
spring boot·项目管理·vue3·crm·合同管理·ruoyi office·项目经营
paopaokaka_luck4 小时前
考研政治刷题小程序(AI推荐题目、ECharts数据分析、章节练习与专项训练、模拟考试、错题本与收藏夹、学习打卡与目标管理、题库和组卷维护)
前端·javascript·spring boot·spring·数据分析·echarts
Rain的Java大神之路14 小时前
如何快速上传10G文件
java·spring boot·redis·后端·mysql·spring cloud·面试
+VX:Fegn089517 小时前
计算机毕业设计|基于springboot + vue旅游管理系统(源码+数据库+文档)
java·开发语言·vue.js·spring boot·课程设计
需要82618 小时前
MySQL 索引 B+ 树与“查询为什么变慢了
java·spring boot·spring·spring cloud·tomcat