JVM 线上故障排查与调优实战:从 OOM 到 GC 优化的完整指南
前言
线上服务出现 OOM(OutOfMemoryError)是 Java 开发者最不愿面对却迟早会遇到的问题。本文通过一个真实的电商订单系统故障案例,完整记录从现象观察 → 日志分析 → 根因定位 → 调优方案 → 效果验证的全过程,帮助大家在遇到类似问题时能够有条不紊地处理。
一、故障现象
某日收到告警:订单服务响应时间从正常的 50ms 飙升至 30s+,大量请求超时,最终触发熔断。
监控表现:
- CPU 使用率:80% → 99%(持续高位)
- 内存使用:8GB / 16GB(持续增长,GC 后不回落)
- Full GC 频率:每 30s 一次(正常应为几小时一次)
- 线程数:1200+(正常 200 左右)
二、初步诊断:快速定位方向
2.1 确认问题类型
先用 jps 确认进程 PID:
jps -lvm
# 输出: 28472 com.example.order.OrderApplication --server.port=8080
# 查看进程启动参数
jinfo 28472 | grep -i "gc\|heap\|cms\|g1"
2.2 查看 GC 状态(实时)
# 实时查看 GC 情况,每1秒刷新
jstat -gcutil 28472 1000
# 输出示例:
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 77.22 0.00 100.00 99.45 88.32 84.67 4125 123.456 234 567.890 691.346
关键发现:Old 区(O)使用率 99.45%,元数据区(M)88.32%,说明大量对象进入了老年代,且 Full GC 几乎无法回收。
三、深入分析:堆内存快照
3.1 导出堆转储文件
# 生成 heap dump(推荐用 jmap,线上慎用 gcore)
jmap -dump:format=b,file=/tmp/heap.hprof 28472
# 如果进程可能卡顿,用 -F 强制
jmap -F -dump:format=b,file=/tmp/heap.hprof 28472
3.2 分析堆内存(MAT 工具)
将 heap.hprof 拉到本地,用 Eclipse MAT 打开,重点关注:
Dominator Tree(支配树):按对象 retained heap 排序,找最大占用者。
本次案例 Dominator Tree 结果:
- HashMap$Node[] - 45,231 对象, Retained Heap: 892 MB
- OrderContext - 12,458 对象, Retained Heap: 634 MB
- String - 890,123 对象, Retained Heap: 142 MB
3.3 Histogram 对比分析
# 导出 Histogram(按类名排序)
jmap -histo 28472 | sort -k 3 -rn | head -30
四、根因定位:从代码到机制
4.1 发现问题代码
通过 MAT 的 "Path to GC Roots" 功能,追踪 OrderContext 的引用链,发现:
问题代码片段 1:静态 Map 缓存无限膨胀
@Service
public class OrderService {
// 危险:大对象作为 Map 的 Value,且没有清理机制
private static final Map<String, OrderContext> ORDER_CACHE = new HashMap<>();
public void createOrder(OrderDTO dto) {
OrderContext ctx = buildContext(dto);
// 永远只 put,从不 remove
ORDER_CACHE.put(dto.getOrderId(), ctx);
processOrder(ctx);
}
// 问题:订单完成后没有清理 cache
// 结果:ORDER_CACHE 持续膨胀 -> 老年代占满 -> Full GC -> OOM
}
问题代码片段 2:ThreadLocal 未清理
public class OrderContextHolder {
private static final ThreadLocal<OrderContext> CONTEXT = new ThreadLocal<>();
public static void set(OrderContext ctx) {
CONTEXT.set(ctx);
}
// 忘记 remove:线程池复用 -> ThreadLocal 内存泄漏
public static void clear() {
// 被注释掉的 remove,导致内存泄漏
// CONTEXT.remove();
}
}
4.2 机制层面解释
为什么 HashMap.Entry 占用这么大?
OrderContext 里嵌套了大量数据:
public class OrderContext {
private String orderId;
private UserDTO user; // 内含详细用户信息
private List<OrderItemDTO> items; // 每单平均 5-10 个商品
private PaymentInfo payment; // 支付信息
private LogisticsInfo logistics; // 物流信息
private Map<String, Object> ext; // 扩展数据
}
一个 OrderContext 约 50KB,45,000 个约 2.25GB!
五、调优方案
5.1 短期修复(止血)
@Service
public class OrderService {
// 改造1:使用 LinkedHashMap LRU 或定期清理
private static final Map<String, OrderContext> ORDER_CACHE =
Collections.synchronizedMap(new LinkedHashMap<>(1024, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, OrderContext> eldest) {
return size() > 500; // 最多缓存 500 条,自动淘汰最老的
}
});
// 改造2:增加定时清理任务
@Scheduled(fixedRate = 300000) // 每5分钟
public void cleanupCache() {
int before = ORDER_CACHE.size();
long expireTime = System.currentTimeMillis() - 30 * 60 * 1000;
ORDER_CACHE.entrySet().removeIf(e -> e.getValue().getCreateTime() < expireTime);
log.info("Cache cleanup: {} -> {}", before, ORDER_CACHE.size());
}
}
// 改造3:ThreadLocal 正确清理
public class OrderContextHolder {
public static void clear() {
CONTEXT.remove(); // 必须调用 remove()
}
// 建议在 Filter 中统一清理
@Component
public class ContextCleanupFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws Exception {
try {
chain.doFilter(request, response);
} finally {
OrderContextHolder.clear();
}
}
}
}
5.2 JVM 参数调优
java -jar order-service.jar \
-Xms4g -Xmx4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/oom.hprof \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-Xloggc:/var/log/gc.log \
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=512m
G1GC vs CMS 对比(16GB 堆场景):
| 特性 | CMS | G1GC |
|---|---|---|
| 停顿模型 | STW | 可预期停顿 |
| 大内存表现 | 停顿不可控 | MaxGCPauseMillis=200 可控 |
| 内存碎片 | 长期碎片多 | 相对更优 |
| 适用场景 | < 4GB | 大于等于 4GB |
5.3 日志分析验证
调优后 GC 日志对比:
调优前(CMS):
2025-03-01T14:30:25.123: [GC ... CMS: 1536M->1024M(2048M), 5.234s]
2025-03-01T14:30:55.678: [Full GC ... CMS: 2048M->2047M(2048M), 8.901s]
调优后(G1GC):
2025-03-01T15:00:00.000: [GC pause (G1 Evacuation Pause) 12.345: [Eden: 512M->512M(1024M), 0.089s]
2025-03-01T15:30:00.000: [GC pause (G1 Evacuation Pause) 17.890: [Eden: 512M->256M(1024M), 0.034s]
5.4 最终效果
| 指标 | 调优前 | 调优后 |
|---|---|---|
| 平均响应时间 | 2000ms+ | 45ms |
| Full GC 频率 | 每30秒 | 约6小时1次 |
| 老年代使用率 | 99% | 稳定 60% |
| CPU 使用率 | 99% | 35% |
| 内存占用 | 持续增长 | 稳定 |
六、总结:排查思路梳理
线上 OOM 排查流程:
- 监控告警 -> CPU/内存/GC 异常
- jps + jinfo -> 确认进程 & JVM 参数
- jstat -gcutil -> 快速定位哪个区满了
- jmap -dump -> 导出堆快照
- MAT / jhat 分析 -> 找大对象 & GC Root
- 定位代码 -> 修 Bug
- JVM 调参 -> 治标
- 灰度上线 + 监控验证
核心经验总结:
- 永远不要用静态 HashMap 做无限缓存 — 使用 LRU Cache(Guava / Caffeine)或限制大小
- ThreadLocal 用完必须 remove — 线程池复用场景下必漏
- 大对象是 GC 的天敌 — OrderContext 这种 50KB 的对象,一次性创建 5 万个约 2.5GB
- JVM 参数因场景而异 — G1GC 适合大内存(4GB+),CMS 适合小内存
- OOM 时自动 dump — -XX:+HeapDumpOnOutOfMemoryError 让你事后有据可查
- GC 日志是最好的诊断工具 — 平时就要开启,别等出问题了才想起来
推荐工具:Eclipse MAT(堆分析)、GCeasy(GC 日志分析)、Arthas(在线诊断)、async-profiler(CPU 火焰图)
希望这篇实战记录能帮你在下次遇到 OOM 时,快速定位、精准修复。

浙公网安备 33010602011771号