零拷贝
零拷贝机制可以减少数据在内和空间和用户空间缓冲区之间反复的 I/O 拷贝操作。
零拷贝机制可以减少用户态和内核态之间切换带来的开销。
场景举例, 通过 read 系统调用读取磁盘上的某个文件, 然后通过 write 系统调用将其通过 Socket 发送出去;
传统 IO 方式下, 假设 read 和 write 都是阻塞式的, 假设 PageCache 和 Socket 缓冲区一开始都为空
- 用户进程发起 read 调用, 切换到内核态, CPU 将数据从 PageCache 复制到用户缓冲区; 完成后切回用户态;
- 用户进程发起 write 调用, 切换到内核态, CPU 将数据从用户缓冲区复制到 Socket 缓冲区;
- CPU 给 DMA 发送拷贝命令, 切回用户态;
- DMA 控制器将数据从 Socket 缓冲区拷贝到网卡的缓冲区, 网卡发送数据;
总共经历 2 次 DMA 拷贝, 2 次 CPU 拷贝, 4 次CPU状态的切换;
编辑
mmap
操作系统为了提高外存的读写效率, 采用了页缓存机制, 将文件内容拷贝到内核的页缓存中;
而用户进程, 是没有权利直接访问内核空间的页缓存的, 所以要拷贝到用户空间的缓存中;
mmap 是 Linux 提供的一种内存映射的方法, 将文件磁盘地址映射到用户进程虚拟地址空间中一段虚拟地址;
相当于为这个文件, 直接在用户空间中建立了页缓存;
还是发送文件的例子
- 用户进程发起 mmap 调用,切换到内核态;
- 在当前进程的 虚拟地址空间中, 分配一块连续的虚拟地址, 将这块地址直接和文件在磁盘上的物理地址建立映射;
- 就好像说是直接在用户空间里面, 做了文件的 Page Cache;
- 进程发起对这片映射空间的访问, 这就引起缺页异常, DMA从磁盘拷贝数据到映射空间, 切换回用户态;
- 用户进程发起 write 调用, 切换到内核态;
- CPU 将数据直接从映射空间拷贝到 Socket 写缓冲区;
- CPU 发出 DMA 写命令; 切换回用户态;
- DMA 将数据从 Socket 写缓冲区拷贝到网卡;
整个过程, 共计 2 次 DMA 拷贝, 1次 CPU 拷贝, 4 次状态切换;
编辑
sendfile 2.1
Sendfile 系统调用在 Linux 内核版本 2.1 中被引入, 数据可以直接在内核空间内部进行 I/O 传输;
sendfile() 只适用于应用程序地址空间不需要对所访问数据进行处理的情况, 因为显然整个过程中数据对用户进程都是不可见的
Linux 2.6.3 之前, sendfile的 in_fd 不能是 Socket , 并且 out_fd 必须是 Socket, 也就是说只支持文件到 Socke 的传输, 不支持从套接字到套接字的直接传输
Linux 2.6.3以后, out_fd 可以是任何文件, 支持本地文件的零拷贝操作
还是发送文件的例子, 假设内核缓冲区和 Socket 缓冲区一开始都为空
- 用户发起 sendfile 调用, 切换到内核态;
- DMA 控制器将数据从磁盘拷贝到 PageCache ;
- CPU 将数据从 PageCache 拷贝到 Socket 写缓冲区;
- CPU 发出 DMA 命令; 返回用户态;
- DMA 将数据从 Socket 写缓冲拷贝到网卡缓冲区;
总共 2 次DMA搬运, 1 次 CPU 拷贝, 2 次状态切换;
编辑
sendfile 2.4
Linux 2.4 版本的内核对 Sendfile 系统调用进行升级;
原本需要 CPU 将数据从内核缓冲区复制到 Socket 缓冲区, 现在仅复制内核缓冲区的文件描述符和数据长度;
还是发送文件的例子:
- 用户发起 sendfile 调用, 切换到内核态;
- DMA 控制器将数据从磁盘拷贝到内核缓冲区;
- CPU 将内核缓冲区文件描述符和数据长度拷贝到 Socket 缓冲区;
- CPU 发出 DMA 命令; 返回用户态;
- DMA 根据我那件描述符和数据长度拷贝数据到网卡缓冲区, 实际上就是直接从内核缓冲区拷贝到网卡缓冲区;
文件描述符和数据长度的拷贝可以忽略不计, 那么总共 2 次 DMA 拷贝, 0 次 CPU 拷贝, 2 次状态切换;
编辑
Splice
Linux 2.6 版本引入; 允许在两个文件描述符之间移动数据而不需要将数据复制到用户空间;
相较于 sendfile, 不再限制于文件到 Socket
Splice 系统调用可以在内核空间的读缓冲区和写缓冲区之间建立管道(pipeline),从而避免了两者之间的 CPU 拷贝操作。
2 次 DMA拷贝, 没有 CPU 拷贝, 2 次状态切换;
编辑
Kafka 使用 sendfile, RocketMQ 使用 mmap + write;
NIO直接内存与零拷贝
上面介绍了零拷贝的原理, 那么 NIO 包下具体哪些地方用到了零拷贝?
NIO 包下的 Buffer 可以分为这么几类, ByteBuffer, IntBuffer, DoubleBuffer... 还有 MappedByteBuffer;
DirectByteBuffer 是 MappedByteBuffer 的子类;
MappedByteBuffer
MappedByteBuffer 是 NIO 基于内存映射(mmap)这种零拷贝方式的提供的一种实现,它继承自 ByteBuffer。FileChannel 定义了一个 map() 方法,它可以把一个文件从 position 位置开始的 size 大小的区域映射到用户空间, 封装为 MapppedByteBuffer ( 实际上是 DirectByteBuffer )。
FileChannel.map(mode, postition, size)
mode:限定内存映射区域(MappedByteBuffer)对内存映像文件的访问模式,包括只可读(READ_ONLY)、可读可写(READ_WRITE)和写时拷贝(PRIVATE)三种模式。
position:文件映射的起始地址,对应内存映射区域(MappedByteBuffer)的首地址。
size:文件映射的字节长度,从 position 往后的字节数,对应内存映射区域(MappedByteBuffer)的大小。
它是一个抽象类, 常用的是他的子类 DirectByteBuffer;
MappedByteBuffer 相比 ByteBuffer 新增了 force()、load() 和 isLoad() 三个重要的方法:
- force():对于处于 READ_WRITE 模式下的缓冲区,把对缓冲区内容的修改强制刷新到本地文件。
- load():将缓冲区的内容载入物理内存中,并返回这个缓冲区的引用。
- isLoaded():如果缓冲区的内容在物理内存中,则返回 true,否则返回 false。
map 方法最终走到本地方法 map0(), map0 为文件分配一块虚拟内存,作为它的内存映射区域,然后返回这块内存映射区域的起始地址。
然后 map 方法通过反射创建一个 DirectByteBuffer 实例,返回;
对 map 方法获得的 MappedByteBuffer 进行读写, 就不用再调用 FileChannel 的 read write 方法了;
map() 方法返回的是内存映射区域的起始地址,通过(起始地址 + 偏移量)就可以获取指定内存的数据。这样一定程度上替代了 read() 或 write() 方法,底层直接采用 sun.misc.Unsafe 类的 getByte() 和 putByte() 方法对数据进行读写。
map0 使用的是 mmap64 系统调用;
释放: 直接让强引用置为 null; 会有专门的线程回收堆外内存;;
DirectByteBuffer
不是 public 的 class , 只有同包下可见, 其构造方法也不是public的;
一般通过ByteBuffer byteBuffer = ByteBuffer.allocateDirect(10);
来获取一个 DBB 对象;
DBB 对象位于堆上, 而内部的字节缓冲区位于堆外的直接内存; 通过 Unsafe 的本地方法 allocateMemory() 进行内存分配,底层调用的是操作系统的 malloc() 函数。
DBB 是 MappedByteBuffer 的子类, 因此,除了允许分配操作系统的直接内存以外,DirectByteBuffer 本身也具有文件内存映射的功能;
对 Channel 的 write 操作必然由 DBB 来完成
以 FileChannel 为例, 如果是 DBB, 直接进行写, 如果是 HeapByteBuffer, 则先构造一个 DBB, 拷贝到DBB, 再由DBB写到Channel;
public int write(ByteBuffer var1) throws IOException {
......
var3 = IOUtil.write(this.fd, var1, -1L, this.nd);
......
}
//IOUtil.write
tatic int write(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
if (var1 instanceof DirectBuffer) {
// 如果是DBB, 直接把数据从DBB写到Channel
return writeFromNativeBuffer(var0, var1, var2, var4);
} else {
// 如果是堆上的 ByteBuffer
ByteBuffer var8 = Util.getTemporaryDirectBuffer(var7);
var8.put(var1);
var8.flip();
var1.position(var5);
int var9 = writeFromNativeBuffer(var0, var8, var2, var4);
}
}
至于为什么, 因为如果 ByteBuffer 是在堆上的块, 那么传输进行中如果发生 GC, 可能导致块的位置发生变动; 需要先将 HeapByteBuffer 拷贝到堆外(这个过程在安全点之外, 因此不会发生 GC);
FileChannel
FileChannel.open 或者基于 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel 方法, 可以创建并打开一个文件通道。
transferTo:将当前通道的数据写入一个 WritableByteChannel 的目的通道。
transferFrom:把一个源通道 ReadableByteChannel 中的数据读取到当前 FileChannel 的文件里面。
transferTo
最终调用的是 FileChannelImpl 类下的 transferTo 方法;
FileChannelImpl.java 定义了 3 个常量,用于标示当前操作系统的内核是否支持 sendfile 以及 sendfile 的相关特性。
- transferSupported:用于标记当前的系统内核是否支持 sendfile() 调用,默认为 true。
- pipeSupported:用于标记当前的系统内核是否支持文件描述符(fd)基于管道(pipe)的 sendfile() 调用,默认为 true。
- fileSupported:用于标记当前的系统内核是否支持文件描述符(fd)基于文件(file)的 sendfile() 调用,默认为 true。
FileChannelImpl 首先执行 transferToDirectly() 方法,调用 native transferTo0 方法以 sendfile 的零拷贝方式尝试数据拷贝, 这里底层是 sendfile64 系统调用;
在 Linux 2.6.3 之前,out_fd 必须是一个 socket,而从 Linux 2.6.3 以后,out_fd 可以是任何文件。也就是说,sendfile64() 函数不仅可以进行网络文件传输,还可以对本地文件实现零拷贝操作。
如果系统内核不支持 sendfile,进一步执行 transferToTrustedChannel() 方法,以 mmap 的零拷贝方式进行内存映射,这种情况下目的通道必须是 FileChannelImpl 或者 SelChImpl 类型。
如果以上两步都失败了,则执行 transferToArbitraryChannel() 方法,基于传统的 I/O 方式完成读写,具体步骤是初始化一个临时的 DirectByteBuffer,将源通道 FileChannel 的数据读取到 DirectByteBuffer,再写入目的通道 WritableByteChannel 里面。
transferFrom
如果源通道是文件, 则使用 mmap, 如果不是文件, 用传统IO;
public long transferFrom(ReadableByteChannel var1, long var2, long var4) throws IOException {
......
return var1 instanceof FileChannelImpl ?
this.transferFromFileChannel((FileChannelImpl)var1, var2, var4) :
this.transferFromArbitraryChannel(var1, var2, var4);
......
}
private long transferFromFileChannel(FileChannelImpl var1, long var2, long var4) throws IOException {
......
MappedByteBuffer var17 = var1.map(MapMode.READ_ONLY, var13, var15);
......
}
直接内存的回收
DirectByteBuffer 有一个 Cleaner 类型的成员, Cleaner 是 PhantomReference 的子类; Cleaner 又有一个 Runnable 类型的成员 thunk;
public class Cleaner extends PhantomReference<Object> {
private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue();
private static Cleaner first = null;
private Cleaner next = null;
private Cleaner prev = null;
private final Runnable thunk;
}
在 DirectByteBuffer 的构造方法中, 会创建一个 Cleaner 对象, 创建 Cleaner 对象的时候, 把当前的 DirectByteBuffer 传了进去, 同时还传递了一个 Deallocator 对象, 这个 Deallocator 将作为 Cleaner 的 thunk 成员;
DirectByteBuffer(int cap) {
......
try {
// JNI 分配空间
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
// 初始化内存空间, 都置为0
unsafe.setMemory(base, size, (byte) 0);
// 创建 Cleaner
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
......
}
由于Cleaner 是虚引用, 所以创建的时候必须传一个 ReferenceQueue, 但是这里我们用不到, 所以 Cleaner 类有一个 static 的 dummyQueue, 传的时候统一用这个;
public class Cleaner extends PhantomReference<Object> {
private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue();
private Cleaner(Object var1, Runnable var2) {
super(var1, dummyQueue);
this.thunk = var2;
}
}
题外: ReferenceQueue 有什么用?
ReferenceQueue 提供了一种对象被 GC 时的一种通知功能;
软弱虚引用都是 Reference 子类, 有这么几种状态, active, pending, enqueued, inactive;
指向的对象存活的时候, 是 active;
当 Reference 指向的对象被垃圾回收器回收的时候, 垃圾回收器会把他们放到 Reference 的静态成员, 一个 pending 链表中;
状态变为 pending 状态;
Reference 本身有指向下一个 Reference 的指针, 所以可以被组织成单链表;
Reference 类加载的时候, 在 static 代码块里起了一个最高优先级的守护线程, 会将 pending 链表中的头部 Reference 取出, 如果有与之绑定的引用队列, 就放到与之绑定的队列中去;
而如果发现 pending 列表里的对象是一个 Cleaner, 则会调用 Cleaner 的 clean 方法; 前面我们提到过, DBB 创建 Cleaner 的时候, 给了一个 Deallocator, 它是一个 Runnable, 在 Cleaner.clean 方法中, 就调用了它的 run 方法 ( 注意不是 start );
一开始创建这个 Deallocator 的时候, 把直接内存的地址还有大小都给了这个 Deallocator, run 方法会根据这写信息, 调用 Native 方法 freeMemory 完成直接内存的释放;