内存映射(mmap)

什么是内存映射 (mmap)?

内存映射(Memory Mapped File)是一种操作系统提供的机制,它将磁盘上的文件直接映射到进程的虚拟内存地址空间中。

在 Java 中,通过 FileChannel.map() 方法可以创建一个 MappedByteBuffer。对这个 Buffer 的读写操作,会直接反映到磁盘文件上,就像操作内存数组一样简单,而不需要调用传统的 read() 或 write() 方法。

这样的话,数据实际上并没有被复制到 Java 堆内存中,而是直接在操作系统的 Page Cache 中进行读写,从而实现了“零拷贝”。

“零拷贝”简单来说就是CPU参与的数据拷贝次数为零次,从而大幅提升数据传输效率 – 因为每次数据从系统内存拷贝至程序内存都需要 CPU 参与,而 CPU 是非常宝贵的资源。

为什么它比传统 IO 快? (底层原理)

要理解 mmap 的优势,我们需要先了解传统 IO 是如何工作的。

传统 IO 的性能瓶颈

当我们使用 FileInputStream 读取文件时,数据需要经过两次拷贝:

  1. 磁盘 -> 内核缓冲区 (Page Cache):操作系统通过 DMA 将数据从磁盘读取到内核空间的 Page Cache 中。
  2. 内核缓冲区 -> 用户缓冲区 (Java Heap):CPU 将数据从内核空间拷贝到用户空间的堆内存中。

这个过程不仅涉及两次数据拷贝,还涉及两次上下文切换(用户态 <-> 内核态),这在处理大文件时会消耗大量 CPU 资源。

mmap 的工作原理 (零拷贝)

mmap 建立映射后,操作系统并不会立即读取文件数据,而是建立了一个“虚拟地址”到“文件内容”的映射关系。

  1. 建立映射:调用 map() 时,操作系统修改进程的页表,将一段虚拟内存地址映射到文件的 Page Cache 上。此时物理内存中可能还没有数据。
  2. 缺页中断:当你第一次访问这段内存(例如调用 buffer.get())时,CPU 发现对应的物理页不存在,触发“缺页中断”。
  3. 懒加载:操作系统捕获中断,直接将文件数据从磁盘读取到 Page Cache 中。
  4. 直接访问:进程直接访问 Page Cache 中的数据。

优势总结:

  • 零拷贝:数据只存在于 Page Cache 中,不需要拷贝到 Java 堆内存。
  • 减少切换:读写操作直接在用户态进行,减少了系统调用带来的上下文切换。

Java 代码实现

基础用法

1
2
3
4
5
6
7
8
9
RandomAccessFile raf = new RandomAccessFile("data.dat", "rw");
FileChannel channel = raf.getChannel();

// 这里是关键,实际上就是建立了内存映射
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 1024 * 1024);

// 往文件中写入数据,实际上就是直接写入到堆外内存中,操作系统会负责同步到磁盘
buffer.putInt(100);
buffer.putLong(12345L);

进程间通信 (IPC) 示例

mmap 非常适合用于进程间通信。以下是一个简单的 Writer/Reader 模型。

Writer (写入者)
注意:为了防止 Reader 读到不完整的数据,我们应该先写数据,最后写长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ZeroCopyWriter {
public static void main(String[] args) throws Exception {
File file = new File("shared.dat");
try (RandomAccessFile raf = new RandomAccessFile(file, "rw");
FileChannel channel = raf.getChannel()) {
raf.setLength(1024);

MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 1024);

String content = "Hello, mmap!";
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);

buffer.position(4);
buffer.put(bytes);

buffer.putInt(0, bytes.length);

System.out.println("Writer: 数据写入完成");
Thread.sleep(60000);
}
}
}

Reader (读取者)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ZeroCopyReader {
public static void main(String[] args) throws Exception {
File file = new File("shared.dat");
while (!file.exists()) Thread.sleep(1000);

try (RandomAccessFile raf = new RandomAccessFile(file, "r");
FileChannel channel = raf.getChannel()) {

MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, 1024);

while (true) {
buffer.clear();
int length = buffer.getInt();

if (length > 0) {
byte[] dst = new byte[length];
buffer.get(dst);
System.out.println("Reader 收到: " + new String(dst, StandardCharsets.UTF_8));
break;
}
Thread.sleep(500);
}
}
}
}