一、风险代码示例(线上高频踩坑)
场景:数据库一次性查出十万 / 百万级付款头数据,全量加载进内存,直接流式收集 Map,无分页、无分片分批处理。
import java.util.List; import java.util.Map; import java.util.stream.Collectors; // 实体简化 class ErpApPaymentHeader { private Long paymentHeaderId; private String status; // getter setter public Long getPaymentHeaderId() { return paymentHeaderId; } public String getStatus() { return status; } } public class StreamBigDataOomDemo { public static void main(String[] args) { // 模拟一次性查询百万条数据,全部加载到List List<ErpApPaymentHeader> paymentHeaders = queryAllPaymentHeader(); // 危险代码:超大List一次性全量收集Map,无分片 Map<Long, String> payHeaderStatusMap = paymentHeaders.stream() .collect(Collectors.toMap( ErpApPaymentHeader::getPaymentHeaderId, ErpApPaymentHeader::getStatus, (oldVal, newVal) -> newVal )); // 后续业务使用map } // 模拟数据库一次性查出百万条记录,全部装入内存List private static List<ErpApPaymentHeader> queryAllPaymentHeader() { // 模拟返回 100万 条数据,全部加载到堆内存 return null; } }高危变种:并行流处理超大集合(内存压力翻倍恶化)
// parallelStream多线程同时创建大量临时对象,堆内存瞬间打满,极易OOM Map<Long, String> map = paymentHeaders.parallelStream() .collect(Collectors.toMap(ErpApPaymentHeader::getPaymentHeaderId, ErpApPaymentHeader::getStatus));二、问题深度分析
1. 内存暴涨核心原因
全量数据一次性加载至堆
queryAllPaymentHeader()将百万条实体对象全部存入List,每条实体包含多个字符串、日期、包装类,单条占用几十~几百字节,百万数据会直接占用几百 MB 甚至 GB 级堆内存。Stream 收集过程产生大量中间临时对象
collect(toMap)执行时:
- 循环读取每条实体,调用 getter 生成字符串 value;
- HashMap 底层持续扩容、创建 Entry 节点、复制底层数组;
- 一次性完成全部数据 put 操作,无缓冲分批逻辑,瞬间申请连续大块堆内存。
GC 回收不及时,触发堆溢出 OOM 实体对象、中间字符串、Map 节点在同一时间段全部存活,新生代内存瞬间占满,频繁 Full GC 仍无法释放足够内存,直接抛出
java.lang.OutOfMemoryError: Java heap space。并行流会大幅加剧内存压力 并行流多线程并发收集,每个线程都会创建独立局部中间容器,整体内存占用是串行模式的 2~4 倍,OOM 触发速度更快。
业务附加遍历逻辑额外增加堆消耗 使用 forEach 批量处理数据、打印日志、填充外部集合时,会创建大量短周期临时字符串、包装对象,加重 GC 停顿与内存占用。
2. 隐性业务风险
- 接口响应超时:全量加载 + 流式收集耗时数秒,触发服务熔断、超时;
- 服务整体卡顿:频繁 Full GC 造成所有业务线程 STW 停顿;
- 连锁故障:当前接口耗尽容器堆内存,其他所有接口同步出现 OOM 宕机。
三、四层解决方案(按推荐优先级排序)
方案 1:数据库分页分片查询(最优根治方案,源头控制数据量)
不再一次性查询全量数据,分页分批加载、分批收集 Map、分批执行业务逻辑,单批次仅少量数据驻留内存。
import java.util.*; import java.util.stream.Collectors; public class PageQuerySolve { // 最终汇总完整映射Map private static Map<Long, String> totalStatusMap = new HashMap<>(); // 单页分片阈值,根据实体大小推荐1000~5000 private static final int PAGE_SIZE = 2000; public static void handleBigData() { long pageNo = 1; while (true) { // 分页查询,单次仅加载2000条数据 List<ErpApPaymentHeader> pageList = queryPaymentHeaderByPage(pageNo, PAGE_SIZE); if (pageList.isEmpty()) { break; } // 单页小集合收集Map,瞬时内存峰值极低 Map<Long, String> pageMap = pageList.stream() .filter(Objects::nonNull) .collect(Collectors.toMap( ErpApPaymentHeader::getPaymentHeaderId, h -> Optional.ofNullable(h.getStatus()).orElse(""), (oldVal, newVal) -> newVal )); // 合并至总Map totalStatusMap.putAll(pageMap); // 单页执行业务处理、日志打印、ID归集 pageList.forEach(header -> { String status = header.getStatus(); Long headerId = header.getPaymentHeaderId(); if ("SUCCESS".equals(status)) { successHeaders.add(headerId); } else { logger.info("应付付款_支付状态为:{},支付头ID:{}", status, headerId); } }); pageList.clear(); // 主动清空引用,加速GC回收当前页对象 pageNo++; } } // 分页查询,生产环境推荐主键游标分页,避免offset偏移性能衰减 private static List<ErpApPaymentHeader> queryPaymentHeaderByPage(long pageNo, int pageSize) { long offset = (pageNo - 1) * pageSize; // SQL示例:select payment_header_id,status from erp_ap_payment_header limit offset,pageSize return new ArrayList<>(); } }优势:内存常驻数据仅单页大小,堆占用平稳无尖峰,从根源规避 OOM。
方案 2:集合手动分片切割(兜底方案,无法修改查询时使用)
如果业务限制必须一次性获取完整大 List,手动切割集合分片,分批收集、分批释放临时内存,降低收集阶段瞬时内存峰值。
import java.util.*; import java.util.stream.Collectors; public class SplitListSolve { // 单批处理数量 private static final int BATCH_SIZE = 3000; public static Map<Long, String> splitCollect(List<ErpApPaymentHeader> bigList) { Map<Long, String> resultMap = new HashMap<>(bigList.size()); int total = bigList.size(); int index = 0; while (index < total) { // 切割分片子集合 int end = Math.min(index + BATCH_SIZE, total); List<ErpApPaymentHeader> batch = bigList.subList(index, end); // 分片单独收集映射 Map<Long, String> batchMap = batch.stream() .filter(Objects::nonNull) .collect(Collectors.toMap( ErpApPaymentHeader::getPaymentHeaderId, h -> Optional.ofNullable(h.getStatus()).orElse(""), (oldVal, newVal) -> newVal )); resultMap.putAll(batchMap); batch.clear(); // 释放分片临时引用,减少内存占用 index = end; } return resultMap; } }短板:原始完整 bigList 仍全部驻留内存,仅缓解收集阶段瞬时峰值,无法彻底解决大对象常驻堆问题。
方案 3:SQL 只查询必要字段,缩小单条数据体积
业务仅需要 ID 与状态映射关系,不在 Java 层查询全字段实体,SQL 只查询所需两列,大幅降低网络传输与内存占用。
-- 仅查询业务必需字段,减少实体内存占用70%以上 select payment_header_id, status from erp_ap_payment_header;配合分页使用,双重降低内存压力,性价比极高。
方案 4:海量离线数据分片落地外部存储(千万级数据专用)
数据量达到千万级,不适合全量加载至内存 Map:
- 分页分批查询,写入本地临时文件或 Redis 分片 Hash;
- 业务使用时按需读取分片数据,不一次性加载全量映射;
- 适配离线报表、批量对账、数据同步等场景。
四、完整总结
1. 问题本质
超大集合一次性流式收集时,全量实体对象、大量中间临时容器同步驻留堆内存,无分批缓冲机制,短时间耗尽堆内存,引发 OOM、长时间 Full GC 卡顿;并行流、大批量 forEach 遍历处理会进一步放大内存压力。
2. 核心编码避坑规范
- 禁止一次性查询十万、百万级数据全部加载至 List,优先数据库分页分片,从源头控制加载数据量;
- 必须全量加载集合时,手动切割分片分批收集,单批数据控制在 1000~5000 条;
- 仅需要部分字段映射时,SQL 只查询业务必需字段,缩减单条数据内存体积;
- 超大集合收集映射避免使用 parallelStream 并行流,防止内存翻倍暴涨;
- 每批次数据处理完成后主动清空局部集合引用,辅助 GC 快速回收无用对象;
- 千万级海量离线数据,禁止全量装入内存 Map,改用文件、Redis 分片存储。
3. 方案快速选型口诀
- 可调整数据库查询 → 方案 1(最优推荐)
- 无法分页、只能拿到完整大 List → 方案 2(兜底分片)
- 仅需 ID + 状态简单映射关系 → 方案 3(SQL 精简字段降内存)
- 千万级离线批量数据处理 → 方案 4(外部存储分片落地)