通过 Java 的内存映射(mmap
)技术优化了文件合并这个操作,效果非常显著,性能提升超过了 5 倍,并且是通过 JMH 基准测试框架验证得出的结论。
mmap
)?Java 提供了一种将磁盘文件映射到内存的机制,底层依赖操作系统的 mmap
系统调用。核心类是:
FileChannel.map()
它可以将文件内容映射成一个 MappedByteBuffer
,从而实现:
read()
、write()
等系统调用传统文件合并方式大致如下:
FileInputStream -> BufferedInputStream -> read bytes -> write to FileOutputStream
这是基于字节流或缓冲流的“传统磁盘 I/O 方式”。
使用内存映射优化后,文件合并流程变成:
FileChannel inChannel = new FileInputStream(file).getChannel();
MappedByteBuffer buffer = inChannel.map(MapMode.READ_ONLY, 0, file.length());
// 然后把 buffer 中的内容直接写入目标文件的 FileChannel
这种方式大幅减少了磁盘 IO 和 CPU 消耗,特别适合合并大量小文件或大文件分片。
JMH 是 Java 官方出品的微基准测试工具,专用于测量代码的运行性能。
通过对比测试两种文件合并方式的性能,结论是:
使用
mmap
的方式,相比传统InputStream/OutputStream
的方式,在文件合并场景中性能提升 5 倍以上
这说明在同样的硬件条件下,mmap
合并文件的速度是原来的 5 倍。
项目 | 传统 IO | 内存映射 mmap |
---|---|---|
原理 | 多次系统调用,磁盘读取 | 文件映射到内存,零拷贝 |
开销 | 用户态 ↔ 内核态切换频繁 | 极少切换 |
适用场景 | 小规模文件处理 | 大文件或高频文件读写 |
性能(经 JMH 实测) | 较慢 | 快 5 倍以上 |
在文件合并操作中,我将原来的传统磁盘 I/O 流方式替换为基于 FileChannel 的内存映射 mmap 方式。通过 JMH 微基准测试实测,在多线程合并大文件分片的场景下,性能提升超过 5 倍,极大优化了文件上传合并效率。
import java.io.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.util.List;
public class FileMergeExample {
// 传统IO流合并文件
public static void mergeFilesTraditional(List<File> parts, File dest) throws IOException {
try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(dest))) {
byte[] buffer = new byte[8192];
for (File part : parts) {
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(part))) {
int bytesRead;
while ((bytesRead = bis.read(buffer)) != -1) {
bos.write(buffer, 0, bytesRead);
}
}
}
}
}
// mmap方式合并文件
public static void mergeFilesMmap(List<File> parts, File dest) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(dest, "rw");
FileChannel destChannel = raf.getChannel()) {
long position = 0;
for (File part : parts) {
try (FileChannel srcChannel = FileChannel.open(part.toPath(), StandardOpenOption.READ)) {
long size = srcChannel.size();
MappedByteBuffer mappedBuffer = srcChannel.map(FileChannel.MapMode.READ_ONLY, 0, size);
destChannel.position(position);
destChannel.write(mappedBuffer);
position += size;
}
}
}
}
public static void main(String[] args) throws IOException {
// 测试文件分片列表,替换为你实际的文件路径
List<File> parts = List.of(
new File("filePart1.bin"),
new File("filePart2.bin"),
new File("filePart3.bin")
);
File destTraditional = new File("merged_traditional.bin");
File destMmap = new File("merged_mmap.bin");
// 传统IO合并
long start = System.currentTimeMillis();
mergeFilesTraditional(parts, destTraditional);
long end = System.currentTimeMillis();
System.out.println("传统IO合并耗时(ms): " + (end - start));
// mmap合并
start = System.currentTimeMillis();
mergeFilesMmap(parts, destMmap);
end = System.currentTimeMillis();
System.out.println("mmap合并耗时(ms): " + (end - start));
}
}
BufferedInputStream
和BufferedOutputStream
读取写入,适合文件操作基础用法。FileChannel.map()
将源文件映射为内存缓冲区,然后写入目标FileChannel
,减少了系统调用和上下文切换。main
中测量两种方式执行时间,方便你对比。BufferedInputStream
IOMappedByteBuffer
)<dependency>
<groupId>org.openjdk.jmhgroupId>
<artifactId>jmh-coreartifactId>
<version>1.36version>
dependency>
<dependency>
<groupId>org.openjdk.jmhgroupId>
<artifactId>jmh-generator-annprocessartifactId>
<version>1.36version>
<scope>providedscope>
dependency>
import org.openjdk.jmh.annotations.*;
import java.io.*;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.AverageTime) // 平均执行时间
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
public class FileReadBenchmark {
private File testFile;
@Setup(Level.Trial)
public void setUp() throws IOException {
testFile = new File("testfile.dat");
if (!testFile.exists()) {
try (FileOutputStream fos = new FileOutputStream(testFile)) {
byte[] data = new byte[1024 * 1024 * 10]; // 10MB
for (int i = 0; i < data.length; i++) {
data[i] = (byte) (i % 256);
}
fos.write(data);
}
}
}
@Benchmark
public long readWithBufferedInputStream() throws IOException {
long sum = 0;
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(testFile))) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = bis.read(buffer)) != -1) {
for (int i = 0; i < bytesRead; i++) {
sum += buffer[i];
}
}
}
return sum;
}
@Benchmark
public long readWithMappedByteBuffer() throws IOException {
long sum = 0;
try (FileChannel channel = FileChannel.open(testFile.toPath(), StandardOpenOption.READ)) {
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size());
while (buffer.hasRemaining()) {
sum += buffer.get();
}
}
return sum;
}
}
你可以通过命令行运行:
mvn clean install
java -jar target/benchmarks.jar
输出类似于:
Benchmark Mode Cnt Score Error Units
FileReadBenchmark.readWithBufferedInputStream avgt 10 50.123 ± 1.234 ms/op
FileReadBenchmark.readWithMappedByteBuffer avgt 10 12.789 ± 0.456 ms/op
模式 | 优势 | 适用场景 |
---|---|---|
BufferedInputStream | 简单、兼容性好 | 小文件、普通场景 |
MappedByteBuffer (mmap) | 速度快、减少系统调用 | 大文件、频繁读取、性能敏感 |
通过 Java 的内存映射(mmap)技术优化文件合并操作,是利用操作系统提供的 内存映射文件(Memory-Mapped File)机制,将磁盘上的文件直接映射到内存地址空间,从而大幅提升 I/O 性能。
在传统的文件合并中,通常使用如下方式:
InputStream in = new FileInputStream("file1.part");
OutputStream out = new FileOutputStream("mergedFile", true);
byte[] buffer = new byte[4096];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
Java 中的 mmap
是通过 FileChannel.map()
方法实现的,它允许将磁盘文件的一部分直接映射到内存中,读取/写入就像操作内存一样高效。
RandomAccessFile raf = new RandomAccessFile("mergedFile", "rw");
FileChannel outChannel = raf.getChannel();
MappedByteBuffer outBuffer = outChannel.map(FileChannel.MapMode.READ_WRITE, position, size);
for (File file : parts) {
FileChannel inChannel = new FileInputStream(file).getChannel();
MappedByteBuffer inBuffer = inChannel.map(FileChannel.MapMode.READ_ONLY, 0, file.length());
outBuffer.put(inBuffer); // 内存直接拷贝,无需显式 read/write
}
通过使用 JMH 测试,对比 mmap
与传统 IO:
方法 | 平均耗时(N个分片合并) | 相对提升 |
---|---|---|
传统 IO | 500ms | baseline |
mmap | 80ms | 提升约 5-6 倍 |
使用 Java 的 mmap 技术将磁盘文件映射到内存,跳过传统 I/O 的缓冲区拷贝过程,极大提高了文件合并的效率,尤其适用于高并发和大文件场景。
mmap 是让你“像访问内存一样访问文件”,绕开传统 read/write 的繁琐路径,从而 少走路、更快达成目标。
假设你要从磁盘读取一块文件数据,传统方式大致流程如下:
read()
方法。mmap(memory-mapped file)是将磁盘文件直接映射到进程的虚拟内存空间,你可以像访问数组一样读写这个文件内容,不需要 read/write 调用。
FileChannel channel = new RandomAccessFile(file, "rw").getChannel();
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, file.length());
这段代码告诉操作系统:
“请把这个文件映射到我进程的内存地址空间,我要直接操作它”。
然后你可以:
buffer.put(byteData); // 直接写入内存映射区域
步骤 | 传统IO | mmap内存映射 |
---|---|---|
读文件 | 磁盘 → 内核 → 应用层 | 磁盘 ←→ 内核 ←→ 应用直接访问 |
用户态/内核态切换 | 多次 | 第一次后基本不切换 |
拷贝次数 | 两次 | 一次,或零次(页缓存中) |
调用方式 | read/write 系统调用 | 像数组一样访问内存 |
最终效果就是:少一次拷贝,少一次系统调用,对于大文件、频繁读写、并发场景来说,效率提升非常明显。
比如你要把 10 个 100MB 的文件片合并成一个完整文件:
想象两种做法:
mmap 是利用操作系统提供的虚拟内存机制,将文件直接映射到内存,避免 read/write 系统调用和内存拷贝开销,使得文件合并等大数据 I/O 操作更高效。
现代操作系统都会使用 虚拟内存(Virtual Memory),它把每个进程看到的内存空间变成“假的”连续地址,但实际物理内存不一定连续。
这个空间按页(Page)划分:
这样做的好处:
当你执行这段 Java 代码:
MappedByteBuffer buffer = fileChannel.map(MapMode.READ_WRITE, 0, file.length());
本质上发生了以下操作:
操作系统将文件的一部分(或全部)映射到虚拟内存页中;
不会立刻读取磁盘内容,而是创建页表项映射;
当你第一次访问这些内存地址时:
后续再访问时就是直接从内存页中访问了,非常快!
Page Fault 是当程序访问一个“尚未加载到物理内存”的虚拟地址时,操作系统发出的中断信号。
在 mmap 场景下的 page fault:
buffer.get(i)
时,发现该页还未加载;所以 mmap 在初始化的时候不会真正读文件,而是“按需加载”。
如果你用 MapMode.READ_WRITE
映射了文件:
这对高性能有利 —— 你修改的数据才会真正产生开销。
mmap 直接与 Page Cache 打通:
read()
/write()
也是操作 Page Cache;read
调用,直接访问 Page Cache;msync()
或操作系统自动刷入磁盘。这让 mmap 能够更高效地利用系统缓存机制。
点 | 原理机制 | 好处 |
---|---|---|
避免重复拷贝 | Page Cache 直接映射进虚拟地址 | 减少内存拷贝,提高 I/O 性能 |
按需加载 | Page Fault + 虚拟内存 | 降低内存占用,延迟加载优化性能 |
写时拷贝 | Copy-On-Write 机制 | 避免无效写操作,提高并发性能 |
内核态零切换 | 映射后直接访问内存 | 避免频繁的用户态 ↔ 内核态切换,提高响应速度 |
更少系统调用 | 不需 read/write | 系统调用开销大幅减少 |