Java 分批导出仍然 OOM?用 32 MiB 堆复现三种 CSV 写法

导出改成每批 1,000 行后,仍然报 Java heap space,可以先检查分批之后的代码:是不是又把所有行,或者整份输出,重新攒回了内存?

下面用一个纯 JDK 的 CSV 实验验证这件事。同样以 20,000 行为目标、同样限制 32 MiB Java 堆,两种累计数据的写法发生 OOM,按批写文件的写法完成了全部数据。文末附完整源码,可以直接复制编译;实验不连接数据库,也没使用 Excel 库。

三种写法,差别在旧数据去了哪里

三个方案调用同一个 readBatch:每次新建最多 1,000 行,每行的内容由行号确定。这里模拟读取一批数据,暂时排除 JDBC 预取、ORM 缓存等因素,只看处理代码自己保留了什么。

分批读,最后放进一个大 List

java 复制代码
List<String> allRows = new ArrayList<>();
for (int start = 1; start <= total;
        start += batchSize) {
    List<String> batch = readBatch(
        start, total, batchSize, width);
    // 旧行仍被 allRows 引用
    allRows.addAll(batch);
}
// 循环结束后才统一写文件

读完第一批,allRows 留着 1,000 行;第二批完成后,就留下 2,000 行。batch 这个局部变量用完了,不代表其中的行能被回收,因为 allRows 仍然引用它们。

addAll 把集合中的元素加入列表,列表也会随添加自动扩容。这里即使将批大小从 1,000 改成 500,也没有消除最终累计全部行的需求。ArrayList API

不保留旧行,却保留整份输出

java 复制代码
StringBuilder csv =
    new StringBuilder("id,payload\n");
for (int start = 1; start <= total;
        start += batchSize) {
    List<String> batch = readBatch(
        start, total, batchSize, width);
    for (String row : batch) {
        // csv 会保留整份输出
        csv.append(row).append('\n');
    }
}
Files.writeString(
    output, csv.toString(),
    StandardCharsets.UTF_8);

这次没有大 List,问题转移到了 csv:所有批次的文本都先保存在字符串缓冲区,循环结束后才统一写文件。它会随着输出总量增长,容量不足时还需要扩容。StringBuilder API

所以查完大集合,还要查有没有累计完整 CSV、JSON 或字节数组。旧行对象能回收,不等于这行的内容已经离开内存。

读完一批,就交给文件 Writer

java 复制代码
try (BufferedWriter writer =
    Files.newBufferedWriter(
        output, StandardCharsets.UTF_8)) {
    writer.write("id,payload\n");
    for (int start = 1; start <= total;
            start += batchSize) {
        List<String> batch = readBatch(
            start, total,
            batchSize, width);
        for (String row : batch) {
            // 底层是文件,持续写出当前行
            writer.write(row);
            writer.write('\n');
        }
    }
}

第三种写法不保留全部历史行,也不构建完整输出字符串。BufferedWriter 用缓冲分段写入底层文件,结束时由 try-with-resources 关闭。BufferedWriter API

以上是完整源码中的关键片段,省略了参数声明、数据生成和日志。output 是文件路径,width 是每行载荷长度。旧批次不再被引用后才有机会回收,不代表循环进入下一轮时就立即释放,也不需要每批手动调用 System.gc()

用同一组数据复现 OOM

本次环境为 Windows / Oracle JDK 21.0.11 / 64 位 HotSpot。三个方案的目标都是 20,000 行,每批 1,000 行,每行由行号和 2,048 个 ASCII 载荷字符构成,输出统一使用 UTF-8 与 LF 换行。

把文末代码保存为 UTF-8 编码的 ExportMemoryLab.java,在该目录打开终端,依次运行:

shell 复制代码
javac -encoding UTF-8 ExportMemoryLab.java

java -Xms32m -Xmx32m -cp . ExportMemoryLab export rows 20000 1000 2048 output/large-rows.csv
java -Xms32m -Xmx32m -cp . ExportMemoryLab export buffer 20000 1000 2048 output/large-buffer.csv
java -Xms32m -Xmx32m -cp . ExportMemoryLab export stream 20000 1000 2048 output/large-stream.csv
java -Xms32m -Xmx32m -cp . ExportMemoryLab verify output/large-stream.csv 20000 2048

需要包含 javac 的 JDK,不需要 Maven 或第三方依赖。源码使用 Java 17+ API,本次数字来自 JDK 21。输出目录由程序创建;再次执行会覆盖这里指定的同名实验文件。前两个大样本的 OOM 是预期观察,执行完一条后继续运行下一条。

-Xmx32m 限制的是 Java 堆;本次日志中的 maxHeapBytes 为 33,554,432。它不代表整个 Java 进程只占 32 MiB。java 命令参数说明

写法 本次实际结果
rows:累计全部行 最后完整批日志为 14,000 行;生成下一批时 OOM
buffer:累计整份文本 最后完整批日志为 8,000 行;随后追加扩容时 OOM
stream:按批写文件 20,000 行完成,41,088,905 字节,约 39.19 MiB

14,000 和 8,000 都是最后一批完成日志,不是精确的失败行号,也不是跨机器、JDK 或 GC 的固定阈值。 本次 bufferappend 扩容阶段就失败了,尚未执行到最后的 toString(),不能把这次 OOM 归因于后者。

下面节选自 stream 的实际导出与校验输出:

text 复制代码
BATCH_DONE mode=stream rows=20000
SUCCESS mode=stream rows=20000 bytes=41088905
VERIFIED rows=20000 linesIncludingHeader=20001 bytes=41088905 firstId=1 lastId=20000 sha256=d0c9c82d2532a68345b79a0282c5eaca94504be8234740fdd2a1019d693fea06

文件比堆大,不意味着必须把文件一次装进堆。这个实验验证的是数据保留方式的差别,没有比较耗时、吞吐量或记录进程内存曲线。

确认不是少写了数据

只看到"没有 OOM"还不够。先把总行数降到 2,500,让三个方案都能在相同堆上限下完成,再对照输出。末批只有 500 行,也能检查不足一整批时的收尾。

shell 复制代码
java -Xms32m -Xmx32m -cp . ExportMemoryLab export rows 2500 1000 2048 output/small-rows.csv
java -Xms32m -Xmx32m -cp . ExportMemoryLab export buffer 2500 1000 2048 output/small-buffer.csv
java -Xms32m -Xmx32m -cp . ExportMemoryLab export stream 2500 1000 2048 output/small-stream.csv

java -Xms32m -Xmx32m -cp . ExportMemoryLab verify output/small-rows.csv 2500 2048
java -Xms32m -Xmx32m -cp . ExportMemoryLab verify output/small-buffer.csv 2500 2048
java -Xms32m -Xmx32m -cp . ExportMemoryLab verify output/small-stream.csv 2500 2048

java -Xms32m -Xmx32m -cp . ExportMemoryLab compare output/small-rows.csv output/small-buffer.csv
java -Xms32m -Xmx32m -cp . ExportMemoryLab compare output/small-rows.csv output/small-stream.csv

这组小样本的实测结果是:三个文件都含 2,500 条数据、2,501 行(含表头),各 5,133,904 字节;两个 compare 都输出 BYTE_IDENTICAL=trueverify 逐行检查内容和顺序,最后检查 EOF,不能只用文件大小相同替代内容校验。

三个小文件的 SHA-256 均为:

text 复制代码
d9073c59d41a84c3172c0e2bb73b459f3cb9b59d56953b37c6745a6d44262f38

大样本的 stream 也做了同样的逐行检查,包含表头共 20,001 行。这样才排除了"文件写得少,所以省内存"的解释。

放回实际项目,还要查哪些位置

沿着 读取一批 → 转换一批 → 写出一批 → 谁还持有它 看一遍,通常比只盯着分页大小更容易找到问题。

  • 循环外的集合和输出缓冲。 看有没有保存历史行的 List、累计整份文本的 StringBuilder,或累计所有字节的内存输出流。底层如果仍是内存输出流,外面再套 Writer 也不会自动解决累积;频繁 flush() 也清不掉其他对象保存的数据。
  • 读取端和中间处理。 本实验没有验证 JDBC 驱动预取、ORM 上下文、业务缓存或 Excel 库内部保留。真实项目里这些需要分别核对;单行特别大、批次过大,同样可能超过内存预算。
  • 异步移交之后的数据。 提交到线程池不代表已经写完,等待执行的任务也可能继续持有批次。这里只指出排查位置,本文的三个方案都是同步实验,不用它们推导异步任务的容量或性能。

示例中的 CSV 载荷字段不含逗号、引号、换行,行内逗号仅作字段分隔符;实际业务仍要正确处理 CSV 转义和不可信字段。文件写入调用、缓冲刷新与持久化刷盘也不是同一件事,代码没有实现生产导出的失败清理、取消或重试策略。

内存曲线有波动也不能单凭这一点认定泄漏。这里真正要查的是:处理完的数据,是否仍被某个对象持续保留。批大小只控制一次拿多少,整个链路的保留方式才决定哪些数据一直留在内存里。

复现源码

文件名为 ExportMemoryLab.java。下面是本次已运行实验的完整原始源码,包含三个模式、逐行校验和逐字节比较;复制后即可使用上面的命令。

java 复制代码
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;

/**
 * 纯 JDK 合成 CSV 实验:分批创建数据,不等于整个导出过程有界保留。
 * 这里只比较对象引用与输出缓冲区的保留方式,不连接数据库或 Excel 库。
 */
public final class ExportMemoryLab {
    private static final String HEADER = "id,payload\n";

    public static void main(String[] args) throws Exception {
        if (args.length == 0) {
            throw new IllegalArgumentException("export <rows|buffer|stream> <行数> <批大小> <载荷字符数> <输出文件>;或 verify <文件> <行数> <载荷字符数>;或 compare <文件一> <文件二>");
        }
        switch (args[0]) {
            case "export" -> export(args);
            case "verify" -> verify(Path.of(args[1]), Integer.parseInt(args[2]), Integer.parseInt(args[3]));
            case "compare" -> {
                long mismatch = Files.mismatch(Path.of(args[1]), Path.of(args[2]));
                if (mismatch != -1) throw new AssertionError("首个字节差异位置=" + mismatch);
                System.out.println("BYTE_IDENTICAL=true");
            }
            default -> throw new IllegalArgumentException("未知命令: " + args[0]);
        }
    }

    private static void export(String[] args) throws IOException {
        if (args.length != 6) throw new IllegalArgumentException("export 需要 5 个参数");
        String mode = args[1];
        int totalRows = positive(args[2]);
        int batchSize = positive(args[3]);
        int payloadChars = positive(args[4]);
        Path output = Path.of(args[5]);
        Files.createDirectories(output.toAbsolutePath().getParent());
        System.out.printf("CONFIG mode=%s rows=%d batch=%d payloadChars=%d maxHeapBytes=%d%n",
                mode, totalRows, batchSize, payloadChars, Runtime.getRuntime().maxMemory());

        switch (mode) {
            case "rows" -> retainAllRows(totalRows, batchSize, payloadChars, output);
            case "buffer" -> retainWholeOutput(totalRows, batchSize, payloadChars, output);
            case "stream" -> writeByBatch(totalRows, batchSize, payloadChars, output);
            default -> throw new IllegalArgumentException("未知模式: " + mode);
        }
        System.out.printf("SUCCESS mode=%s rows=%d bytes=%d%n", mode, totalRows, Files.size(output));
    }

    // 反例一:每次只生成一批,但 allRows 一直持有历史行,批次结束也不能释放。
    private static void retainAllRows(int total, int batchSize, int width, Path output) throws IOException {
        List<String> allRows = new ArrayList<>();
        for (int start = 1; start <= total; start += batchSize) {
            List<String> batch = readBatch(start, total, batchSize, width);
            allRows.addAll(batch);
            logBatch("rows", start + batch.size() - 1);
        }
        try (BufferedWriter writer = Files.newBufferedWriter(output, StandardCharsets.UTF_8)) {
            writer.write(HEADER);
            for (String row : allRows) writeLine(writer, row);
        }
    }

    // 反例二:不再保存所有行对象,但 StringBuilder 保存了完整 CSV,依旧随总量增长。
    private static void retainWholeOutput(int total, int batchSize, int width, Path output) throws IOException {
        StringBuilder csv = new StringBuilder(HEADER);
        for (int start = 1; start <= total; start += batchSize) {
            List<String> batch = readBatch(start, total, batchSize, width);
            for (String row : batch) csv.append(row).append('\n');
            logBatch("buffer", start + batch.size() - 1);
        }
        // toString() 还可能产生额外副本;本次大样本实际上在 append 扩容阶段已失败。
        Files.writeString(output, csv.toString(), StandardCharsets.UTF_8);
    }

    // 对照:每批写入文件,方法中不保留旧批次,也不构建完整输出字符串。
    private static void writeByBatch(int total, int batchSize, int width, Path output) throws IOException {
        try (BufferedWriter writer = Files.newBufferedWriter(output, StandardCharsets.UTF_8)) {
            writer.write(HEADER);
            for (int start = 1; start <= total; start += batchSize) {
                List<String> batch = readBatch(start, total, batchSize, width);
                for (String row : batch) writeLine(writer, row);
                logBatch("stream", start + batch.size() - 1);
            }
        }
    }

    // 模拟分页读取:每次新建最多 batchSize 行。每行内容由行号确定,三个模式完全一致。
    // 数据使用 ASCII 且不含逗号、引号和换行,因此此实验无需实现通用 CSV 转义。
    private static List<String> readBatch(int start, int total, int batchSize, int width) {
        int count = Math.min(batchSize, total - start + 1);
        List<String> batch = new ArrayList<>(count);
        for (int i = 0; i < count; i++) batch.add(makeRow(start + i, width));
        return batch;
    }

    private static String makeRow(int id, int width) {
        char[] payload = new char[width];
        for (int i = 0; i < width; i++) payload[i] = (char) ('a' + (id + i) % 26);
        return id + "," + new String(payload);
    }

    private static void writeLine(BufferedWriter writer, String row) throws IOException {
        writer.write(row);
        writer.write('\n'); // 所有模式统一 UTF-8、LF,保证输出可以逐字节比较。
    }

    private static void logBatch(String mode, int completed) {
        System.out.printf("BATCH_DONE mode=%s rows=%d%n", mode, completed);
    }

    // 独立进程逐行校验所有数据、顺序与末尾 EOF,避免正确版只是少写了数据。
    private static void verify(Path file, int expectedRows, int width) throws Exception {
        try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
            if (!"id,payload".equals(reader.readLine())) throw new AssertionError("表头不一致");
            for (int id = 1; id <= expectedRows; id++) {
                if (!makeRow(id, width).equals(reader.readLine())) throw new AssertionError("数据不一致,行号=" + id);
            }
            if (reader.readLine() != null) throw new AssertionError("出现额外数据行");
        }
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        try (InputStream input = Files.newInputStream(file)) {
            byte[] buffer = new byte[8192];
            int read;
            while ((read = input.read(buffer)) != -1) digest.update(buffer, 0, read);
        }
        System.out.printf("VERIFIED rows=%d linesIncludingHeader=%d bytes=%d firstId=1 lastId=%d sha256=%s%n",
                expectedRows, expectedRows + 1, Files.size(file), expectedRows, HexFormat.of().formatHex(digest.digest()));
    }

    private static int positive(String value) {
        int result = Integer.parseInt(value);
        if (result <= 0) throw new IllegalArgumentException("参数必须为正整数");
        return result;
    }
}
相关推荐
秋饼38 分钟前
Spring AI 2.0 接入 DeepSeek V4.1 Flash 生产级实战
java·ai·技术分享·后端开发
shehuiyuelaiyuehao1 小时前
算法43,外观数列,模拟算法+双指针
java·算法
lhldsg1 小时前
多商户团购系统源码:技术架构选型与二次开发实战指南
java·小程序·架构
亦暖筑序1 小时前
AgentScope Java 实战:@Tool 方法里到底该不该写业务逻辑?
java·agent·ai编程
脉动数据行情11 小时前
Java SpringBoot 国际期货批量采集实践 美原油 / 黄金 / 指数期货定时落库
java·开发语言·spring boot
Zane19941 小时前
写Stream时踩过的坑:中间操作不会真正执行,直到你调用这一个方法
java·后端
EXI-小洲2 小时前
Spring AI (第二章)大模型对话上下文记忆
java·人工智能·spring
扬大平仔2 小时前
# 小深:用 AgentScope Java 2.0 Harness 做私人助手(上)mysql
java·开发语言·mysql
阿里云基础软件2 小时前
一句话看透 JVM,SysOM 诊断 Skill 新增 Java 应用诊断能力
java·开发语言·jvm·人工智能·操作系统·sysom 诊断 skill