摘要:大文件读写时,堆内存会频繁触发 GC(因为要创建大量 byte ),而 Direct Memory 的 GC 压力小;内存映射文件(MappedByteBuffer)本质就是基于 Direct Memory 实现的,能把文件直接映射到内存,读写速度接近内存操作
JVM 的直接内存 (Direct Memory) 是 JVM 在用户态 “另起炉灶” 申请的内存,目的是提升和内核交互的效率,而非变成内核态内存。
大文件读写时,堆内存会频繁触发 GC(因为要创建大量 byte ),而 Direct Memory 的 GC 压力小;内存映射文件(MappedByteBuffer)本质就是基于 Direct Memory 实现的,能把文件直接映射到内存,读写速度接近内存操作。Direct Memory 的核心用途是高性能 IO 场景(网络、文件、大数据传输),核心价值是减少用户态 - 内核态的数据拷贝;普通业务开发几乎不用,只有高并发、高吞吐、低延迟的 IO 场景才需要主动使用;使用时重点防范内存泄漏,设置合理上限,并做好监控。简单记:只要你的功能核心诉求是 “快”(IO 吞吐量 / 延迟),且数据量较大,就可以考虑用 Direct Memory;如果只是普通业务逻辑,堆内存就够了。
用MappedByteBuffer做
分片内存映射
(每次映射 100MB~200MB,避免单次映射过大);按行解析映射的字节数据,逐行处理(插入数据库 / 校验);处理完一片后释放映射内存,再映射下一片,循环直到文件处理完成;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.RestController;import org.springframework.web.multipart.MultipartFile;import java.io.File;import java.io.RandomAccessFile;import java.nio.MappedByteBuffer;import java.nio.channels.FileChannel;import java.nio.charset.StandardCharsets;import java.util.ArrayList;import java.util.List;/*** 2GB csv大文件导入示例(Spring Boot)*/@RestControllerpublic class LargeCsvImportController {// 每次映射的大小(100MB,可根据服务器内存调整)private static final long MAP_SIZE = 100 * 1024 * 1024L;/*** CSV大文件导入接口*/@PostMapping("/import/large-csv")public String importLargeCsv(@RequestParam("file") MultipartFile multipartFile) {// 步骤1:将MultipartFile转成本地临时文件(MappedByteBuffer需要文件路径)File tempFile = null;try {tempFile = File.createTempFile("large-csv-", ".csv");multipartFile.transferTo(tempFile);// 步骤2:初始化文件通道和总大小long fileSize = tempFile.length;try (RandomAccessFile raf = new RandomAccessFile(tempFile, "r");FileChannel channel = raf.getChannel) {long currentPosition = 0;String remainingLine = ""; // 保存分片末尾的不完整行// 步骤3:循环分片映射文件while (currentPosition batchList = new ArrayList; for (String line : lines) { if (line.isEmpty) continue; // 跳过空行 // 解析CSV行(示例:拆分逗号分隔的字段) String fields = line.split(","); // 业务校验、转换等逻辑... batchList.add(line); // 批量提交(每1000行提交一次) if (batchList.size >= 1000) { // 调用DAO层批量插入数据库 // jdbcTemplate.batchUpdate("INSERT INTO table (...) VALUES (...)", batchList); System.out.println("批量处理1000行,内容:" + batchList.get(0)); batchList.clear; } } // 处理剩余不足1000行的数据 if (!batchList.isEmpty) { // jdbcTemplate.batchUpdate(...) System.out.println("处理剩余" + batchList.size + "行"); } } /** * 手动释放MappedByteBuffer(解决内存泄漏问题) */ private void releaseMappedByteBuffer(MappedByteBuffer mappedBuf) { try { if (mappedBuf == null) return; // 通过反射调用Cleaner释放映射内存 sun.misc.Cleaner cleaner = ((sun.nio.ch.DirectBuffer) mappedBuf).cleaner; if (cleaner != null) { cleaner.clean; } } catch (Exception e) { e.printStackTrace; } } }
在分片解析 CSV 时,为什么要专门处理split("\n")后的跨行问题,
本质上是想理解分片映射带来的 “行被截断” 的风险和解决思路
MappedByteBuffer是
按固定大小(比如 100MB)分片映射文件
的,而 CSV 的 “行” 是按\n分隔的,这两者没有任何关联 —— 也就是说,一个完整的 CSV 行,大概率会跨越两个分片的边界:
比如第 100MB 分片的最后几个字节,刚好是某一行的前半部分(比如"100001,张三,25,北京"中的"100001,张三,25,");剩下的"北京"会出现在下一个 100MB 分片的开头;如果直接按分片split("\n")解析,会把这行拆成两个不完整的行,导致数据解析错误(比如字段缺失、格式异常)。
假设 CSV 文件内容如下(为简化,每行很短):
1,张三,25,北京2,李四,30,上海3,王五,35,广州4,赵六,40,深圳
如果我们按
15 个字节
分片(仅为示例),文件字节分布如下(UTF-8 编码,每个中文占 3 字节,英文 / 数字占 1 字节,\n占 1 字节):
分片 1(0-15 字节):1,张三,25,北京\n2,李四,30,上分片 2(16-30 字节):海\n3,王五,35,广州\n4,赵六,40,深圳
如果直接对分片 1
split("\n"),得到的行是:
["1,张三,25,北京", "2,李四,30,上"]
第二行"2,李四,30,上"是不完整的;
对分片 2
["海", "3,王五,35,广州", "4,赵六,40,深圳"]
第一行"海"也是不完整的 —— 这会直接导致数据解析错误(比如 “李四” 的地址变成 “上”,“海” 被当成独立行)。
代码中remainingLine(剩余行)的核心作用,就是把 “被截断的行的前半部分” 和 “下一个分片的后半部分” 拼接成完整行,逻辑拆解如下:
String remainingLine = ""; —— 用于暂存上一个分片末尾的不完整行。
// 拆分当前分片的内容为行String lines = content.split("\n");// 如果上一个分片有剩余的不完整行,拼接到当前分片的第一行if (!remainingLine.isEmpty) {lines[0] = remainingLine + lines[0];remainingLine = "";}// 判断当前分片的最后一行是否完整(是否以\n结尾)if (content.endsWith("\n")) {// 最后一行完整,无剩余remainingLine = "";} else {// 最后一行不完整,暂存到remainingLine,待下一个分片处理remainingLine = lines[lines.length - 1];// 移除不完整的最后一行,避免处理错误数据lines = java.util.Arrays.copyOf(lines, lines.length - 1);}
还是上面的分片例子:
处理分片 1
:
content = "1,张三,25,北京\n2,李四,30,上" → 不以\n结尾;split ("\n") → ["1,张三,25,北京", "2,李四,30,上"];因为 content 不以\n结尾,所以remainingLine = "2,李四,30,上",并把 lines 截断为["1,张三,25,北京"];此时处理的是完整行:1,张三,25,北京。
处理分片 2
:
content = "海\n3,王五,35,广州\n4,赵六,40,深圳" → 以\n结尾;split ("\n") → ["海", "3,王五,35,广州", "4,赵六,40,深圳"];因为remainingLine不为空(值为2,李四,30,上),所以把第一行拼接为:2,李四,30,上 + 海 = 2,李四,30,上海;此时 lines 变为["2,李四,30,上海", "3,王五,35,广州", "4,赵六,40,深圳"];处理的都是完整行,解决了跨行问题。
文件所有分片处理完后,可能还有最后一个remainingLine(比如文件最后一行不以\n结尾),需要单独处理:
if (!remainingLine.isEmpty) {batchProcessLines(new String{remainingLine});}分片映射的边界和 CSV 行的边界不一致,必然导致
行被截断
,直接 split 会解析出不完整数据;remainingLine的核心作用是
暂存上一个分片的不完整行
,并和下一个分片的开头拼接成完整行;处理逻辑的关键:判断分片内容是否以\n结尾(确定最后一行是否完整),拼接剩余行,截断不完整行。
这一步是 CSV 分片解析的
核心容错逻辑
,如果不处理,2GB 的大 CSV 文件导入必然会出现大量数据解析错误(字段缺失、行错乱)。
来源:我凯辰韩
