OOM事故-调优场景与架构优化
Situation(情景)
我有部分是做数据相关的工作嘛,我整理了一个出来。
我是做 order process 数据重构迁移优化的。从 深燃的数据核心部门,迁移重构优化了大概 30 多个 process,迁移到了我所在 技术部 订单数据组,弄完后把部分 process 交给其他两个组,交易组和维护组。完成重构后,再按业务职责交接给交易组和维护组。
OOM 与栈溢出的区别(补充)
在深入案例之前,先明确一个重要概念:
OOM(OutOfMemoryError):是系统级别的错误,针对堆内存溢出。一个 JVM 只有一个堆,当堆内存不够用时,整个 JVM 就无法创建新对象,导致系统崩溃。无论主线程还是子线程,OOM 都会导致整个应用崩溃。
栈溢出(StackOverflowError):是线程级别的错误。如果是子线程发生栈溢出,不会导致整个系统崩溃;但如果是主线程发生栈溢出,也会导致系统崩溃。
这个区别很重要,因为 OOM 是更严重的故障,需要优先处理。
案例描述
定时任务公司内部是用的 autosys 的网站。
我们有个老的 process 当时迁过来大概半年了,然后相关的 job 有两个:
job1:定时每天晚上 8 点定时每隔 5 分钟执行一个 shell 脚本,sftp 从远程数据源拉取了一些金融数据的 excel 的文件压缩包,然后解压到服务器。我们观察了文件基本都是在晚上 9 点前就位的。
job2:这个 Job 2 原定每晚 9 点 10 分执行。当天凌晨,我被 Leader 的紧急电话叫醒。原来是系统在 11 点半触发了数据对账监控告警:监控发现 HBase 里的当天数据依然为空(或者远低于 60 万条的阈值,这个逻辑是以前维护这个 process 的同事配的)。
当时属于线上 P1 级故障,因为这个 process 是我去负责的,我的紧急任务就是立刻接入,排查这个任务到底是在中途卡住了、还是根本没跑、亦或是遭遇了异常崩溃,并在最短时间内恢复数据。
系统发现在 11 点半 HBase 里依然没有当天的数据,运维就紧急找到我们去处理这个异常
Task(任务)
- 紧急目标:排查崩溃原因,在最短时间内恢复生产数据,确保不影响白天的下游业务。
- 长期目标:彻底重构该 Process 的内存管理与解析逻辑,消除未来的 OOM 隐患。
Action(行动)问题排查
我将排查和优化分为了三个阶段:紧急止血、深度定位、架构重构。
- 然后我先登上服务器执行
ps -ef | grep loadDataXXX查看后台进程,找一下任务。,grep 过滤了一下我们那个loadData任务的进程,发现这个 Java 的进程直接没了 - 所以我就排除了任务还在跑卡住的情况,基本断定是程序 Crash(崩溃)了,大概率是 OOM。当然也可以是定时任务因为啥原因没执行(这个在之前发过但是极少,就是定时任务平台调度超时了就失败了)。
- 看了下 autosys job,准点触发成功的,没问题。
- 然后查看日志确实 OOM 了。
-XX:+HeapDumpOnOutOfMemoryError:发生 OOM 时自动生成堆转储文件(关键参数)-XX:HeapDumpPath=:指定 OOM 堆转储文件的保存路径,如-XX:HeapDumpPath=/var/log/app/heapdump.hprof-XX:+ShowMessageBoxOnError:发生致命错误时弹出对话框(仅 Windows 环境有效)
- 把 dump 的堆文件下载下来,拖到 IDEA 查看,发现确实是 OOM 内存溢出了,并且主要是一个 ArrayList 占用的内存太大。然后程序启动 JVM 参数配的最小堆内存跟最大都是配的 2G。
- 最后大概知道了原因,看了下 excel 大概一百多个字段,之前的某些字段为空面没值,然后那天突然都被填充了。后面去问了下,是别的 组 有需求所以做了填充,导致数据的体积增大。
- 然后看了下代码也有不少不合理的地方,后面进行了优化,下面先说解决方案。
解决方案
当时先立马让 运维 帮改了下 shell 脚本,把 JVM 参数的堆内存改成了 4G,然后重新启动了定时任务 job2。因为当时还没过 0 点,不然还要改下日期,shell 脚本里是 load 当天日期的 excel 文件。
优化方案
- 先堆内存还是保持了 4G,因为我们组的机器内存多的很,还有几百个 G,并且他这个任务是短期执行的,调点没啥浪费。
- 然后是代码:
- 首先把接收的对象所属类的字段类型进行了优化,先跟 BA 确认大致的字段的含义,比如有些字段肯定不需要
long类型来存,直接改成了int。
- 首先把接收的对象所属类的字段类型进行了优化,先跟 BA 确认大致的字段的含义,比如有些字段肯定不需要
- 还有原本的 process 接收了所有的字段的值,这个我改成了只取经过跟 BA 跟下游确认后需要的字段,原本拿了一百多个字段,后面改成了五十多个。
- 因为我们 process 是先迁移然后重构优化的,所有的 process 当时都迁移过来了,然后在重构的是另一个 process。当时立马把这个 process重构的优先级提起来了
- 然后除了上面的优化之外还整上了 Kafka 跟 Flink。数据除了老的 sink 逻辑发 HBase 外,还会发一遍 Kafka 跟 ES。然后 Kafka 保存两周的数据,方面是可以给别的组用,另方面是如果发现某天或者几天的数据少了,我们也可以修改下新加的配置文件。比如有个 reload 的配置可以指定日期,就是只消费对应日期的数据,直接从 Kafka 消费效率比较快,不用解析 excel 了。老的如果要 reload 数据需要改 shell 脚本,然后 reload shell 脚本之前默认是 load 当天的日期的 excel 数据。
解析 80 万行 Excel 文件(每行 20+ 字段、含 ID、数量、金额等),解析后存入内存进行数据校验、格式转换、最终同步至数据库。
二、核心问题
| 问题类型 | 具体表现 |
|---|---|
| 字段类型不合理 | 可使用 int 的字段(如 ID、数量,范围 0-100 万)误用 long,内存占用翻倍 |
| 内存配置不足 | 默认堆内存(-Xms2g -Xmx2g),解析 30 万行即报 OutOfMemoryError |
| GC 性能差 | Young GC 每分钟触发 20+ 次,Full GC 每 10 分钟 1 次,单次 GC 停顿超 500ms |
| 解析逻辑低效 | 一次性加载全量数据到 List,临时对象(如 BigDecimal)重复创建 |
三、分层优化方案
1. 字段优化
问题本质
long 占 8 字节,int 占 4 字节,100 万行数据中单个字段优化可节省 400 万字节(≈3.8MB),多字段累加效果显著。
优化代码(前后对比)
// 优化前
private Long userId; // 实际范围 0-100万,误用 Long
private Long quantity; // 实际范围 0-100万,误用 Long
private BigDecimal amount; // 金额字段,需保留 BigDecimal
// 优化后
private Integer userId; // 改用 Integer,节省 4 字节/字段
private Integer quantity; // 改用 Integer,节省 4 字节/字段
private BigDecimal amount; // 金额字段,需保留 BigDecimal
// getter/setter...
2. GC 参数优化
原系统老版本配置的是 CMS 收集器。我之所以在重构优化时坚持换成 G1,主要是因为 CMS 的底层机制在‘大批量 Excel 解析’这种特定业务场景下,存在不可调和的致命痛点:
- 第一,CMS 无法解决内存碎片问题。 CMS 采用的是‘标记-清除’算法,而我们的 Job 在解析数十万行金融数据时,会高频创建并销毁海量的临时变量和对象。CMS 频繁回收后,会在老年代产生严重的内存碎片。当后续大批量的校验结果或
ArrayList尝试晋升到老年代时,往往会因为找不到连续的物理空间,迫使 CMS 退化为单线程的 Serial Old,从而引发长达数秒的 Full GC 停顿,这是线上无法接受的。而 G1 采用基于 Region 的复制算法,从局部来看它是有界的内存整理,天然没有碎片。- 第二,大堆场景下的性价比。 为了防止突发的大数据量导致 OOM,我们将堆内存扩容到了 4G。CMS 面对大堆时,并发扫描的开销会显著增加,吞吐量下滑。而 G1 专为大堆设计,它通过‘垃圾优先’的 Region 局部回收机制,配合
-XX:MaxGCPauseMillis=200参数,可以做到停顿时间可预测。总结来说,换用 G1 是为了利用它的复制算法消除内存碎片,并利用它的可预测停顿保障大堆环境下的系统稳定性,配合我们代码层面的组件升级,最终把 Full GC 降到了 0 次。
推荐 JVM 参数(生产环境)
java -Xms4g -Xmx4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200
- 初始/最大堆 4GB,避免动态扩容
- 使用 G1 收集器,优化大堆 GC 效率
- 目标 GC 停顿时间 200ms
- 【jvm调优案例+1】 https://www.bilibili.com/video/BV1Sx4y1U7rp
3. 解析逻辑优化:批量处理 + 对象复用
问题本质
- 全量加载数据到 List:内存峰值过高(100 万行一次性加载占 2-3GB)
- 重复创建对象:每行
new ExcelData()、new BigDecimal(),触发频繁 Young GC
优化代码(批量 + 对象池)以下是示例代码
import org.apache.commons.pool2.BasePooledObjectFactory;
import org.apache.commons.pool2.ObjectPool;
import org.apache.commons.pool2.impl.GenericObjectPool;
// 对象池复用 ExcelData 对象
class ExcelDataFactory extends BasePooledObjectFactory<ExcelData> {
@Override
public ExcelData create() {
return new ExcelData();
}
}
// 批量处理(每批 10000 行)
public void parseExcelBatch(File file, int batchSize) {
ObjectPool<ExcelData> pool = new GenericObjectPool<>(new ExcelDataFactory());
List<ExcelData> batch = new ArrayList<>(batchSize);
try (InputStream is = new FileInputStream(file)) {
// 使用 SXSSF 流式读取
Workbook workbook = new SXSSFWorkbook(100); // 内存保留 100 行
Sheet sheet = workbook.getSheetAt(0);
for (Row row : sheet) {
ExcelData data = pool.borrowObject();
// 填充 data(复用对象)
data.setId((int) row.getCell(0).getNumericCellValue());
// ... 其他字段
batch.add(data);
if (batch.size() >= batchSize) {
saveToDatabase(batch); // 批量入库
batch.forEach(pool::returnObject); // 归还对象池
batch.clear();
}
}
if (!batch.isEmpty()) {
saveToDatabase(batch);
batch.forEach(pool::returnObject);
}
}
}
4. 解析器优化:POI SXSSF 替代 XSSF
问题本质
传统 XSSFWorkbook(POI)会全量加载 Excel 到内存,100 万行文件可能占用 10GB+ 内存;SXSSFWorkbook 通过"内存 + 临时文件"机制,仅保留指定行数(如 100 行)在内存。这个和我在 AS 项目刚进项目时犯的错一样。组长让我写一个处理训练集和测试集的脚本,我错误地把文件都搂到内存里再进行批量处理。这种方式实际上是非常耗费内存的,虽然我们每张图片都只有 220 KB 到 400 KB 左右的大小。后面这一部分的优化是采用文件流的方式去读取写入文件,极大地减少了内存资源的消耗
优化代码(解析器对比)
// 优化前:XSSF 全量加载(内存爆炸风险)
public void parseWithXSSF(File file) throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(file))) {
Sheet sheet = workbook.getSheetAt(0);
for (Row row : sheet) {
// 全量加载到内存
}
}
}
// 优化后:SXSSF 流式加载(内存可控)
public void parseWithSXSSF(File file) throws Exception {
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) { // 内存保留 100 行
Sheet sheet = workbook.getSheetAt(0);
for (Row row : sheet) {
// 流式读取,临时文件自动清理
}
}
}
四、优化效果对比
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 最大堆内存占用 | 6GB(仍 OOM) | 3.2GB | 降低 47% |
| 100 万行解析耗时 | 180 秒(中途 OOM) | 55 秒 | 提升 69% |
| Young GC 次数 | 120 次 | 35 次 | 减少 71% |
| Full GC 次数 | 8 次 | 0 次 | 完全消除 |
五、进阶优化方向(可扩展)
1. 内存映射文件(超大 Excel 场景)
对于 500 万行+ Excel,使用 MappedByteBuffer(NIO)替代传统 IO,直接映射文件到内存地址空间,避免 JVM 堆内存占用。
try (FileChannel channel = FileChannel.open(Paths.get("large.xlsx"), StandardOpenOption.READ)) {
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size());
// 解析 buffer 数据(需自定义 Excel 格式解析逻辑,适合 CSV/TSV 类文件)
}
2. 异步多阶段处理
拆分"读文件 → 解析 → 入库"为 3 个阶段,用线程池异步执行,提升吞吐量:
ExecutorService executor = Executors.newFixedThreadPool(3);
// 1. 读文件(线程 1)
Future<InputStream> readFuture = executor.submit(() -> new FileInputStream(file));
// 2. 解析(线程 2)
Future<List<ExcelData>> parseFuture = executor.submit(() -> parseInputStream(readFuture.get()));
// 3. 入库(线程 3)
Future<Integer> saveFuture = executor.submit(() -> saveToDatabase(parseFuture.get()));
3. 监控与报警
集成 JMX 监控关键指标,设置阈值报警:
- 监控指标:堆内存使用率、Young GC 次数、对象创建速率、SXSSF 临时文件大小
- 报警触发:堆内存使用率 > 80%、单次 GC 停顿 > 300ms 时,通过 Prometheus + Grafana 告警
六、调优核心总结
Excel 解析场景的 JVM 调优遵循"从源头减少内存占用 → 优化内存分配 → 降低 GC 开销"的逻辑:
- 基础层:字段类型精准化(最有效、无额外成本)
- 逻辑层:批量处理 + 对象复用(降低内存峰值)
- 工具层:SXSSF 替代 XSSF(第三方库选型优化)
- 配置层:堆与 GC 参数精细化(保障稳定运行)
- 监控层:实时监控 + 报警(提前规避风险)
附录
内存估算实战:如何计算单条记录的内存占用
在实际调优中,能够快速估算内存占用是一项重要技能。以本次案例为例,我们来详细计算:
场景设定
- Excel 文件:50 万行数据,假设有 50 个字段
- 实际情况:只有 3 个字段有值(ID、姓名、年龄),其余 47 个字段为空
单条记录内存计算
对象头(Object Header)
- 64 位 JVM:16 字节(所有对象都有)
- 用于存储对象元数据(类指针、标记信息等)
有值的字段
- ID(long 类型):8 字节
- 姓名(String,平均 10 字符):10 字节(字符数组)+ 对象头 16 字节 + 其他开销 ≈ 40 字节
- 年龄(int 类型):4 字节
- 小计:8 + 40 + 4 = 52 字节
空字段引用
- 47 个空字段,每个引用占 4 字节
- 小计:47 × 4 = 188 字节
对齐填充(Padding)
- JVM 要求对象大小必须是 8 的倍数
- 当前总和:16 + 52 + 188 = 256 字节(已对齐)
实际计算结果
- 单条记录内存占用:约 224-256 字节
- 50 万条数据总内存:224 × 500,000 ≈ 112 MB(仅对象本身)
关键发现
- 空字段的引用占用大量内存(188 字节,占 75%)
- 如果将字段类型从 Long 改为 Integer,每个字段节省 4 字节
- 优化后单条记录可降至约 150 字节,节省 33% 内存
为什么这个估算很重要?
- 快速定位问题:通过估算可以判断是数据量问题还是代码问题
- 验证优化效果:优化前后可以对比理论值与实际值
- 容量规划:帮助合理配置 JVM 堆内存大小
