搞 JVM 调优这些年,我最大的感受是:这事儿不是去背参数,而是去理解你的应用到底在做什么,以及 JVM 会用什么方式替你做内存与计算的管理。很多初学者上来就先改-Xmx,觉得堆调大了就不卡了,结果一上线 Full GC 反而更频繁;也有人张口就推荐换 ZGC,仿佛不换新收集器就落伍了。这些我全都经历过,也都在线上环境里交过学费。
这篇文章没有什么高深莫测的底层源码分析,只有我在实际项目中整理出来的一套 JVM 调优思路,以及那些踩过之后才明白的坑。主要围绕调优目标、堆内存结构、GC 收集器选型、日志与工具的使用,以及一套完整案例复盘来展开。如果你正在处理线上频繁 GC、接口响应变慢、或者想为上线前预留合理的 JVM 参数,这篇文章应该能给你一些可靠的参考。
1. 先别急着改参数:调优到底在调什么
1.1 调优不是性能玄学,而是一套目标拆解
很多人把 JVM 调优当成“玄学”,觉得只要能减少 GC 停顿就能让系统变快。但实际我们需要先搞清楚:任何调优行为都是在一组约束条件下做权衡。
我在评估一个应用时,会先列出三个通常互相冲突的目标:
- 延迟 Latency:指应用响应一次请求需要多长时间,通常看 P99、P999 这类指标。
- 吞吐量 Throughput:指单位时间内能处理的请求数量,通常对应 CPU 利用率是否被有效使用。
- 内存占用 Footprint:指 JVM 进程占用的物理内存大小,也包含堆、元空间、线程栈等部分。
一个人如果追求极致的低延迟,就倾向于让 GC 尽量少发生、尽量简短,但这往往要求堆内存分配得够大,导致物理内存占用升高。如果追求高吞吐量,就可以允许偶尔发生较长的 GC 停顿,换取总体上更多的 CPU 时间花在业务线程上。如果追求小内存部署,又要牺牲堆空间来适应机器容量。
所以 JVM 调优的第一步不是“调”,而是定清楚这轮调整要保哪一个指标。我记得有一次做所谓的“优化”,目标写的是“让接口耗时更稳定”,结果一顿操作把堆从 4G 减到 2G,接口耗时确实从偶尔抖动变得规律了,但吞吐量降了不少,业务方反馈更慢,因为本来能并发处理的请求量变少了。这个教训让我明白:没有明确指标的调优,就是盲人摸象。
1.2 什么时候才真正需要深入调整 JVM 参数
我的判断标准比较朴素:如果你的应用在没有调整 JVM 参数的情况下,出现了下面某一种稳定可复现的现象,才值得介入:
- 接口响应时间规律性出现“毛刺”,刚好对应的 GC 日志里能看到长时间停顿。
- 系统并发量上不来,CPU 使用率不高,但线程大量处于 Object.wait() 或 Blocked 状态。
- 容器/物理机内存报警,Java 进程占用比预想高出太多。
- 频繁出现
OutOfMemoryError,或 GC 日志里 Full GC 发生得太密集,比如几分钟一次。 - 刚上线的新服务,默认参数跑出来的 GC 行为与你对业务峰值的预判完全不符。
如果只是应用启动慢一点点,或者某次性能测试有波动,我倾向于先查代码逻辑、外部依赖、连接池配置,而不是动 JVM。JVM 参数是最后一道杠杆,不是第一根救命稻草。我之前遇到过一个现象:接口偶发超时,查了 GC 日志发现完全没有长时间停顿,最后定位到是一个第三方 HTTP 客户端连接池默认 timeout 太长,被下游慢接口拖住了。这种问题调 JVM 再久也只是隔靴搔痒。
注意:把调优目标和业务指标对应起来很重要。例如“让两次 Full GC 的间隔从 10 分钟变成 30 分钟”,这是一个可以验证的调优目标,而“优化 GC 性能”这种话是没法量化的。
2. 堆内存与 GC 收集器的选择逻辑
2.1 堆内存分布:年轻代、老年代与参数之间怎么配
JVM 的堆内存并不是一块平坦的大空地,而是被分为年轻代(Young Generation)和老年代(Old Generation),年轻代里又有 Eden 区和两个 Survivor 区。这个分代设计基于一个经验事实:大多数 Java 对象存活时间很短,刚创建出来没多久就可以被回收了。
我习惯用这样一张简化图来理解整个结构:
- Eden 区:新对象绝大多数在这里分配。
- S0、S1 区:经过一轮 Minor GC 后仍然存活的对象会在两个 Survivor 区之间交替存放。
- Old 区:存活年龄超过阈值,或晋升时放不下的大对象,会跑到老年代。
- 元空间/Metaspace:存放类元数据,JDK 8 之后已经从堆外独立出来。
主要参数我会这样解释:
-Xms和-Xmx:分别代表堆初始大小和最大大小。生产环境最好设置成相同值,避免运行期堆扩容带来的性能抖动。-Xmn:显式指定年轻代大小。如果不设置,JVM 会根据-XX:NewRatio自动分配,默认 NewRatio=2,也就是老年代是年轻代的 2 倍,年轻代约占堆的 1/3。-XX:SurvivorRatio:默认是 8,表示 Eden 区与单个 Survivor 区的比例是 8:1,因此年轻代里真正可用的“新对象分配区”其实不到 80%。-XX:MaxTenuringThreshold:对象年龄阈值,默认 15,但 JVM 会根据 Survivor 区空间动态调整,不需要强求改大或改小。
关于-Xmn的经验,我想多说一句:年轻代并不是越大越好。年轻代变大,Minor GC 的频率会降低,但每次 GC 停顿时间可能变长,因为要扫描的活对象和引用关系更多。而且如果老年代相应被压缩,大对象晋升后很快会撑爆老年代,反而引起 Full GC。我见过有人把 8G 堆里的年轻代直接设成 6G,结果老年代只有 2G,运行一天后 Full GC 比原来还频繁。这类调整需要在分配速率、存活率、GC 频率三者之间做实测验证。
2.2 收集器选型:JDK 版本和业务特征决定合适方案
收集器的选择往往是调优里讨论最激烈的部分。我按实际使用场景给出自己的倾向:
- Parallel Scavenger + Parallel Old:适合对吞吐量要求高、能容忍较短停顿的后台批处理任务或计算密集任务。它在多核机器上能利用多线程并行回收,但在停顿控制上比较粗糙。
- CMS:曾经是低延迟场景的常客,标记-清除阶段并发进行,但存在内存碎片和长时间并发标记带来的 CPU 损耗。JDK 9 后官方开始废弃,JDK 14 中移除,新项目没有必要再选它。
- G1:JDK 9 之后的默认收集器。它把堆划分成多个 Region,可以设置
-XX:MaxGCPauseMillis来引导停顿时间目标。适合大部分在线业务系统,尤其是堆内存较大且内部对象分配速率不均匀的场景。 - ZGC / Shenandoah:主打超低停顿,目标是让 GC 停顿时间控制在几毫秒甚至 1 毫秒以内。但它们的代价是更高的 CPU 开销和内存占用,而且 JDK 版本要求高。并非所有业务都适合无脑上 ZGC。
我个人的选型方法是先看 JDK 版本。如果你用的是 JDK 8,且不打算升级,选 G1 也是可行的,但需要显式加上-XX:+UseG1GC并做完整验证。如果你在使用 JDK 17 或更高版本,默认 G1 已经足够稳定,只有在延迟指标非常苛刻时才需要评估 ZGC。
这里有一个误区必须澄清:G1 的-XX:MaxGCPauseMillis并不是一个硬性保证,它只是给垃圾回收器一个停顿目标,G1 会根据历史数据持续调整 Region 划分和回收策略,去“尽量”达到。如果你把这个值设得极小,比如 1ms,结果是 G1 会频繁发起 GC 来迎合目标,反而导致 CPU 占用升高和吞吐下降。我通常设置在 100~300ms 之间,再根据实际 GC 日志调整。
3. 拿到真实数据:日志与工具的正确打开方式
3.1 常用排查工具怎么搭配使用
调优不需要装一大堆重型监控平台,我平时最快上手的就是 JDK 自带工具和一份干净的 GC 日志。
jps:查看当前机器上有哪些 Java 进程,拿到进程号。jstat -gc <pid> 1000:每秒输出一次 GC 情况,包括各区域使用量、GC 次数和耗时。jmap -heap <pid>:打印堆配置和当前堆使用概览。生产环境慎用,因为有时会造成停顿。jstack <pid>:看线程快照,排查死锁、线程阻塞点。jcmd <pid> GC.heap_info:相对 jmap 更轻量一些,可用于查看堆信息。- 如果条件允许,用 VisualVM 或 JMC 连接本地/测试环境观察曲线,不要在线上随意 attach 图形化界面。
我的组合拳是这样的:发现接口变慢之后,先在线上用jstat连着看几分钟,确认 GC 频率和耗时;再配合jstack看是不是线程卡在锁上;最后结合 GC 日志判断到底是内存分配速率过高、老年代空间不够,还是业务代码里存在不合理的对象持有链。
注意:线上使用
jmap有可能触发一次 Full GC,某些环境下这是不可接受的。建议先用jstat和 GC 日志做预估,再决定要不要用jmap -histo去看对象分布。
3.2 读懂 GC 日志里的关键信息
开启 GC 日志很简单,但不同 JDK 版本格式差异不小。JDK 8 里一般用:
-Xloggc:/path/to/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStampsJDK 11+ 统一用统一日志方式:
-Xlog:gc*:file=/path/to/gc.log:time,uptime,level,tags一份典型的 G1 日志里,我们可以找到这些关键信息:
GC Pause (G1 Evacuation Pause):年轻代回收停顿,标记为 young。GC Pause (G1 Mixed):混合回收,既回收年轻代也回收老年代的 Region。GC Pause (G1 Humongous Allocation):大对象分配触发的停顿。To-space exhausted / Evacuation Failure:说明年轻代存活对象太多,Survivor 区放不下,有一部分对象被迫提前进入老年代,这往往是老年代压力增加的信号。
我一般会重点统计两部分:单次 GC 的平均停顿时间,以及 Full GC 相关事件的频率。如果 Full GC 每小时超过 1 次,就已经值得警惕,最好是能透过 GC 日志反推业务代码哪里在制造大量长期存活对象。
4. 一次完整调优案例的复盘
4.1 从现象到参数调整的完整路径
用一个简化但真实的案例来展示我的调优路径。这是某后端订单查询服务,部署在 4 核 8G 的一台虚拟机上,JDK 8,默认 ParallelGC,-Xmx4g -Xms4g。业务特点是晚间高峰时查询量暴增,单次接口会组装较多对象,但大多数都是短命对象。
上线的第一周就在高峰期出现如下现象:
- 接口 P99 从 30ms 上升到 400ms 左右。
jstat显示 Minor GC 每秒发生 5 次以上,每次耗时接近 100ms。- GC 日志显示每 5 分钟左右出现一次 Full GC,耗时在 1s 到 3s。
- 堆内存使用曲线呈现锯齿状,但老年代使用率缓慢爬升,几乎不下降。
当时的分析并不复杂:默认 ParallelGC 在面对这个分配速率时,年轻代回收效率还可以,但老年代已经因为存活对象堆积而逼近阈值,每隔一段时间被迫做一次全堆回收。这个时候最重要的一点是:不要急着去调对象晋升年龄,而要先判断那些进入老年代的对象是不是本该短期存活的对象。
我做的第一步是查看jmap -histo,发现内存里大量byte[],进一步定位到代码中有一个基于时间范围的批量查询,把多批集合数据统一拼接成一个大 JSON 字符串缓存到了静态 Map 里。这个缓存的 key 只按小时维度变化,但 value 对象很大,而且没有清理机制,高峰期很容易把老年代塞满。
这其实是一个非常典型的非参数问题:所谓 Full GC 频繁,根因是业务代码持有大对象集合。我把代码改成按分钟维度分片缓存,并加上容量上限,老年代压力立刻下降不少。这里我想强调,JVM 调优很多时候不是直接调 JVM,而是用 JVM 的数据反推代码问题。参数调整只是最后一步的微调手段。
4.2 参数调整过程与对比验证
在完成代码层面的对象持有优化之后,GC 压力有明显改善,但仍然没有到达最佳状态。当时的请求量下,Minor GC 的频率在高峰期依然偏高,单次停顿还能压到 50ms 左右。这时我才开始调整收集器和区域比例。
第一步:把收集器从 ParallelGC 换成 G1。不是因为它一定比 Parallel 快,而是因为我需要更可控的停顿目标,同时也希望老年代回收能更平滑。
java -Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags -jar app.jar第二步:观察了三天日志后,发现年轻代 GC 依然偏频繁,但单次停顿已经稳定在 100ms 以内。此时我开始尝试调整-XX:MaxGCPauseMillis,从 200 降到 150,再观察是否出现 Region 回收失衡。
第三步:检查老年代空间。通过jcmd <pid> GC.heap_info确认老年代使用曲线平稳,没有每小时的周期尖峰,于是不再继续动-Xmn。因为对于 G1 来说,直接指定年轻代大小会限制它对停顿目标的动态调整能力,得不偿失。
我记录了一组对比数据:
- 调整前:Full GC 每 5 分钟一次,单次 1~3s,P99 约 400ms。
- 代码优化后:Full GC 每小时 2 次,单次 300ms,P99 约 120ms。
- 换成 G1 并设置停顿目标后:Full GC 基本消失(准确说是 G1 会做 mixed 回收),P99 稳定在 50~70ms。
这三个阶段的对比让我清楚看到:代码层面释放的是内存压力,收集器与参数层面解决的是停顿分配策略。把两者混为一谈,只会让问题更难排查。
4.3 这次调优里沉淀出来的经验
这个案例让我最深刻的一点是:先搞清楚哪些对象存活,再决定怎么调参数。如果你在第一步就盲目把-Xmx调大,比如从 4G 调到 6G,那个静态缓存 Map 会继续吞噬内存,得到的结果只会是:进程内存上去了,Full GC 间隔稍微变长,但问题依然存在,甚至因为堆变大导致单次 GC 停顿更久。
我后来复盘时给自己列了三条纪律:
- 线上调参之前,必须有至少一整天的高峰数据作为基线。
- 一次只调整一个变量,并且观察时间不少于一天。如果没有明显向好的趋势,就回滚再试下一次。
- 凡是能用代码解决的问题,不要用 JVM 参数去掩盖。比如清掉长期缓存、避免过大的临时集合、优化 SQL 返回字段,这些调整往往比 JVM 参数更有效。
5. 常见问题与实操避坑清单
5.1 我整理了一张高频问题排查对照表
日常工作中,有一些问题反复出现,我把它们整理成了一张速查表,排查顺序基本也是按照这个表格从上往下来:
| 现象 | 可能原因 | 优先检查项 |
|---|---|---|
| 接口偶发超时,GC 日志有长停顿 | 老年代空间不足 / 收集器配置不当 | 老年代使用率、Full GC 频率 |
| Minor GC 频繁,但每次时间短 | 年轻代偏小或对象分配速率过高 | Survivor 区容量、Eden 使用率峰值 |
| Full GC 后内存仍持续上涨 | 集合对象被长期持有 / 缓存未清理 | jmap -histo对象分布、自查静态集合 |
| 换 G1 后 CPU 飙高 | GC 线程占用过多 / 停顿目标设置过小 | -XX:MaxGCPauseMillis是否过于激进 |
| 日志出现 To-space exhausted | 存活对象超过 Survivor 承载能力 | 代码中存在批量创建常驻对象的逻辑 |
| 进程内存超过 Xmx | 堆外内存 / 元空间 / 线程栈膨胀 | jcmd查看 metaspace,检查线程数量 |
这张表不是万能的,但它帮我建立了一个稳定的排查起点,尤其是团队里有新同学时,照着表逐项排除可以省去很多 debug 时间。
5.2 我踩过的一些坑和给新手的建议
第一个坑:在 JDK 8 上盲开-XX:+UseG1GC却不加任何停顿目标。默认 G1 的目标停顿是 200ms,但如果你不在 JDK 8 上主动设置,某些版本会有额外的 Full GC 开销,反而比 ParallelGC 差。升级到 JDK 11 之后 G1 才变得更加成熟。
第二个坑:把-Xms和-Xmx设置成巨大值,但忽视了容器或虚拟机的物理内存限制。我们曾有一台机器内存 8G,配了-Xmx6g,加上元空间、线程栈、堆外缓冲区,进程实际占用接近 7.5G。机器内存一紧张,OS 开始交换,JVM 反而出现诡异的卡顿。后来规定 Xmx 不超过物理内存的 50%~60%,留足堆外和系统缓冲。
第三个坑:频繁修改参数却不看后续指标。有些人调完参数第二天发现接口快了,就以为大功告成,结果第三周业务量上涨又被打回原形。调优是一个持续跟踪的过程,至少要观察完整的一个业务周期,比如一周,才能确认参数生效。
第四个坑:误解-XX:MaxTenuringThreshold的作用。它只是一个上限,JVM 会根据 Survivor 空间的使用动态调整年龄阈值,强行设成 1 或设成 15 并不会直接给你一个好结果。除非你能通过真实数据证明对象存活年龄分布有问题,否则别动它。
第五个建议:新项目建议直接 JDK 17 或更高版本,先跑默认 G1,等遇到真实瓶颈再来调。JDK 8 的 CMS 已经是过去式,不要再为了“熟悉”而选择旧技术栈。JDK 17 之后默认的参数组合对大多数 Web 服务已经相当友好,所谓“不调参不能上线”是完全没有必要的焦虑。
6. 最后还想分享的一点
回过头看,JVM 调优最核心的能力不是记住参数,而是形成一套“用数据说话”的排查逻辑。你在社区里看到的各种参数模板、启动脚本、调优案例,都只是别人在特定业务场景下的快照,直接抄过来不一定适用。真正值得学习的是他们如何观察现象、如何提取指标、如何验证假设。
我在实际项目中一直保留一个习惯:每次调优前先把 GC 日志留档,调整后继续留档,然后定期翻一下对比数据。哪怕是一次失败的调整,也会成为下一次判断的依据。JVM 并不神秘,它只是内存管理和执行模型足够复杂,让我们必须比平时写代码更严谨地对待它。希望这篇整理出来的心得,能让你在下次面对 GC 毛刺和频繁 Full GC 时,多几分从容,少几步弯路。