
搞 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自动分配默认 NewRatio2也就是老年代是年轻代的 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 中移除新项目没有必要再选它。G1JDK 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:MaxGCPauseMillis200 -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~3sP99 约 400ms。代码优化后Full GC 每小时 2 次单次 300msP99 约 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 时多几分从容少几步弯路。