导出改成每批 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 的固定阈值。 本次 buffer 在 append 扩容阶段就失败了,尚未执行到最后的 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=true。verify 逐行检查内容和顺序,最后检查 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;
}
}