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