大字段处理不当导致的OOM

VIP/

一次看似简单的接口调用,却让生产服务器差点“躺平”。本文记录了一次因大字段处理不当引发的OOM事故,从问题排查到最终解决的全过程。

背景

上周五下午,正当我准备摸鱼迎接周末时,监控系统突然爆出一连串告警:

text
【生产告警】服务A - OOM异常,实例IP: 10.xx.xx.xx
【生产告警】服务A - 响应时间飙升,99线从50ms升至5s

打开日志,映入眼帘的是熟悉的红色堆栈:

java
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情况:

bash
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):

bash
jmap -dump:live,format=b,file=heap.bin <pid>

将heap.bin下载到本地,用MAT(Memory Analyzer Tool)打开。分析结果让我大吃一惊:

![MAT分析结果示意]
(这里放一张MAT的截图,显示有个char[]数组占据了90%的内存)

最大的对象竟然是一个巨大的JSON字符串,占用了将近2GB的内存!

3. 定位代码

顺着引用链找下去,最终定位到一段“平平无奇”的代码:

java
@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对象的定义是这样的:

java
@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条就是:

text
5000 × 50KB = 250MB(原始数据)

但是,当转换成JSON字符串时:

java
String jsonResult = JSON.toJSONString(dataList);

问题就来了:

  1. JSON序列化会增加额外的字符(括号、引号、逗号等)

  2. 字符串在Java中是以char[]形式存储,每个char占2字节

  3. 中间过程会产生多个临时对象

实际内存占用轻松超过1GB!

4. 查看请求日志

翻看接口调用日志,发现这个导出接口被频繁调用:

text
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对象的特殊性

java
String json = JSON.toJSONString(list);

这行代码执行时发生了什么:

  1. StringBuilder内部char[]不断扩容(从16到34到70…直到超过1GB)

  2. 创建String对象时,会复制char[](新的数组)

  3. 返回结果时,可能还有其他中间对象

4. 内存分配示意图

text
堆内存分布:
┌────────────────────────────────────┐
│  Eden    │  S0 │ S1 │   老年代      │
├────────────────────────────────────┤
│ 请求1的临时对象                      │
│    ↓ 晋升                           │
│ 请求1的大JSON串 →→→→→ 老年代(1GB)    │
│ 请求2的大JSON串 →→→→→ 老年代(1GB)    │
│ 请求3的大JSON串 →→→→→ 老年代(1GB)    │
│ 请求4尝试分配内存 →→→ OOM!          │
└────────────────────────────────────┘

解决方案

针对这个问题,我们从多个角度进行了优化:

方案一:分批处理(立即实施)

这是最快见效的解决方案:

java
@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);
    }
}

方案二:字段裁剪(中期优化)

分析发现,前端只需要部分字段,可以按需返回:

java
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,边序列化边输出:

java
@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压缩:

java
@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+并发

经验教训

  1. 不要一次性加载所有数据 – 数据量再小,也要考虑未来的增长

  2. 关注大字段 – 单个对象可能不大,但集合起来就很可观

    java
    // 危险!
    List<LargeData> list = dao.findAll(); // 万一数据量很大呢?
    
    // 安全
    Page<LargeData> page = dao.findByPage(pageRequest);
  3. JSON序列化是内存杀手 – 序列化过程会产生大量临时对象,对大集合尤其明显

  4. 接口设计要考虑数据量 – 不是所有接口都适合返回完整数据,考虑分页、流式返回

  5. 监控告警要及时 – 这次幸亏监控告警及时,在业务高峰期前发现问题

总结

大字段处理不当导致的OOM是一个常见但容易被忽视的问题。它不仅会影响单个接口的可用性,还可能导致整个应用崩溃。通过合理的分页、流式处理和字段裁剪,可以有效避免这类问题。

记住:在Java世界里,所有数据最终都要放进内存,而内存是有限的。处理大字段时,一定要想清楚:”这些数据真的需要一次性全部加载到内存吗?”

如果答案是否定的,那么就要考虑流式处理了。

购买须知/免责声明
1.本文部分内容转载自其它媒体,但并不代表本站赞同其观点和对其真实性负责。
2.若您需要商业运营或用于其他商业活动,请您购买正版授权并合法使用。
3.如果本站有侵犯、不妥之处的资源,请在网站右边客服联系我们。将会第一时间解决!
4.本站所有内容均由互联网收集整理、网友上传,仅供大家参考、学习,不存在任何商业目的与商业用途。
5.本站提供的所有资源仅供参考学习使用,版权归原著所有,禁止下载本站资源参与商业和非法行为,请在24小时之内自行删除!
6.不保证任何源码框架的完整性。
7.侵权联系邮箱:aliyun6168@gail.com / aliyun666888@gail.com
8.若您最终确认购买,则视为您100%认同并接受以上所述全部内容。

免费源码网 java 大字段处理不当导致的OOM https://svipm.com.cn/21252.html

相关文章

猜你喜欢