一、故障现场表现

线上运维监控推送告警,订单服务接口 RT 持续飙升,TPS 断崖式下跌,用户反馈页面加载超时、请求直接丢包。登录服务器查看机器指标,CPU 稳定跑满 95% 以上,打开监控面板看到核心指标:每分钟 Full GC 次数 8~12 次,单次 Full GC STW 停顿 300ms~800ms,每次回收后老年代内存下降幅度不足 10%,很快再次被占满,循环触发 Full GC,整个应用近乎卡死。

常规业务重启只能临时缓解 1~2 小时,随后故障复现,单纯加大堆内存治标不治本,必须走完完整排查链路定位根因。

二、前置操作:先稳住现场,留存原始数据

线上故障优先保留现场再处理,直接重启会丢失全部排查依据,分两步操作:

2.1 锁定目标 Java 进程 PID

bash

运行

jps -l

输出示例:

plaintext

28741 com.biz.order.OrderApplication
13690 jps

28741 即为业务进程 PID,全程记录该编号,后续所有工具命令均以此为参数。

2.2 同步抓取三类核心快照(按优先级执行)

Full GC 发生时伴随长时间 STW,批量执行 dump 会加剧服务卡顿,分开分次抓取,优先轻量工具,最后导出堆快照。

  1. 线程栈快照(影响最小)

bash

运行

jstack -l 28741 > thread_dump.log
  1. 内存对象统计快照(快速看对象占用)

bash

运行

jmap -histo 28741 > histo.txt
  1. 堆转储文件(重量级,STW 明显,低峰执行) 只导出当前存活对象,减小文件体积、缩短停顿时间:

bash

运行

jmap -dump:live,format=b,file=heap.hprof 28741

生产规范:所有服务强制配置 GC 日志自动打印、OOM 自动 dump 参数,避免故障时临时修改配置重启丢数据

shell

# JVM 启动参数标配
-Xloggc:/opt/logs/gc.log
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/opt/logs/dump/

三、第一步:jstat 实时观测 GC 趋势,判断故障大类

用 jstat 持续打印 GC 运行数据,快速区分两种高频 Full GC 场景:内存泄漏 / 分配超大对象。

bash

运行

# 每500毫秒打印一次GC信息,持续输出
jstat -gc 28741 500

输出关键字段解读:

  • S0C/S1C:两个 Survivor 区总容量
  • EC:Eden 区容量
  • OC:老年代容量
  • M:元空间容量
  • YGC/YGCT:年轻代 GC 次数、总耗时
  • FGC/FGCT:Full GC 次数、总耗时

两种典型故障特征

  1. 内存泄漏(本次故障对应场景) 老年代 OC 占用持续稳步上涨,每次 Full GC 后 Old 区内存下降极少,几十分钟内填满老年代,FGC 频率持续走高。
  2. 大对象直接进老年代 YGC 次数正常,但每次业务峰值瞬间 OC 直接拉满,单次请求批量创建超大集合,绕过 Survivor 直接分配到老年代,单次触发 Full GC。

本次观测结果:每 3 分钟 Old 区上涨 1.2G,Full GC 回收仅释放 200M 左右,确定存在对象长期无法回收的内存泄漏。

四、第二步:GC 日志文本分析,确认 Full GC 触发源

打开 gc.log,检索关键词 Full GC,逐条查看日志完整片段,标准日志片段节选:

plaintext

2026-06-17T14:22:36.123+0800: 12456.234: [Full GC (Ergonomics) [PSYoungGen: 102400K->0K(114688K)] [ParOldGen: 3072000K->2864320K(3145728K)] 3174400K->2864320K(3260416K), 0.687234 secs] [Metaspace: 45678K->45678K(108032K)]

关键字段拆解:

  1. Full GC (Ergonomics):JVM 自适应扩容机制触发,老年代空间不足,是泄漏最常见标记;
  2. ParOldGen 回收前后对比:3072000K -> 2864320K,回收量极低,大量对象持有有效引用;
  3. Metaspace 数值无变化,排除类加载器泄漏。

额外检索 Metadata GC Threshold,若大量出现则代表元空间溢出,需单独排查动态加载类、热部署、动态脚本等逻辑。

五、第三步:jstack 线程栈定位运行中的可疑逻辑

Full GC 频繁时,业务线程大概率在循环批量处理数据、缓存写入、定时任务逻辑,通过 thread_dump.log 检索关键词定位代码入口:

  1. 检索 RUNNABLE 下循环、批量查询、本地缓存操作;
  2. 检索 TimerScheduledExecutor、XXLJob 定时任务线程;
  3. 检索 ThreadLocal,判断是否存在未清理的线程本地变量;
  4. 检索 GC task thread,观察 GC 线程是否长时间阻塞。

本次线程栈高频出现同一定时任务线程:

plaintext

"syncOrderDataJob" #47 daemon prio=5 os_prio=0 tid=0x00007f8b34145800 nid=0x724 runnable [0x00007f8b28ef8000]
   java.lang.Thread.State: RUNNABLE
        at com.biz.order.job.OrderSyncJob.syncData(OrderSyncJob.java:62)
        at com.xxl.job.core.handler.impl.MethodJobHandler.execute(MethodJobHandler.java:31)

锁定定时任务同步订单数据逻辑,结合 jstat 内存上涨时间,和任务执行周期完全匹配,缩小排查范围。

六、第四步:MAT 堆快照深度分析,定位泄漏对象与引用链

将 heap.hprof 文件导入 Eclipse MAT,按固定顺序分析,避开无效指标,直接抓泄漏疑点。

6.1 Leak Suspects 泄漏嫌疑报告(优先查看)

MAT 自动生成泄漏嫌疑列表,本次报告第一条:

plaintext

Problem Suspect 1
One instance of "java.util.HashMap" loaded by "org.springframework.boot.loader.LaunchedURLClassLoader @ 0x700000000" occupies 2.6GB (82.36%) of the heap.
The memory is accumulated in one instance of "java.util.HashMap$Node[]" loaded by "<system class loader>".

总堆内存 3.2G,单个 HashMap 占用超 80% 内存,直接锁定泄漏容器。

6.2 查看引用链,追溯代码位置

右键嫌疑 HashMap → List objects → with incoming references,展开完整引用链:

plaintext

com.biz.order.cache.LocalDataCache.localCache (static field)
-> java.util.HashMap
-> 大量 OrderDTO 对象(29万+实例)

引用链指向静态缓存类的静态 Map 成员变量,进入代码核对逻辑。

6.3 Histogram 直方图辅助验证

切换 Histogram 视图,按实例数量排序,OrderDTO 实例数量远超正常业务量级,和 Leak Suspects 结论互相印证。

七、第五步:代码核对,复现内存泄漏根因

定位 LocalDataCache 缓存类,问题代码如下:

java

运行

@Component
public class LocalDataCache {
    // 静态HashMap,全局唯一,无过期、无淘汰、无主动清理逻辑
    private static final Map<Long, OrderDTO> localCache = new HashMap<>();

    // 定时任务每1分钟执行,全量同步数据库订单写入缓存
    public void refreshOrderCache() {
        List<OrderDTO> allOrder = orderMapper.selectAllOrder();
        for (OrderDTO dto : allOrder) {
            localCache.put(dto.getId(), dto);
        }
    }
}

问题拆解:

  1. static 修饰 Map,类加载后全程存活,GC 无法回收内部对象;
  2. 定时任务每次全量查询全表订单,只 put 不删除,数据持续累加;
  3. 订单表每日新增上万条数据,一周后缓存填满老年代,触发连续 Full GC。

额外补充线上高频 Full GC 代码坑点,日常排查可直接对照:

  1. ThreadLocal 使用后未调用 remove (),线程池复用线程导致对象常驻;
  2. 接口先全量查数据到内存集合,再内存分页聚合,大数据量一次性填满堆;
  3. POI、EasyExcel 导出百万行文件,未分批释放行对象;
  4. 接口日志打印完整大对象 JSON,临时大对象批量涌入老年代;
  5. 弱引用、软引用缓存使用错误,大量对象无法自动回收。

八、修复方案与上线验证

8.1 代码修复

  1. 替换原生 HashMap 为 Caffeine 本地缓存,设置最大容量、写入过期时间,自动淘汰冷数据;
  2. 定时任务同步前清空缓存旧数据,避免重复堆积;
  3. 全量查询分页分批加载,单次只加载 1000 条,处理完成后释放局部集合。

java

运行

// 修复后缓存定义
private static final LoadingCache<Long, OrderDTO> localCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .build(id -> null);

// 定时任务优化
public void refreshOrderCache() {
    localCache.invalidateAll();
    long page = 1;
    int pageSize = 1000;
    while (true) {
        List<OrderDTO> pageData = orderMapper.selectPage(page, pageSize);
        if (CollectionUtils.isEmpty(pageData)) break;
        pageData.forEach(dto -> localCache.put(dto.getId(), dto));
        page++;
    }
}

8.2 上线后观测指标

发布新版本重启服务,持续观测 4 小时:

  1. Full GC 次数从每分钟 10 次降至每小时 0~1 次;
  2. 单次 GC STW 停顿稳定低于 50ms;
  3. 老年代内存占用稳定在 30% 左右,无持续上涨趋势;
  4. 接口 RT、TPS 恢复正常,监控告警彻底消除。

九、完整排查流程总结(标准化执行顺序)

  1. 监控告警确认故障:CPU、GC、接口指标异常;
  2. jps 获取进程 PID,同步抓取 jstack、jmap -histo、heap.hprof 快照;
  3. jstat -gc 持续观测,区分泄漏 / 大对象两种 Full GC 诱因;
  4. 解析 GC 日志,确认 Full GC 触发类型与回收效率;
  5. 分析线程栈,锁定定时任务、批量处理等可疑业务入口;
  6. MAT 解析堆快照,通过泄漏报告 + 引用链定位占用内存的对象;
  7. 对照引用链找到对应业务代码,复现内存泄漏逻辑;
  8. 代码优化修复,上线后持续观测 GC 指标验证效果。

十、线上避坑补充

  1. 堆 dump 文件导出务必避开业务高峰期,live 参数减少 STW 时长;
  2. 所有 Java 服务强制开启 GC 日志与 OOM 自动 dump,省去故障时临时改参重启;
  3. 本地缓存优先使用带淘汰机制的 Caffeine、Guava Cache,杜绝原生 static HashMap 长期存储业务数据;
  4. 批量查询、文件导出、定时同步类逻辑,必须做分页分批处理,避免一次性加载全量数据进内存;
  5. 故障排查优先留存现场数据,不要上来直接重启,绝大多数内存泄漏问题重启后现场完全消失,无法定位根因。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐