1、传统IO通过网络发送数据流程
DMA(Direct Memory Access,直接存储器访问) DMA控制方式是以存储器为中心,在主存和I/O设备之间建立一条直接通路,在DMA控制器的控制下进行设备和主存之间的数据交换。这种方式只在传输开始和传输结束时才需要CPU的干预。它非常适用于高速设备与主存之间的成批数据传输。
1、处于用户态的程序无法直接访问物理设备资源数据,调用read方法后,需要从用户态切换到内核态通过系统调用将数据从磁盘通过DMA 的方式拷贝到内核缓冲区。
2、然后从内核态切换回用户态,将数据从内核缓冲区读入用户缓冲区,CPU参与拷贝
3、调用 write 方法,这时将数据从用户缓冲区写入 socket 缓冲区,CPU参与拷贝
4、用户态的程序无法向网卡写数据,因此需要再次从用户态切换到内核态,调用操作系统的写能力,使用 DMA 将 socket 缓冲区的数据写入网卡
5、写入完毕后需要再次切换回用户态
总结:
用户态与内核态切换 4次 ,两次DMA拷贝,两次CPU拷贝
2、java NIO零拷贝
2.1、标准的NIO读取文件数据发送网络流程
总结:
用户态与内核态切换 4次 ,两次DMA拷贝,四次CPU拷贝
2.2、NIO零拷贝优化
优化方向:
- 减少用户空间和内核空间的拷贝
- 减少内核空间的内存复制
- 减少jvm堆内存的复制
2.2.1、mmap + write 零拷贝优化
(1)流程&原理
**使用mmap系统调用可以将内核缓冲区的地址与用户缓冲区进行映射,省去了将数据从内核缓冲区拷贝到用户缓冲区的过程
mmap + write 实现零拷贝的基本流程如下:
(1)用户进程向内核发起系统mmap调用
(2) 将用户进程的内核空间的读缓冲区与用户空间的缓存区进行内存地址映射
(3)内核基于DMA Copy将文件数据从磁盘复制到内核缓冲区
(4)用户进程mmap系统调用完成并返回
(5) 用户进程向内核发起wri te系统调用
(6)内核基于cPU Copy将数据从内核缓冲区拷贝到Socket缓冲区
(7)内核基于DMA Copy将数据从Socket缓冲区拷贝到网卡
(8) 用户进程write系统调用完成并返回
总结:
用户态与内核态切换 4次 ,两次DMA拷贝,三次CPU拷贝,减少了一次CPU拷贝
缺点:mmap主要用处是提高IO性能,特别是针对大文件,对于小文件,内存映射文件反而会导致碎片空间的浪费
(2)java中使用mmap
MappedByteBuffer类**
/**
*
* @param sourceFilePath
* @param targetFilePath
*/
public static void copyFileByMmap(String sourceFilePath,String targetFilePath){
var start = System.currentTimeMillis();
try {
@Cleanup FileChannel inChannel = new RandomAccessFile(sourceFilePath, "r").getChannel();
@Cleanup FileChannel outChannel = new RandomAccessFile(targetFilePath, "rw").getChannel();
MappedByteBuffer sourceBuffer = inChannel.map(FileChannel.MapMode.READ_ONLY, 0, inChannel.size());
outChannel.write(sourceBuffer);
} catch (FileNotFoundException e) {
System.out.println(" 源文件路径:" + sourceFilePath + "复制源文件不存在");
} catch (IOException e) {
System.out.println("文件复制异常" + e.getMessage());
}
var end = System.currentTimeMillis();
System.out.println("耗时:" + (end - start));
}
2.2.2、sendfile
Linux2.1版本中的sendfile
(1)流程&原理
sendfile基本流程如下:
(1)用户进程发起sendfi le系统调用
(2)内核基于DMA Copy将文件数据从磁盘拷贝到内核缓冲区
(3)内核将内核缓冲区 中的文件数据拷贝到Socket缓冲区
(4)内核基于socket缓冲区中文件数据拷贝到复制到网卡
(5)用户进程sendfile系统调用完成并返回
总结:
用户态与内核态切换 两次 ,两次DMA拷贝,1次CPU拷贝**
缺点: Sendfile拷贝方式存在用户程序不能对数据进行修改的问题
2.2.3、sendfile + DMA Gather Cpoy
Linux2.4 版本的 sendfile + 带 「分散-收集(Scatter-gather)」的DMA
(1)流程&原理
sendfile + DMA GatherCopy基本流程如下:
(1)用户进程发起sendfi le系统调用
(2)内核基于DMA Copy将文件数据从磁盘拷贝到内核缓冲区
(3)内核将内核缓冲区 中的文件描述信息(文件描述符,数据长度)拷贝到Socket缓冲区
(4)内核基于socket缓冲区中的文件描述信息和DMA硬件提供的Gather Copy功能将内核缓冲区数据复制到网卡
(5)用户进程sendfi le系统调用完成并返回
**
总结:
用户态与内核态切换 两次 ,两次DMA拷贝,0次CPU拷贝
缺点: Sendfile+DMA gather copy拷贝方式存在用户程序不能对数据进行修改的问题
(2)java中使用sendfile
channel 调用 transferTo/transferFrom 方法拷贝数据
/**
*
* @param sourceFilePath
* @param targetFilePath
*/
public static void copyFileBySendFile(String sourceFilePath,String targetFilePath){
var start = System.currentTimeMillis();
try {
FileChannel sourceChannle = new FileInputStream(sourceFilePath).getChannel();
FileChannel targetChannle = new FileOutputStream(targetFilePath).getChannel();
long size = sourceChannle.size();
//表示还剩余多少字节没有传输
long left = size;
while (left > 0){
//底层会使用操作系统的零拷贝,一次最多传输2g的数据
left -= sourceChannle.transferTo(size - left,sourceChannle.size(),targetChannle);
}
targetChannle.force(true);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
var end = System.currentTimeMillis();
System.out.println("copyFileBySendFile耗时:" + (end - start));
}
2.2.4、使用直接内存
为什么Java的堆内存地址值,与JNI堆外内存的地址值不一致? 由于JVM的有自己的内存模型,JVM缓冲区起始地址和长度,与JNI堆外内存的值不一致,所以,不能直接传递给JNI函数去调用底层的c语言内存操作函数。
通过 DirectByteBuffer
- ByteBuffer.allocate(10) HeapByteBuffer 使用的还是 java 内存
- ByteBuffer.allocateDirect(10) DirectByteBuffer 使用的是操作系统内存
java可以使用DirectByteBuffer将堆外内存映射到jvm内存中来直接访问使用,
- 这块内存不受jvm垃圾回收影响,因此内存地址固定,有助于IO读写
- java中的DirectByteBuffer 对象仅维护了此内存的虚引用,内存回收分为两步
- DirectByteBuffer 对象被垃圾回收,将虚引用加入引用队列
- 通过专门的线程访问引用队列,根据虚引用释放堆外内存
- 减少了一次数据拷贝,用户态和内核态的切换次数没变
总结:
用户态与内核态切换 4次 ,两次DMA拷贝,两次CPU拷贝
优点: 减少了一个数据在堆外内存和堆内的内存拷贝,降低了堆内存的占用,减轻了gc压力
缺点: **
3、常用组件的零拷贝方式
RocketMQ
RocketMQ选择了mmap+write 这种零拷贝方式,适用于业务级消息这种小块文件的数据持久化和传输。
Kafka
采用的是Sendfile 这种零拷贝方式,适用于系统日志消息这种高吞叶量的大块文件的数据持久化和传输(Kafka的索引文件使用的是mmap+write方式,仅仅数据文件使用的是Sendfile方式)。