一、问题背景:测试突发 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 工具精准定位根因,再从代码、业务、配置三个层面优化,最终解决了问题。

这次排查也给了我们一些反思:

  1. 重视字符串操作:Java 中 String 是不可变对象,频繁拼接需使用 StringBuilder,避免临时对象浪费内存;

  2. 大数量数据处理必分片:任何涉及万级以上数据的场景,都要考虑分片处理,避免全量加载;

  3. 善用工具:MAT 是排查内存泄漏的利器,掌握其基本使用(Leak Suspects、Dominator Tree)能大幅提升排查效率;

  4. 上线前充分测试:新功能上线前,需进行压测和边界测试,尤其是涉及大量数据处理的功能,提前暴露内存问题。

希望这次实战经验能帮助大家少走弯路,遇到 OOM 问题时能快速定位、高效解决!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐