说说零拷贝

从一个业务场景开始:从本地磁盘读取一个文件通过socket发送出去。(kafka消费场景)

传统的I/O接口处理流程如下:读文件到应用->应用打包文件到socket->发送

  1. 应用发起系统调用sys_read()(或等价的方法)请求读磁盘文件
  2. 系统切换到内核态,读磁盘数据到内核读缓冲区(DMA方式)
  3. 系统将内核读缓冲区数据拷贝到应用缓冲区(CPU拷贝),read方法返回,系统切换到用户态。
  4. 应用包装好数据后发起send() 系统调用
  5. 系统切换到内核态,将数据写入到socket缓冲区(CPU拷贝)
  6. 将socket缓冲区的数据发送给网络接口卡(DMA方式),网卡发出
  7. send() 返回,系统切换到用户态回到应用。

整个过程将经历4次上下文切换,2次CPU拷贝。

I/O过程

1. NIO的零拷贝

java.nio包中有一个TransferTo接口,专门用来发送数据,我们来看看它是怎么做的。

TransferTo接口调用了本地TransferTo方法,在Linux平台上将发起sendfile系统调用,执行过程如下:

  1. 应用发起sendfile系统调用请求发送文件
  2. 系统切换到内核态,读磁盘数据到内核读缓冲区(DMA方式)
  3. 将内核读缓冲区的数据直接拷贝到socket缓冲区(CPU拷贝)
  4. 将socket缓冲区的数据发送给网络接口卡(DMA方式),网卡发出
  5. 系统切换到用户态回到应用

整个过程经历2次上下文切换和1次CPU拷贝。

如果底层NIC(网络接口卡)支持gather操作,可以进一步减少内核中的数据拷贝。在Linux 2.4以及更高版本的内核中,socket缓冲区描述符已被修改用来适应这个需求。这种方式不但减少上下文切换,同时消除了需要CPU参与的重复的数据拷贝。

用户这边的使用方式不变,依旧通过transferTo方法,但是方法的内部实现发生了变化:

  1. transferTo方法调用触发DMA引擎将文件上下文信息拷贝到内核缓冲区

  2. 数据不会被拷贝到套接字缓冲区,只有数据的描述符(包括数据位置和长度)被拷贝到套接字缓冲区。DMA 引擎直接将数据从内核缓冲区拷贝到协议引擎,这样减少了最后一次需要消耗CPU的拷贝操作。

将一个文件拷贝到另一个目录,使用nio方式性能提升100%,对比代码

2. 直接内存

在不需要进行数据文件操作时,可以使用NIO的零拷贝。但如果既需要IO速度,又需要进行数据操作,则需要使用NIO的直接内存映射。

Linux提供的mmap系统调用, 它可以将一段用户空间内存映射到内核空间, 当映射成功后, 用户对这段内存区域的修改可以直接反映到内核空间;同样地, 内核空间对这段区域的修改也直接反映用户空间。正因为有这样的映射关系, 就不需要在用户态(User-space)与内核态(Kernel-space) 之间拷贝数据, 提高了数据传输的效率,这就是以内存直接映射为基础的零拷贝技术。

NIO的直接内存映射

NIO中一个重要的类:MappedByteBuffer——java nio引入的文件内存映射方案,读写性能极高。MappedByteBuffer将文件直接映射到内存。可以映射整个文件,如果文件比较大的话可以考虑分段进行映射,只要指定文件的感兴趣部分就可以。

由于MappedByteBuffer申请的是直接内存,因此不受Minor GC控制,只能在发生Full GC时才能被回收,因此Java提供了DirectByteBuffer类来改善这一情况。它是MappedByteBuffer类的子类,同时它实现了DirectBuffer接口,维护一个Cleaner对象来完成内存回收。因此它既可以通过Full GC来回收内存,也可以调用clean()方法来进行回收

2.1 直接内存的创建

在ByteBuffer有两个子类,HeapByteBuffer和DirectByteBuffer。前者是存在于JVM堆中的,后者是存在于Native堆中的。

申请堆内存

1
2
3
4
5
public static ByteBuffer allocate(int capacity) {
if (capacity < 0)
throw new IllegalArgumentException();
return new HeapByteBuffer(capacity, capacity);
}

申请直接内存

1
2
3
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}

为什么使用直接内存(好处)

  • 对垃圾回收停顿的改善。因为full gc时,垃圾收集器会对所有分配的堆内内存进行扫描,垃圾收集对Java应用造成的影响,跟堆的大小是成正比的。过大的堆会影响Java应用的性能。如果使用堆外内存的话,堆外内存是直接受操作系统管理。这样做的结果就是能保持一个较小的JVM堆内存,以减少垃圾收集对应用的影响。full gc会回收空闲的直接内存。)
  • 减少了数据从JVM拷贝到native内存的次数,在某些场景下可以提升程序I/O的性能。
  • 可以突破JVM内存限制,操作更多的物理内存。

使用直接内存注意事项

  • 与堆内存相比直接内存读数据快、申请慢,所以适合申请次数少,访问频繁的场合。
  • 堆外内存只能通过序列化和反序列化来存储,保存对象速度比堆内存慢,不适合存储很复杂的对象。一般简单的对象或者扁平化的比较适合。
  • 当直接内存不足时会触发full gc,排查full gc的时候,一定要考虑。
  • 堆外内存难以控制,如果内存泄漏,那么很难排查

NIO的直接内存映射的函数调用

FileChannel提供了map方法来把文件映射为内存对象:

MappedByteBuffer map(int mode,long position,long size);
可以把文件的从position开始的size大小的区域映射为内存对象,mode指出了 可访问该内存映像文件的方式

READ_ONLY,(只读): 试图修改得到的缓冲区将导致抛出 ReadOnlyBufferException.(MapMode.READ_ONLY)

READ_WRITE(读/写): 对得到的缓冲区的更改最终将传播到文件;该更改对映射到同一文件的其他程序不一定是可见的。 (MapMode.READ_WRITE)

PRIVATE(专用): 对得到的缓冲区的更改不会传播到文件,并且该更改对映射到同一文件的其他程序也不是可见的;相反,会创建缓冲区已修改部分的专用副本。 (MapMode.PRIVATE)

使用参数-XX:MaxDirectMemorySize=10M,可以指定DirectByteBuffer的大小最多是10M。

对比代码

将一个文件读入内存不做处理,与nio处理方式进行对比,直接内存处理性能提升500%