在我二十余年的 JVM 调优生涯中,最难忘的不是背下多少参数,而是第一次靠调整 GC 参数,把一个因内存泄漏濒临崩溃的电商订单系统拉回稳定 ------ 那套系统因静态集合未清理订单对象,Minor GC 从每分钟 1 次飙升到每秒 3 次,Full GC 每小时触发一次,每次 STW 2 秒,订单超时率突破 10%。我用 jstat 监控 GC 趋势,用 jprofiler 定位泄漏点,调整 - Xms/-Xmx 并切换 G1GC 后,Minor GC 降到每 5 分钟 1 次,Full GC 几乎消失,超时率归零。这次经历让我明白:JVM 性能优化不是 "调大内存就完事",而是读懂 GC 的 "语言",让内存分配和回收贴合业务的对象生命周期 ------ 就像给 JVM 调整 "呼吸节奏",既不憋闷(内存不足),也不频繁喘气(GC 频繁)。

一、复现内存泄漏
性能优化的起点,是复现问题 ------ 我常让新人先写一段内存泄漏的代码,理解 "无用对象不被回收" 的本质,而非上来就调参数。这段代码模拟电商系统的订单缓存泄漏,核心是静态 List 持有订单对象引用,导致 GC 无法回收:
java
// 模拟内存泄漏的订单缓存代码
public class OrderMemoryLeak {
// 静态集合:持有订单引用,GC无法回收
private static List<Order> orderCache = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
// 持续创建订单对象并加入缓存,不清理
while (true) {
Order order = new Order();
order.setOrderId(UUID.randomUUID().toString());
order.setAmount(new Random().nextDouble() * 1000);
orderCache.add(order);
// 模拟业务延迟,让对象持续堆积
Thread.sleep(10);
// 伪清理:仅判断数量,未真正移除引用
if (orderCache.size() > 10000) {
// 错误:仅清空size,未释放引用(实际应调用clear())
orderCache = new ArrayList<>();
// 真实场景中,若此处代码遗漏,缓存会无限膨胀
}
}
}
static class Order {
private String orderId;
private double amount;
// 省略get/set
}
}
这段代码的 "坑" 藏在伪清理逻辑:看似限制了缓存大小,但若代码遗漏orderCache = new ArrayList<>(),静态集合会无限持有 Order 对象引用 ------ 哪怕订单已处理完成,GC 也无法回收,最终导致老年代内存占比持续上升,触发频繁 Full GC。我曾在生产环境见过几乎一样的代码:开发人员想 "优化" 缓存清理逻辑,却注释掉了 clear (),上线 3 小时就触发 OOM。
二、用 jstat 和 jprofiler 读懂 GC 的 "求救信号"
内存泄漏不会凭空出现,JVM 会通过 GC 指标发出 "求救信号",而我们要做的就是用工具捕捉这些信号。
1. jstat
jstat 是 JVM 自带的轻量监控工具,无需额外安装,核心是看 GC 的频率、耗时和内存占用。我常用的命令是:
bash
# 每1秒输出一次进程ID为1234的GC统计,共输出10次
jstat -gc 1234 1000 10
输出结果中,我重点关注这几个指标(针对内存泄漏场景):
S0C/S1C:Survivor 区大小,若持续被占满,说明年轻代对象无法回收,频繁进入老年代;OU:老年代已用内存,若持续上升(即使触发 Full GC 也不下降),就是内存泄漏的核心特征;YGC/YGCT:Minor GC 次数 / 总耗时,若 YGC 次数飙升,说明年轻代内存不足;FGC/FGCT:Full GC 次数 / 总耗时,若 FGC 频繁,说明老年代已无可用内存。
针对上面的泄漏代码,jstat 输出会清晰显示:OU 从初始的 100MB 持续涨到 900MB(-Xmx 设为 1G),YGC 从每分钟 1 次涨到每秒 3 次,FGC 每 10 分钟触发一次,且每次 FGC 后 OU 仅下降 10MB------ 这就是典型的内存泄漏:无用对象无法回收,老年代被 "撑满"。
2. jprofiler
jstat 能发现 "有泄漏",但无法定位 "谁在泄漏"------ 这时候需要 jprofiler 这类性能分析工具,核心是抓取内存快照(Heap Dump),分析对象的引用链。
我排查泄漏的步骤很固定:
- 启动 jprofiler,连接到泄漏进程;
- 抓取堆快照,筛选 "Order" 类的实例数量 ------ 发现有 10 万个 Order 对象存活;
- 查看 Order 对象的引用链:发现所有对象都被
OrderMemoryLeak.orderCache(静态集合)持有; - 验证引用链:确认静态集合未清理,这就是泄漏根源。
新手常犯的错是 "只看对象数量,不看引用链"------ 比如看到大量 Order 对象,就以为是业务创建过多,实则是引用未释放。我曾遇到一个案例:开发人员看到堆中有大量 String 对象,却忽略了这些 String 被静态配置类的 Map 持有,最终定位到配置类未清理过期配置,导致 String 泄漏。
三、调参
定位泄漏后,先修复代码(比如给 orderCache 加过期清理),再调整 JVM 参数 ------ 调参不是 "盲目改数值",而是基于 GC 原理,让堆结构和收集器贴合业务场景。我以 "电商订单系统" 为例,拆解调参逻辑:
1. 基础堆参数:
-Xms:初始堆大小,-Xmx:最大堆大小;- 核心原则:将两者设为相同值(如
-Xms4G -Xmx4G),避免 JVM 运行时动态调整堆大小 ------ 堆伸缩会触发额外的 GC,增加 STW 时间。
早年我曾将 - Xms 设为 1G、-Xmx 设为 4G,结果系统运行时堆从 1G 扩容到 4G,每次扩容都触发 Full GC,接口延迟飙升;改为相同值后,扩容 GC 完全消失。
2. 收集器选择:
电商系统的核心需求是 "低延迟",而默认的 Parallel GC(吞吐量优先)在老年代回收时 STW 时间长 ------G1GC(Garbage-First)是更优选择,它将堆分为多个 Region,优先回收垃圾多的 Region,能控制 STW 停顿时间。
核心参数:
java
# 使用G1GC,设置最大停顿时间为200ms(电商系统可接受的延迟)
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
# 设置年轻代占比(默认5%,电商场景调至20%)
-XX:G1NewSizePercent=20 -XX:G1MaxNewSizePercent=50
切换 G1GC 的核心逻辑:G1 的 "Region 化管理" 能避免 Full GC(用 Mixed GC 替代),且通过MaxGCPauseMillis控制停顿时间,贴合电商订单 "低延迟" 的需求。我曾将一个订单系统从 Parallel GC 切换到 G1GC,Full GC 从每小时 1 次降到每天 1 次,单次 STW 从 2 秒降到 200ms 以内。
3. 辅助参数
java
# 设置Region大小(根据堆大小调整,4G堆设为16M)
-XX:G1HeapRegionSize=16M
# 启用并发标记起始阈值(老年代占比达到45%时启动并发标记)
-XX:InitiatingHeapOccupancyPercent=45
这些参数的调整依据是 G1 的回收原理:Region 大小适配对象大小(避免大对象跨 Region),并发标记起始阈值提前启动回收,避免老年代占比过高触发 Full GC。
四、验证
调参不是 "调完就完",必须用数据验证效果,我会对比调参前后的核心指标:
| 指标 | 调参前(内存泄漏 + Parallel GC) | 调参后(修复泄漏 + G1GC) |
|---|---|---|
| Minor GC 频率 | 每秒 3 次 | 每 5 分钟 1 次 |
| Full GC 频率 | 每小时 1 次 | 每天 1 次 |
| 单次 STW 时间 | 2 秒(Full GC) | 200ms(Mixed GC) |
| 老年代内存占比 | 90%(持续上升) | 40%(稳定) |
| 订单接口平均响应时间 | 500ms | 50ms |
验证的核心是 "指标闭环":不仅看 GC 指标,还要看业务指标(接口响应时间、超时率)------ 我曾见过调参后 GC 指标变好,但业务响应时间反而上升,最终定位到 G1GC 的 Region 大小设置过大,导致大对象分配失败,这就是 "只看技术指标,忽略业务效果" 的坑。
五、GC 调优
二十余年的调优经历,让我总结出三条核心心法:
1. 先修泄漏,再调参数
新手常犯的错是 "没修泄漏就调大堆内存"------ 比如把 - Xmx 从 1G 调到 8G,看似缓解了 OOM,但泄漏依然存在,只是崩溃时间从 3 小时推迟到 24 小时。我曾接手一个系统,堆内存从 4G 调到 16G,问题却越来越严重,最终定位到静态集合泄漏,修复后堆内存调回 4G 反而更稳定。
2. 读懂 GC 日志,而非死记参数
调参的核心是 "读懂 GC 的语言":比如 G1GC 日志中出现 "to-space exhausted",说明 Survivor 区不足,需调大年轻代占比;出现 "concurrent mode failure",说明并发标记未完成,老年代已占满,需调低并发标记起始阈值。我至今能从 GC 日志的一行输出,判断出问题所在 ------ 这比背 100 个参数更重要。
3. 适配业务,而非追求 "最优参数"
没有 "万能参数":电商系统追求低延迟,适合 G1GC;大数据批处理系统追求吞吐量,适合 Parallel GC;低内存嵌入式系统,适合 Serial GC。我曾给一个批处理系统强行用 G1GC,结果吞吐量下降 30%,改回 Parallel GC 后恢复正常 ------ 调优的本质是让 JVM 适配业务,而非让业务适配 JVM。
最后小结
JVM 性能优化不是 "玄学",而是 "基于原理的实战":先通过 jstat 发现 GC 异常,用 jprofiler 定位泄漏点,修复后基于 GC 原理调整参数(堆大小、收集器),最后用业务指标验证效果。调参的 "术" 是记住参数含义,调优的 "道" 是读懂 GC 的逻辑,让内存分配和回收贴合业务的对象生命周期。当你能从 GC 日志中读出 JVM 的 "呼吸节奏",能从内存快照中找到泄漏的引用链,能根据业务场景选择合适的收集器 ------ 你就不再是 "只会改参数的新手",而是能让 JVM 在高并发下稳定 "呼吸" 的调优高手。