一次OOM 排查与修复实战:从 MAT 报告到代码优化
一、问题背景:测试突发 OOM
某天下午,监控告警突然响起,提示核心服务的内存使用率飙升至 98%,随后服务触发 OOM 异常重启。查看服务日志,发现关键报错信息:
java.lang.OutOfMemoryError
at java.util.Arrays.copyOf([CI)[C (Arrays.java:3332)
at java.lang.AbstractStringBuilder.ensureCapacityInternal(I)V (AbstractStringBuilder.java:124)
at java.lang.AbstractStringBuilder.append(C)Ljava/lang/AbstractStringBuilder; (AbstractStringBuilder.java:649)
at java.lang.StringBuilder.append(C)Ljava/lang/StringBuilder; (StringBuilder.java:207)
at java.util.regex.Matcher.appendReplacement(Ljava/lang/StringBuffer;Ljava/lang/String;)Ljava/util/regex/Matcher; (Matcher.java:883)
从报错栈信息来看,问题出在字符串拼接和正则替换环节。结合业务场景,该服务近期上线了 Excel 数据导入功能,推测 OOM 与该功能的大量数据处理有关。
二、排查过程:MAT 分析堆快照,定位根因
为了精准定位内存泄漏点,我们在服务启动参数中添加了 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof,当再次发生 OOM 时,生成堆快照文件,然后使用 Eclipse Memory Analyzer(MAT)工具进行分析。
2.1 核心问题定位:线程持有超大对象
通过 MAT 生成的「Leak Suspects」报告,很快发现了关键线索:
线程 java.lang.Thread @ 0x665cabd20 R-pool-27 持有本地变量总大小达 5.26GB(占堆内存 95.70%),内存主要堆积在 java.lang.Thread 实例中,且该线程的调用栈指向 Excel 导入任务的处理逻辑。
2.2 内存堆积载体:大量重复对象与超大字符串
进一步分析「Dominator Tree」(支配树),发现:
-
10020 个
com.huawei.drms.facilitymgmtservice.domain.importv2.po.sub.ExcelDigitPortPo实例,每个实例持有大量错误信息字符串,累计占用内存超 5GB; -
char[]数组占堆内存 5.25GB,是字符串底层存储的核心,推测是大量超长字符串导致频繁扩容,无法被 GC 回收; -
调用栈最终指向
ErrorMsgConfig.replaceArgs方法,该方法负责错误信息的参数占位替换,是字符串拼接的核心场景。
2.3 代码溯源:replaceArgs 方法的隐患
查看 replaceArgs 原始代码,发现两个致命问题:
private String replaceArgs(String value, Set<I18NCreatDto> propertiesList, Map<String, String> argLangMap) {
if (StringUtils.isEmpty(value)) {
return key; // 存在未定义变量 bug
}
if (args == null || args.length == 0) {
return value;
}
for (int i = 0; i < args.length; i++) {
// 每次 replace 都会生成新 String,循环中产生大量临时对象
value = value.replace("{" + i + '}', convertArg(args[i], propertiesList, argLangMap));
}
return value;
}
1. 字符串拼接方式不当:使用 String.replace 方法,每次调用都会生成新的 String 对象(底层是新的 char[]),循环处理时会产生大量临时对象,且超长字符串会导致 Arrays.copyOf 频繁扩容,占用大量内存;
2. 业务逻辑隐患:未限制字符串长度,Excel 导入的超长单元格数据会直接参与拼接,导致单个错误信息字符串体积超大;
3. 明显 bug:value 为空时返回未定义变量 key,可能触发空指针异常。
三、优化方案:从代码到配置的全方位修复
结合排查结果,我们从「代码逻辑优化」「内存回收加速」「配置兜底」三个维度进行修复,核心目标是减少临时对象、避免内存堆积。
3.1 核心优化:重构 replaceArgs 方法
针对字符串拼接问题,改用 StringBuilder 原地操作,避免临时对象产生;同时修复 bug,提升代码鲁棒性:
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\d+\\}");
private String replaceArgs(String value, Set<I18NCreatDto> propertiesList, Map<String, String> argLangMap) {
if (StringUtils.isEmpty(value)) {
return ""; // 修复未定义变量 bug
}
if (args == null || args.length == 0) {
return value;
}
// 使用 StringBuilder 原地操作,减少临时对象
StringBuilder sb = new StringBuilder(value);
Matcher matcher = PLACEHOLDER_PATTERN.matcher(sb);
while (matcher.find()) {
String placeholder = matcher.group();
int argIndex = Integer.parseInt(placeholder.substring(1, placeholder.length() - 1));
if (argIndex < 0 || argIndex >= args.length) {
continue; // 索引越界则跳过
}
// 调用 convertArg 获取参数值,空值兜底
String argValue = convertArg(args[argIndex], propertiesList, argLangMap);
argValue = StringUtils.defaultString(argValue);
// 原地替换占位符
sb.replace(matcher.start(), matcher.end(), argValue);
matcher.reset(sb); // 重置匹配器,适配替换后的字符串
}
String result = sb.toString();
// 主动释放引用,加速 GC
sb.setLength(0);
sb = null;
return result;
}
关键优化点:
-
用
StringBuilder替代String.replace,全程仅操作一个字符数组,无临时 String 生成; -
预编译正则表达式
PLACEHOLDER_PATTERN,避免循环中重复编译,提升性能; -
添加索引越界校验,避免异常;空值兜底,防止空指针。
3.2 业务层优化:Excel 数据分片处理
原导入逻辑一次性加载 1 万+ 条 Excel 数据,导致大量 ExcelDigitPortPo 实例堆积在内存中。优化为分片处理,每 200 条数据为一批,处理完后立即释放引用:
public ImportResult saveCheckSuccessData(ImportObjectEnum objectEnum, ImportOperatorEnum operatorEnum,
List<BaseExcelPo> poList, TaskContext context) {
int batchSize = 200;
List<BaseExcelPo> batchList = new ArrayList<>(batchSize);
for (BaseExcelPo po : poList) {
batchList.add(po);
if (batchList.size() >= batchSize) {
processBatch(batchList, objectEnum, operatorEnum, context);
batchList.clear(); // 清空分片,释放内存
System.gc(); // 主动触发 GC(应急兜底)
}
}
if (!batchList.isEmpty()) {
processBatch(batchList, objectEnum, operatorEnum, context);
batchList.clear();
}
return result;
}
// 分片处理核心逻辑
private void processBatch(List<BaseExcelPo> batchList, ImportObjectEnum objectEnum,
ImportOperatorEnum operatorEnum, TaskContext context) {
try {
batchList.forEach(po -> {
try {
// 业务处理逻辑
} catch (Exception e) {
po.addErrorConfig(errorMsgConfig);
po.clearErrorMsgReference(); // 清空大对象引用,加速 GC
}
});
} finally {
batchList = null; // 强制释放引用
}
}
3.3 配置层兜底:JVM 参数优化
针对大对象回收,调整 JVM 参数,使用 G1GC 垃圾收集器,提升大内存回收效率:
-Xmx16G -Xms8G -XX:+UseG1GC -XX:G1HeapRegionSize=64M -XX:+UseStringDeduplication -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof
参数说明:
-
-Xmx16G:设置最大堆内存为 16GB,适配大数量 Excel 导入; -
-XX:+UseG1GC:启用 G1GC,适合大内存、大对象场景,回收效率更高; -
-XX:+UseStringDeduplication:启用字符串去重,减少重复char[]占用的内存。
四、测试验证:确保优化效果
优化后,我们通过单元测试和压测验证效果,确保问题彻底解决。
4.1 单元测试:覆盖核心场景
基于 JUnit 5 + Mockito 编写单元测试,覆盖空值、无参数、正常替换、索引越界、超长字符串等场景:
4.2 压测验证:内存稳定无泄漏
使用 JMeter 模拟 10 并发、每次导入 1 万条 Excel 数据的场景,压测 1 小时,观察内存变化:
-
内存峰值稳定在 8GB 左右,无持续上升趋势;
-
GC 频率正常,无 Full GC 频繁触发;
-
服务无 OOM 异常,响应时间稳定在 500ms 以内。
五、总结与反思
本次 OOM 问题的核心是「字符串拼接方式不当导致临时对象堆积」+「全量数据加载导致实例无法回收」。通过 MAT 工具精准定位根因,再从代码、业务、配置三个层面优化,最终解决了问题。
这次排查也给了我们一些反思:
-
重视字符串操作:Java 中 String 是不可变对象,频繁拼接需使用 StringBuilder,避免临时对象浪费内存;
-
大数量数据处理必分片:任何涉及万级以上数据的场景,都要考虑分片处理,避免全量加载;
-
善用工具:MAT 是排查内存泄漏的利器,掌握其基本使用(Leak Suspects、Dominator Tree)能大幅提升排查效率;
-
上线前充分测试:新功能上线前,需进行压测和边界测试,尤其是涉及大量数据处理的功能,提前暴露内存问题。
希望这次实战经验能帮助大家少走弯路,遇到 OOM 问题时能快速定位、高效解决!
更多推荐




所有评论(0)