本站所有源码均为自动秒发货,默认(百度网盘)
一次看似简单的接口调用,却让生产服务器差点“躺平”。本文记录了一次因大字段处理不当引发的OOM事故,从问题排查到最终解决的全过程。
背景
上周五下午,正当我准备摸鱼迎接周末时,监控系统突然爆出一连串告警:
【生产告警】服务A - OOM异常,实例IP: 10.xx.xx.xx 【生产告警】服务A - 响应时间飙升,99线从50ms升至5s
打开日志,映入眼帘的是熟悉的红色堆栈:
java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:3332) at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124) at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448) at java.lang.StringBuilder.append(StringBuilder.java:136) at com.alibaba.fastjson.JSON.toJSONString(JSON.java:xxx)
糟了,OOM了!
问题排查
1. 第一反应:查看GC情况
登录跳板机,执行jstat命令查看GC情况:
jstat -gcutil <pid> 1000 10 S0 S1 E O M YGC YGCT FGC FGCT GCT 0.00 0.00 95.2 98.5 92.3 1245 12.345 23 45.678 58.023
老年代使用率98.5%,Full GC已经发生了23次!很明显,内存中有些对象无法被回收。
2. 抓取堆内存分析
立即使用jmap抓取堆内存(生产环境谨慎操作,会STW):
jmap -dump:live,format=b,file=heap.bin <pid>
将heap.bin下载到本地,用MAT(Memory Analyzer Tool)打开。分析结果让我大吃一惊:
![MAT分析结果示意]
(这里放一张MAT的截图,显示有个char[]数组占据了90%的内存)
最大的对象竟然是一个巨大的JSON字符串,占用了将近2GB的内存!
3. 定位代码
顺着引用链找下去,最终定位到一段“平平无奇”的代码:
@RestController public class DataController { @Autowired private DataService dataService; @GetMapping("/export") public String exportData() { // 查询大量数据 List<LargeData> dataList = dataService.queryLargeData(); // 直接转换为JSON字符串 String jsonResult = JSON.toJSONString(dataList); // 返回给前端 return jsonResult; } }
而LargeData对象的定义是这样的:
@Data public class LargeData { private Long id; private String title; private String content; // 这个字段存储大段文本,平均每条10KB private String attachmentUrls; // 多个附件URL拼接,可能很大 private String remark; // 备注,也可能很大 private List<SubData> subDataList; // 子数据列表 // ... 还有几十个字段 }
业务场景是导出数据,dataService.queryLargeData()会查询最近一个月的数据,大约5000条。每条数据平均大小50KB,5000条就是:
5000 × 50KB = 250MB(原始数据)
但是,当转换成JSON字符串时:
String jsonResult = JSON.toJSONString(dataList);
问题就来了:
-
JSON序列化会增加额外的字符(括号、引号、逗号等)
-
字符串在Java中是以char[]形式存储,每个char占2字节
-
中间过程会产生多个临时对象
实际内存占用轻松超过1GB!
4. 查看请求日志
翻看接口调用日志,发现这个导出接口被频繁调用:
14:30:15 - /export - 成功 14:30:18 - /export - 成功 14:30:22 - /export - 成功 14:30:25 - /export - 失败(OOM)
短短10秒内,这个接口被调用了4次!每次请求都在内存中构建一个几百MB的JSON字符串,并发请求直接将堆内存打满。
为什么会导致OOM?
深入分析这个场景,存在以下几个问题:
1. 对象过大
单个JSON字符串太大,直接在堆内存中创建了超大对象。JVM对大对象有特殊的分配策略,直接进入老年代。
2. 内存滞留
虽然方法执行完后,这些对象应该被回收,但:
-
老年代GC(Full GC)频率较低
-
大对象移动成本高
-
并发请求导致内存迅速被占满
3. String对象的特殊性
String json = JSON.toJSONString(list);
这行代码执行时发生了什么:
-
StringBuilder内部char[]不断扩容(从16到34到70…直到超过1GB)
-
创建String对象时,会复制char[](新的数组)
-
返回结果时,可能还有其他中间对象
4. 内存分配示意图
堆内存分布: ┌────────────────────────────────────┐ │ Eden │ S0 │ S1 │ 老年代 │ ├────────────────────────────────────┤ │ 请求1的临时对象 │ │ ↓ 晋升 │ │ 请求1的大JSON串 →→→→→ 老年代(1GB) │ │ 请求2的大JSON串 →→→→→ 老年代(1GB) │ │ 请求3的大JSON串 →→→→→ 老年代(1GB) │ │ 请求4尝试分配内存 →→→ OOM! │ └────────────────────────────────────┘
解决方案
针对这个问题,我们从多个角度进行了优化:
方案一:分批处理(立即实施)
这是最快见效的解决方案:
@GetMapping("/export") public void exportData(HttpServletResponse response) { // 分批查询,每批500条 int pageSize = 500; int pageNum = 1; // 设置响应类型 response.setContentType("application/json;charset=utf-8"); try (PrintWriter writer = response.getWriter()) { writer.write("["); while (true) { // 分批查询 PageInfo<LargeData> page = dataService.queryLargeDataByPage(pageNum, pageSize); List<LargeData> dataList = page.getList(); if (CollectionUtils.isEmpty(dataList)) { break; } // 分批序列化并写入 for (int i = 0; i < dataList.size(); i++) { String jsonItem = JSON.toJSONString(dataList.get(i)); writer.write(jsonItem); // 不是最后一条数据,添加逗号 if (pageNum * pageSize + i < page.getTotal() - 1) { writer.write(","); } // 每批数据后flush一下 if (i % 100 == 0) { writer.flush(); } } pageNum++; // 每页处理完也flush writer.flush(); } writer.write("]"); writer.flush(); } catch (IOException e) { log.error("导出失败", e); } }
方案二:字段裁剪(中期优化)
分析发现,前端只需要部分字段,可以按需返回:
public class LargeDataVO { private Long id; private String title; // content、attachmentUrls等大字段不需要返回 // 只返回必要字段 } // 使用MapStruct或手动转换 List<LargeDataVO> voList = dataList.stream() .map(this::convertToVO) .collect(Collectors.toList());
方案三:流式处理(终极方案)
使用Jackson的流式API,边序列化边输出:
@GetMapping("/export-stream") public void exportDataStream(HttpServletResponse response) throws IOException { response.setContentType("application/json;charset=utf-8"); JsonFactory factory = new JsonFactory(); try (JsonGenerator generator = factory.createGenerator(response.getOutputStream())) { generator.writeStartArray(); dataService.queryLargeDataWithCallback(new DataCallback() { @Override public void onData(LargeData data) { try { // 直接写入到输出流 generator.writeStartObject(); generator.writeNumberField("id", data.getId()); generator.writeStringField("title", data.getTitle()); // 需要哪些字段就写哪些 generator.writeEndObject(); } catch (IOException e) { throw new RuntimeException(e); } } }); generator.writeEndArray(); } } // 在Service中使用游标查询 public void queryLargeDataWithCallback(DataCallback callback) { // 使用游标方式,避免一次性加载到内存 jdbcTemplate.query("SELECT * FROM large_data WHERE create_time > ?", new Object[]{oneMonthAgo}, rs -> { LargeData data = mapRow(rs); callback.onData(data); }); }
方案四:压缩传输(额外优化)
如果必须传输大文本,可以启用GZIP压缩:
@GetMapping(value = "/export-compress", produces = "application/json") public ResponseEntity<byte[]> exportCompress() throws IOException { List<LargeData> dataList = dataService.queryLargeData(); String json = JSON.toJSONString(dataList); // 压缩数据 ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (GZIPOutputStream gzip = new GZIPOutputStream(baos)) { gzip.write(json.getBytes(StandardCharsets.UTF_8)); } return ResponseEntity.ok() .header("Content-Encoding", "gzip") .body(baos.toByteArray()); }
优化效果
实施分批处理方案后,立即解决了OOM问题:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 内存占用 | 1.5GB/请求 | 10MB/批次 |
| 响应时间 | 10s (然后OOM) | 分批返回,首字节时间<100ms |
| Full GC次数 | 23次/小时 | 2次/天 |
| 并发支持 | 2-3个并发就OOM | 支持20+并发 |
经验教训
-
不要一次性加载所有数据 – 数据量再小,也要考虑未来的增长
-
关注大字段 – 单个对象可能不大,但集合起来就很可观
// 危险! List<LargeData> list = dao.findAll(); // 万一数据量很大呢? // 安全 Page<LargeData> page = dao.findByPage(pageRequest);
-
JSON序列化是内存杀手 – 序列化过程会产生大量临时对象,对大集合尤其明显
-
接口设计要考虑数据量 – 不是所有接口都适合返回完整数据,考虑分页、流式返回
-
监控告警要及时 – 这次幸亏监控告警及时,在业务高峰期前发现问题
总结
大字段处理不当导致的OOM是一个常见但容易被忽视的问题。它不仅会影响单个接口的可用性,还可能导致整个应用崩溃。通过合理的分页、流式处理和字段裁剪,可以有效避免这类问题。
记住:在Java世界里,所有数据最终都要放进内存,而内存是有限的。处理大字段时,一定要想清楚:”这些数据真的需要一次性全部加载到内存吗?”
如果答案是否定的,那么就要考虑流式处理了。