服务没有 OOM监控里的堆内存也只有 50% 左右。但接口每隔几分钟就集体卡住 3 到 5 秒重启后只能短暂恢复。最后顺着 GC 日志查下去问题出在一个一次性解析 80MB JSON 的接口。一、事故现场这是一个典型的 Java 服务卡顿现场。业务反馈订单后台偶尔打不开刷新几次又恢复了。监控里的接口曲线很有规律平时 RT120ms 异常时 P994.8s 持续时间3 到 5 秒 发生频率每隔 4 到 6 分钟一次再看机器资源CPU 平时 25%异常瞬间升到 85% 堆内存长期在 45% 到 60% 线程数没有明显增长 数据库连接池也没有占满看起来内存还有不少空间。但日志里出现了一条关键记录Pause Full (G1 Compaction Pause) 4090M-2180M(4096M) 4.317s这句话已经解释了接口为什么会集体卡顿。Full GC 期间发生了 Stop-The-World业务线程需要暂停。暂停 4.3 秒接口自然也会卡 4.3 秒左右。可问题又来了监控里堆内存明明只有一半为什么还会 Full GC二、你看到的 50%可能是 GC 之后的结果很多人看到监控里的堆内存曲线会下意识认为堆只用了 50% 所以不是内存问题但监控通常是每 15 秒、30 秒甚至 1 分钟采样一次。真实过程可能是14:30:01 堆占用 2.3GB 14:30:06 突然创建大量临时对象 14:30:08 堆占用接近 4GB 14:30:09 触发 Full GC暂停 4.3 秒 14:30:13 回收后降到 2.1GB 14:30:15 监控采样记录 52%监控刚好错过了 GC 前的峰值只采到了回收后的结果。于是页面上看起来一直是堆内存还有一半实际上在两次采样之间堆已经冲到上限并完成了一次 Full GC。所以判断 GC 问题不能只看一条堆内存曲线。至少还要同时看Full GC 次数 GC 暂停时间 对象分配速率 老年代占用 GC 前后堆变化三、先用 jstat 确认是不是 GC 在抖先找到 Java 进程jps-l假设 PID 是21638持续观察 GCjstat-gcutil216381000每秒输出一次重点看E Eden 区使用率 O Old 区使用率 YGC Young GC 次数 YGCT Young GC 总耗时 FGC Full GC 次数 FGCT Full GC 总耗时 GCT GC 总耗时现场观察到S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.0 82.1 68.4 91.8 94.2 90.1 386 7.12 8 31.46 38.58 0.0 82.1 96.7 94.5 94.2 90.1 387 7.16 8 31.46 38.62 0.0 0.0 12.3 53.2 94.2 90.1 387 7.16 9 35.78 42.94第三行出现了两个变化FGC8 - 9 FGCT31.46 - 35.78一次 Full GC 用了约 4.32 秒。同时老年代从 94.5% 降到了 53.2%。这也证明了前面的判断监控看到的 53%是 Full GC 回收之后的结果。四、打开 GC 日志比盯着监控猜更直接如果使用 JDK 17可以配置统一日志-Xlog:gc*,safepoint:file/data/logs/app/gc.log:time,uptime,level,tags:filecount10,filesize100M线上至少应该保留这些信息GC 触发原因 GC 前后的堆大小 各阶段耗时 是否发生 Full GC Safepoint 暂停时间事故日志里出现了连续记录[gc,start] GC(128) Pause Young (Concurrent Start) (G1 Humongous Allocation) [gc,heap ] GC(128) Humongous regions: 412-407 [gc ] GC(128) Pause Young ... 3721M-3590M(4096M) 86.214ms [gc,start] GC(131) Pause Full (G1 Compaction Pause) [gc,heap ] GC(131) Humongous regions: 426-118 [gc ] GC(131) Pause Full ... 4090M-2180M(4096M) 4317.622ms这里最值得注意的是G1 Humongous Allocation Humongous regions: 426-118G1 会把超过一定阈值的大对象当作 Humongous Object 处理。大量大对象进入堆后会快速消耗连续 Region也会增加 GC 压力。因此下一步不是立刻调大-Xmx而是要找应用为什么在短时间内创建这么多大对象五、先看对象再找代码入口可以先查看堆概况jcmd21638GC.heap_info再观察对象直方图jcmd21638GC.class_histogram/tmp/histo-1.log在业务低峰间隔一段时间再取一次jcmd21638GC.class_histogram/tmp/histo-2.log现场排名靠前的对象包括[B byte[] [C char[] java.lang.String java.util.HashMap$Node com.example.OrderExportRowbyte[]、char[]和String占用异常高通常可以沿着这些方向排查是否一次性读取大文件 是否一次性查询大量数据 是否拼接超大字符串 是否把完整响应重复序列化 是否一次性解析大 JSON如果需要进一步确认可以在可控环境使用 JFR 记录对象分配jcmd21638JFR.startnameallocationsettingsprofileduration120sfilename/tmp/allocation.jfr打开 JFR 后按分配热点查看最终定位到了一个报表接口POST /internal/order/syncAll它每隔 5 分钟执行一次时间正好与 Full GC 频率吻合。六、根因一份 JSON 在内存里出现了好几次原来的代码为了同步订单先从远程系统拉取完整数据publicintsyncAllOrders(){StringjsonrestTemplate.getForObject(remoteUrl/orders/all,String.class);ListOrderDTOordersobjectMapper.readValue(json,newTypeReferenceListOrderDTO(){});ListOrderEntityentitiesorders.stream().map(this::convert).toList();orderRepository.saveAll(entities);returnentities.size();}远程系统的数据增长后响应体已经接近 80MB。这 80MB 并不等于只占 80MB 堆内存。整个处理过程中内存里可能同时存在HTTP 响应字节数组 转换后的 String Jackson 解析过程中的缓冲区 反序列化后的 DTO 列表 转换后的 Entity 列表 批量保存使用的临时对象 日志中再次序列化的内容一份 80MB JSON经过字符串、对象树和业务对象的多次转换瞬时占用可能放大数倍。而且任务每 5 分钟执行一次。上一次产生的对象还没完全回收下一次任务又开始分配老年代很快被推高。最终形成事故链路一次性拉取 80MB JSON ↓ 创建大量 byte[]、String 和 DTO ↓ 短时间对象分配速度暴涨 ↓ Humongous Region 快速增加 ↓ Young GC 回收效果有限 ↓ 触发 Full GC ↓ 业务线程暂停 4 秒 ↓ 所有接口同时出现超时尖刺七、第一版修复不要一次性拉完最有效的修复不是换 GC也不是单纯扩大堆。而是从数据源头限制单次处理规模。把全量接口改成分页publicintsyncOrders(){intpage1;intpageSize500;inttotal0;while(true){OrderPageresultremoteClient.queryOrders(page,pageSize);if(result.getRecords().isEmpty()){break;}ListOrderEntityentitiesresult.getRecords().stream().map(this::convert).toList();orderRepository.saveAll(entities);totalentities.size();if(!result.isHasNext()){break;}page;}returntotal;}单页 500 条后每次只持有一小批对象。处理完当前页相关对象就具备了被回收的条件。但还要注意不要把每一页重新放入一个总列表ListOrderEntityallnewArrayList();while(...){all.addAll(currentPage);}这样表面上分页了内存里仍然保留着全部对象问题并没有解决。八、第二版修复必须处理大响应时用流式解析如果上游暂时无法提供分页可以考虑流式读取和解析。核心思路是读取一条 处理一条或一小批 处理完成后释放引用 不要先构造完整 ListJackson 可以使用流式 APIpublicvoidimportOrders(InputStreaminputStream)throwsIOException{JsonFactoryfactoryobjectMapper.getFactory();try(JsonParserparserfactory.createParser(inputStream)){if(parser.nextToken()!JsonToken.START_ARRAY){thrownewIllegalArgumentException(response must be a JSON array);}ListOrderEntitybatchnewArrayList(500);while(parser.nextToken()JsonToken.START_OBJECT){OrderDTOdtoobjectMapper.readValue(parser,OrderDTO.class);batch.add(convert(dto));if(batch.size()500){orderRepository.saveAll(batch);batch.clear();}}if(!batch.isEmpty()){orderRepository.saveAll(batch);}}}流式解析可以降低峰值内存但它不能替代所有治理手段。仍然需要限制响应体最大尺寸 设置连接和读取超时 限制单次任务处理时长 防止同一个定时任务并发执行 失败后从断点继续而不是全量重跑九、为什么把 Xmx 调大不是最终答案事故发生后有人提出4GB 不够那就调到 8GB扩大堆可能争取一些时间但没有消除大对象分配。数据继续增长后80MB 会变成 150MB任务也可能从一个增加到多个。而且堆变大以后问题出现得更晚更难在测试环境复现 一次回收需要扫描和整理的内存更多 故障时可能产生更长的暂停 Heap Dump 文件也会更大正确的处理顺序应该是先控制单次数据规模 再降低对象生命周期和内存拷贝 然后根据业务基线设置堆大小 最后才评估 GC 参数GC 调优不能替代代码和数据模型治理。十、修复后的结果分页和批量处理上线后监控数据变成单次最大响应80MB - 约 1.2MB 老年代峰值95% - 61% Full GC高峰期 0 次 接口 P994.8s - 180ms 同步任务耗时略有增加但运行稳定同步任务总耗时并没有大幅下降。但它不再一次性抢占大量内存也不会让在线接口集体暂停。对生产系统来说稳定地处理 3 分钟通常比冒着拖垮整台 JVM 的风险处理 2 分钟更有价值。十一、Full GC 排查清单遇到 Full GC 或周期性卡顿可以按下面的顺序排查。1. 先确认卡顿是否与 GC 时间吻合接口 P99 尖刺时间 Full GC 时间 Safepoint 暂停时间 CPU 瞬时变化2. 查看 GC 前后发生了什么jstat-gcutilpid1000jcmdpidGC.heap_info3. 检查 GC 日志Full GC 的触发原因 老年代回收前后变化 Humongous Region 数量 单次暂停耗时 Full GC 是否越来越频繁4. 找对象分配热点jcmdpidGC.class_histogram jcmdpidJFR.startnameallocationsettingsprofileduration120sfilename/tmp/allocation.jfr5. 回到业务代码是否一次性查询大量数据 是否一次性解析大 JSON 是否读取超大文件到 byte[] 是否存在无上限集合或本地缓存 是否重复序列化、复制大对象 定时任务是否重叠执行不要一上来就导出生产 Heap Dump。Heap Dump 可能很大生成过程也可能进一步影响服务。应先评估机器磁盘、业务压力和操作风险再选择合适的环境和时间执行。十二、总结这次事故最反直觉的地方是监控显示堆内存只有 50% 服务却每隔几分钟卡死一次真正的原因是监控采到了 Full GC 之后的内存而没有采到 GC 之前冲向上限的瞬间。最终根因也不是 JVM 参数太小而是业务代码一次性拉取并解析了 80MB JSON。记住三个结论不要只看堆内存百分比要看 GC 次数和暂停时间 不要只想着扩大堆要先找对象为什么被大量创建 能分页就不要全量能流式处理就不要一次性构造完整 List遇到线上问题也可以来找我一起看如果你遇到 Java 服务卡顿、Full GC、CPU 飙升、接口超时或者需要协助安装部署 Spring Boot、MySQL、Redis、Nginx可以关注公众号 我后台 联系我。开发、运维问题、基础安装和初步排查我可以免费帮忙看一下。发送问题时建议附上故障现象和发生时间 脱敏后的完整日志 JDK 与中间件版本 监控截图或关键指标 最近的发布和配置变更请不要发送密码、Token、数据库账号、服务器私钥或用户隐私数据。涉及生产操作、长期运维或完整实施时会先说明范围和风险。下一篇继续复现线程池参数到底怎么配一次核心线程配错引发的请求堆积。