堆内存明明还有一半,接口为什么每隔几分钟卡死一次?

服务没有 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、数据库账号、服务器私钥或用户隐私数据。涉及生产操作、长期运维或完整实施时,会先说明范围和风险。


下一篇继续复现:

线程池参数到底怎么配?一次核心线程配错引发的请求堆积。

相关推荐
是小李呀19 小时前
解决 “Your local changes will be overwritten by revert“ 报错
后端
fliter19 小时前
不用再反复 stash:用 Git Worktree 同时开发多个分支
后端·github
卷无止境19 小时前
KTransformers:让巨型模型跑在你家电脑上的黑科技
人工智能·后端
_waylau19 小时前
Spring Framework HTTP服务客户端详解
java·后端·网络协议·spring·http·spring cloud
是小李呀19 小时前
如何安全撤销未推送的 Git Revert 操作
后端
蓝银草同学19 小时前
Stream 数据统计实战:求和、平均值、分组汇总(AI 辅助学习 Java 8)
java·前端·后端
fengxinzi_zack19 小时前
TagManagePage:CRUD 标签管理 + 编辑模式
后端
IT_陈寒19 小时前
Redis过期key的坑,差点让线上服务崩了
前端·人工智能·后端