Java NIO:DirectBuffer、内存映射与零拷贝原理
一、HeapByteBuffer 与 DirectByteBuffer
1. HeapByteBuffer:堆内存缓冲区
之前学习 ByteBuffer 时,主要使用:
ByteBuffer buffer = ByteBuffer.allocate(1024);这种方式创建的是 HeapByteBuffer,数据存储在 JVM 堆内存中。
JVM
|
+---- Heap(堆内存)
|
+---- HeapByteBuffer
|
+---- 数据特点:
数据存储在 JVM 堆内存中。
受到 JVM GC 管理。
创建和使用成本相对较低。
可以通过
array()获取底层字节数组。
2. DirectByteBuffer:直接内存缓冲区
除了堆内存,Java NIO 还提供了直接内存 Buffer。
创建方式:
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);它使用的是 JVM 堆外直接内存(Direct Memory)。
操作系统内存
|
+---- JVM 堆内存
| |
| +---- HeapByteBuffer
|
+---- JVM 堆外直接内存
|
+---- DirectByteBuffer特点:
数据存储在 JVM 堆外内存。
Java 包装对象仍然由 GC 管理,但底层内存不属于普通 JVM 堆。
可以供底层 Native IO 直接访问。
适合高频 IO 和网络数据传输场景。
分配和释放成本相对较高。
3. HeapByteBuffer 为什么可能产生额外的数据复制?
这是理解 DirectBuffer 的核心。
假设需要将 JVM 中的数据发送到网络。
使用 HeapByteBuffer 时,可以简化理解为:
JVM 堆内存
|
| HeapByteBuffer
|
v
临时 DirectBuffer
|
| 数据复制
|
v
Native IO
|
v
操作系统为什么需要临时 DirectBuffer?
因为 JVM 堆内存受到 GC 管理,底层 Native IO 需要能够稳定访问的内存地址。
在 JDK 8 的部分 IO 实现中,使用 HeapByteBuffer 进行底层 IO 时,会通过临时 DirectBuffer 作为中转缓冲区。
因此可能产生一次额外的数据复制:
HeapByteBuffer → 临时 DirectBuffer而使用 DirectByteBuffer:
DirectByteBuffer
|
v
Native IO
|
v
操作系统可以减少 HeapByteBuffer 到临时 DirectBuffer 的中间复制。
重点:DirectBuffer 减少的是部分数据复制,不代表整个 IO 过程没有数据复制,也不等于零拷贝。
4. HeapByteBuffer 与 DirectByteBuffer 对比
5. Java 8 代码示例
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public class DirectBufferDemo {
public static void main(String[] args) {
// 堆内存
ByteBuffer heapBuffer = ByteBuffer.allocate(1024);
// 直接内存
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);
System.out.println(heapBuffer.isDirect()); // false
System.out.println(directBuffer.isDirect()); // true
directBuffer.put(
"Hello NIO".getBytes(StandardCharsets.UTF_8)
);
directBuffer.flip();
byte[] data = new byte[directBuffer.remaining()];
directBuffer.get(data);
System.out.println(
new String(data, StandardCharsets.UTF_8)
);
}
}输出:
false
true
Hello NIO注意:
directBuffer.array();对于 DirectByteBuffer,调用 array() 通常会抛出 UnsupportedOperationException,因为它没有可供 Java 直接访问的底层 byte[]。
6. DirectBuffer 的缺点与生产注意事项
DirectBuffer 并不是越多越好。
第一:分配成本较高。
申请直接内存需要执行底层内存分配操作,成本通常高于普通堆内存。
第二:底层内存不受 JVM 堆直接管理。
例如:
JVM 堆内存还有空间
|
v
Direct Memory 已经达到限制
|
v
OutOfMemoryError:
Direct buffer memory因此,可能出现 JVM 堆内存充足,但直接内存不足的情况。
第三:释放不是立即完成的。
buffer.clear();只是重置 Buffer 的 position 和 limit,不会释放直接内存。
DirectByteBuffer 的 Java 对象不再被引用后,底层内存通常通过 Cleaner 等机制清理,但不保证立即释放。
生产环境建议:
普通业务数据优先使用 HeapByteBuffer。
高频网络 IO 可以考虑 DirectBuffer。
避免频繁创建和销毁大容量 DirectBuffer。
高频 IO 场景可以使用内存池。
关注直接内存使用情况,合理设置
-XX:MaxDirectMemorySize。
二、MappedByteBuffer:内存映射文件
1. 什么是 MappedByteBuffer?
之前使用 FileChannel 读取文件:
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer);数据读取流程:
磁盘文件
|
v
FileChannel
|
v
ByteBuffer
|
v
Java 程序而 MappedByteBuffer 使用了另一种方式。
将文件的一段区域映射到进程的虚拟地址空间,程序可以像操作内存一样操作文件内容。
创建方式:
MappedByteBuffer buffer = channel.map(
FileChannel.MapMode.READ_WRITE,
0,
channel.size()
);可以理解为:
磁盘文件
|
| 操作系统建立内存映射
|
v
进程虚拟地址空间
|
v
MappedByteBuffer
|
v
Java 程序通过 get()、put() 访问注意:
内存映射不是把整个文件立即读取到物理内存中。
它主要建立文件区域与进程虚拟地址空间之间的映射关系。实际访问某个页面时,操作系统通常通过缺页异常按需加载对应文件页,并利用 Page Cache(页缓存)管理数据。
2. MappedByteBuffer 的三种映射模式
FileChannel 提供三种映射模式。
READ_ONLY:只读映射
FileChannel.MapMode.READ_ONLY特点:
可以读取文件。
不允许修改映射内容。
调用 put() 修改内容会抛出 ReadOnlyBufferException。
READ_WRITE:读写映射
FileChannel.MapMode.READ_WRITE特点:
可以读取文件。
可以修改文件内容。
修改的数据可以通过 force() 请求同步到存储设备。
PRIVATE:私有映射
FileChannel.MapMode.PRIVATE特点:
可以读取文件。
修改采用写时复制(Copy On Write)。
修改内容不会反映到原文件。
3. Java 8 完整代码示例
实现:创建文件,通过 MappedByteBuffer 修改文件内容。
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
public class MappedByteBufferDemo {
public static void main(String[] args) throws Exception {
Path path = Paths.get("mapped.txt");
// 创建初始文件
Files.write(
path,
"Hello NIO".getBytes(StandardCharsets.UTF_8)
);
try (FileChannel channel = FileChannel.open(
path,
StandardOpenOption.READ,
StandardOpenOption.WRITE)) {
// 建立文件映射
MappedByteBuffer buffer = channel.map(
FileChannel.MapMode.READ_WRITE,
0,
channel.size()
);
// 读取第一个字节
System.out.println((char) buffer.get(0));
// 修改第一个字节 H -> h
buffer.put(0, (byte) 'h');
// 请求同步修改内容
buffer.force();
}
System.out.println(
new String(
Files.readAllBytes(path),
StandardCharsets.UTF_8
)
);
}
}输出:
H
hello NIO重点理解:
为什么不需要调用 channel.read()?
因为文件区域已经映射到虚拟地址空间,可以通过 MappedByteBuffer 的 get()、put() 直接访问映射区域。
为什么需要 force()?
修改映射区域后,操作系统可能还没有将数据同步到存储设备。
buffer.force();可以请求将修改的数据同步到存储设备。
注意:force() 是同步请求,不应将其理解为对所有硬件故障都提供绝对持久性保证。
关闭 FileChannel 后,映射会立即失效吗?
不会。映射区域的生命周期不等于 FileChannel 的生命周期。
Java 8 没有公开的标准 MappedByteBuffer 立即解除映射 API,通常依赖底层资源清理机制释放映射。
4. MappedByteBuffer 适用场景
常见应用:
大文件随机读取。
文件索引访问。
固定文件区域的读写。
高频访问文件中的指定数据。
例如,需要读取文件索引 100 位置的数据:
byte value = mappedBuffer.get(100);不需要每次都调用 FileChannel.read(),可以直接访问映射区域。
注意:
Java 8 中 ByteBuffer 的容量使用 int 表示,单次映射区域通常不能超过 Integer.MAX_VALUE。超大文件需要拆分成多个映射区域。
三、DirectBuffer 与 MappedByteBuffer 的区别
这两个知识点很容易混淆。
它们都涉及堆外内存,但解决的问题不一样。
重点记忆:
DirectByteBuffer:解决堆内存参与 Native IO 时可能产生的额外复制问题。
MappedByteBuffer:解决文件与进程虚拟地址空间之间的映射访问问题。
MappedByteBuffer 本身也是一种直接映射缓冲区,但它和普通 allocateDirect() 创建的 DirectByteBuffer 用途不同。
四、零拷贝:Zero Copy
1. 什么是零拷贝?
零拷贝是一类减少数据复制、减少用户态与内核态之间数据搬运开销的技术。
核心目标:
尽可能减少不必要的数据复制,降低 CPU 参与的数据搬运开销,提高 IO 性能。
注意:
零拷贝不代表整个过程完全没有任何数据复制。
2. 传统文件传输方式
假设服务器需要将一个文件发送给客户端。
传统方式:
byte[] data = Files.readAllBytes(path);
socket.getOutputStream().write(data);可以简化理解为以下过程:
磁盘文件
|
| DMA
v
内核 Page Cache
|
| CPU复制
v
JVM 用户空间 Buffer
|
| CPU复制
v
内核 Socket 发送缓冲区
|
| DMA
v
网卡
|
v
客户端传统方式主要存在的问题:
文件数据从内核空间复制到用户空间。
用户空间的数据再复制回内核空间。
CPU 需要参与额外的数据复制。
文件越大,额外的数据搬运成本越明显。
对于大文件、高并发文件下载,这些操作会产生额外开销。
3. Linux sendfile:零拷贝技术
Linux 提供了 sendfile() 系统调用。
它允许数据在文件和 Socket 之间传输,避免传统方式中先将文件数据复制到用户空间,再复制回内核空间。
简化流程:
磁盘文件
|
| DMA
v
内核 Page Cache
|
| 减少用户空间中转
v
Socket 发送路径
|
| DMA
v
网卡
|
v
客户端它的优势:
减少用户空间和内核空间之间的数据复制。
减少 CPU 参与的数据搬运。
减少应用程序对大块数据的中转处理。
提高大文件网络传输效率。
在支持相应优化的场景中,还可以通过传递内存页引用或描述符,减少内核内部的数据复制。
注意:实际 sendfile 实现可能仍然涉及内核内部复制、DMA 或其他硬件数据传输操作。
所以零拷贝是减少不必要的数据复制,而不是物理意义上的完全没有复制。
4. Java NIO 如何使用零拷贝?
上一课学习过:
source.transferTo(position, count, target);这个方法在某些操作系统和传输场景下,可以利用底层高效的数据传输机制。
例如:
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;
public class ZeroCopyDemo {
public static void transfer(
FileChannel fileChannel,
SocketChannel socketChannel) throws Exception {
long position = 0;
long size = fileChannel.size();
while (position < size) {
long transferred = fileChannel.transferTo(
position,
size - position,
socketChannel
);
if (transferred > 0) {
position += transferred;
} else {
// 如果是非阻塞SocketChannel,
// 暂时无法发送时,应等待OP_WRITE事件。
break;
}
}
}
}核心代码:
fileChannel.transferTo(
position,
size - position,
socketChannel
);Java 屏蔽了部分底层实现细节,具体可能利用操作系统的 sendfile 等机制。
注意:
不保证所有平台都使用 sendfile。
不保证一次调用传输全部数据。
非阻塞 SocketChannel 返回 0 时,应保存当前 position,等待 OP_WRITE 后继续发送。
不要在非阻塞场景中使用 while 循环不断空转。
5. 传统 IO 与零拷贝对比
传统文件传输通常可以抽象为两次 DMA 搬运和两次 CPU 数据复制。
sendfile 优化可以减少用户空间中转,在适当的内核和硬件条件下进一步减少内核内部复制。
具体复制次数取决于操作系统实现和硬件支持,不应将某个固定数字当成所有平台的通用结论。
五、DirectBuffer、MappedByteBuffer 与零拷贝的关系
这是本节最容易混淆的知识点。
三种技术都可以提高 IO 性能,但底层原理不同。
可以这样理解:
DirectBuffer:优化内存与 Native IO 之间的数据交互。
MappedByteBuffer:优化文件的映射访问。
sendfile:优化文件到网络的数据传输。
它们不是同一种技术,也不能简单地认为使用 DirectBuffer 就实现了零拷贝。
六、Netty 为什么使用 DirectByteBuf?
Netty 是基于 Java NIO 构建的高性能网络通信框架。
它提供了 ByteBuf 对 Buffer 进行封装。
ByteBuf 有堆内存和直接内存两种主要实现:
HeapByteBuf:底层使用 JVM 堆内存。
DirectByteBuf:底层使用 JVM 堆外直接内存。
Netty 在网络 IO 场景中通常会使用 DirectByteBuf。
主要原因:
减少 HeapBuffer 参与 Native IO 时可能产生的临时缓冲区复制。
减少高频网络读写中的内存中转。
配合内存池复用缓冲区,降低频繁分配和释放内存的成本。
例如:
ByteBuf buffer = Unpooled.directBuffer(1024);
buffer.writeBytes("Hello Netty".getBytes(
StandardCharsets.UTF_8
));这里创建的是直接内存 ByteBuf。
注意:Netty 的 ByteBuf 和 Java NIO 的 ByteBuffer 不一样。
Netty 还提供了:
引用计数。
retain()。
release()。
内存池。
动态扩容。
读写索引分离。
因此,Netty 的直接内存管理不能简单等同于直接调用 ByteBuffer.allocateDirect()。
七、生产环境中的应用场景
1. DirectBuffer
适合:
高频 TCP 网络通信。
高吞吐量数据传输。
需要减少 HeapBuffer 到 Native IO 临时缓冲区复制的场景。
生产建议:
优先考虑使用成熟的内存池,不要在每次请求中随意创建大容量 DirectBuffer。
2. MappedByteBuffer
适合:
大文件随机访问。
文件索引读取。
固定文件区域读写。
需要将文件映射到内存访问的场景。
注意:
映射区域会占用虚拟地址空间。
访问映射区域可能触发缺页异常和磁盘 IO。
单次映射容量存在限制。
需要关注文件映射的生命周期和文件修改。
Java 8 没有公开的标准立即解除映射 API。
3. 零拷贝
适合:
文件下载服务器。
静态资源服务器。
大文件网络传输。
高吞吐量数据转发。
例如一个文件服务器,如果需要频繁向客户端发送大型文件,可以考虑 FileChannel.transferTo(),让底层根据平台选择合适的传输优化方式。
八、常见问题与答案
1. DirectBuffer 为什么比 HeapBuffer 更适合 Native IO?
因为 DirectBuffer 使用堆外内存,Native IO 可以直接访问其内存区域,减少 HeapBuffer 在某些 IO 操作中需要的临时直接缓冲区复制。
2. DirectBuffer 是不是零拷贝?
不是。它只是减少部分数据复制,不能保证整个数据传输过程没有复制。
3. DirectBuffer 会被 GC 管理吗?
DirectByteBuffer 的 Java 包装对象受 GC 管理,但底层直接内存不属于普通 Java 堆内存,通常通过 Cleaner 等机制回收。
4. 为什么 DirectBuffer 可能出现内存溢出?
因为直接内存和 JVM 堆内存是不同的内存区域。即使 JVM 堆还有空间,直接内存达到限制后也可能抛出:
OutOfMemoryError: Direct buffer memory5. MappedByteBuffer 为什么不需要显式调用 FileChannel.read()?
因为它将文件区域映射到进程的虚拟地址空间,可以直接通过 get()、put() 访问映射内容。
6. MappedByteBuffer 是不是把整个文件一次性加载到内存?
不是。它建立的是虚拟地址空间映射,实际页面通常在访问时按需加载。
7. MappedByteBuffer 修改文件后为什么需要 force()?
因为修改的数据可能还处于操作系统缓存中,force() 可以请求将修改同步到存储设备。
8. transferTo() 一定会使用 sendfile 吗?
不一定。是否使用底层高效传输机制取决于操作系统、JDK 实现和目标 Channel 类型。
9. 零拷贝是不是完全没有 CPU 参与?
不是。零拷贝主要减少不必要的数据复制,CPU 仍然需要参与系统调用、协议处理和其他必要工作。
10. Netty 为什么使用 DirectByteBuf?
因为 Netty 是高性能网络通信框架,直接内存可以减少网络 IO 中部分数据复制。配合内存池,还可以降低频繁分配和释放直接内存的成本。
九、重点总结
HeapByteBuffer 使用 JVM 堆内存。
DirectByteBuffer 使用 JVM 堆外直接内存。
DirectBuffer 可以减少 HeapBuffer 在 Native IO 中可能发生的临时缓冲区复制。
DirectBuffer 分配和回收成本相对较高,需要注意内存管理。
MappedByteBuffer 通过内存映射访问文件内容。
MappedByteBuffer 不代表整个文件立即加载到物理内存。
READ_ONLY 只读,READ_WRITE 可修改原文件,PRIVATE 使用写时复制。
force() 可以请求将映射区域的修改同步到存储设备。
零拷贝的核心是减少数据复制和用户空间中转。
Linux sendfile 是零拷贝相关技术的典型实现。
Java NIO 的 transferTo() 在特定场景下可以利用底层高效传输机制。
DirectBuffer、MappedByteBuffer、sendfile 解决的问题不同,不能混为一谈。
Netty 使用 DirectByteBuf,并结合内存池优化高频网络 IO。
十、课后测试
DirectBuffer 为什么比 HeapBuffer 更适合 Native IO?
DirectBuffer 为什么不等于零拷贝?
DirectBuffer 的底层内存由谁负责回收?
为什么 JVM 堆内存还有空间,仍然可能出现 Direct buffer memory?
MappedByteBuffer 的核心原理是什么?
MappedByteBuffer 是不是将整个文件一次性加载到 JVM 堆内存?
READ_ONLY、READ_WRITE、PRIVATE 有什么区别?
MappedByteBuffer 修改文件后为什么需要 force()?
Linux sendfile 主要解决什么问题?
DirectBuffer、MappedByteBuffer、sendfile 分别优化了什么?
Java NIO 学习进度:7 / 8 课,剩余最后 1 课。
下一课:Java NIO:Reactor 模型与 Netty EventLoop 原理。
重点学习:
Reactor 模型的基本原理。
单 Reactor 单线程模型。
单 Reactor 多线程模型。
主从 Reactor 多线程模型。
Netty EventLoop 与 EventLoopGroup。
Selector 与 NioEventLoop 的关系。
一个 EventLoop 为什么可以管理多个 Channel?
为什么 Netty 的 IO 线程不能执行耗时阻塞业务?
完成第 8 课后,就完成 Java NIO 核心学习路线。
