百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术分类 > 正文

I/O Zero Copy是什么?看完这篇你绝对会了

ztj100 2024-11-09 15:18 12 浏览 0 评论

前文我们介绍了 Java I/O 的底层原理,想必大家都知道类似 Netty、KafKa 等大数据量高吞吐框架都会提到一个概念 Zero Copy(零拷贝),这是什么技术呢,今天我们来学习下。


一、为什么需要 Zero Copy技术?

要想了解 zero-copy 我们需要知道该技术的应用场景,网络传输中一个基本的场景是:通过网络传输一个文件,按照一般的思路,用Java语言来描述发送端的逻辑,大致如下。

Socket socket = new Socket(HOST, PORT);
InputStream inputStream = new FileInputStream(FILE_PATH);
OutputStream outputStream = new DataOutputStream(socket.getOutputStream());
byte[] buffer = new byte[4096];
while (inputStream.read(buffer) >= 0) {    
  outputStream.write(buffer);
}

看起来是很简单的,但是如果我们深入到操作系统的层面,就会发现实际的微观操作要更复杂。在这个场景中,至少出现 4 次数据拷贝和 3 次的内核态和用户态的切换。具体来说有以下步骤:

1、JVM 发出 read() 系统调用,触发上下文切换,从用户态切换到内核态。第一次 copy 是通过 DMA 引擎直接从硬盘文件系统读取文件内容存储在内核缓存空间。

2、将数据从内核缓冲区拷贝到用户空间缓冲区,read() 系统调用返回,并从内核态切换回用户态。

3、JVM发出 write() 系统调用,触发上下文切换,从用户态切换到内核态,将数据从用户缓冲区拷贝到内核中与目的地 Socket 关联的缓冲区。

4、数据最终经由 Socket 通过 DMA 传送到硬件(如网卡)缓冲区,write() 系统调用返回,并从内核态切换回用户态。

我们都知道,上下文切换是 CPU 密集型的工作,数据拷贝是 I/O 密集型的工作(至于为啥有内核缓冲与进程缓冲区,可以看这篇文章《10分钟看懂 Java IO 底层原理》)。如果一次简单的传输就要像上面这样复杂的话,效率是相当低下的。Zreo Copy (零拷贝)机制的终极目标,就是消除冗余的上下文切换和数据拷贝,提高效率。

二、Zero Copy 原理

通过上面的分析可以看出,第 2、3 次拷贝(也就是从内核空间到用户空间的来回复制)是没有意义的,数据应该可以直接从内核缓冲区直接送入 Socket 缓冲区。Zero Copy这个技术就是来解决这个问题,不过零拷贝需要由操作系统直接支持,不同操作系统有不同的实现方法。

关于零拷贝提供了两种解决方式:mmap + write 方式、sendfile 方式

1、虚拟内存

所有现代操作系统都使用虚拟内存,使用虚拟地址取代物理地址,这样做的好处就是:

1)多个虚拟内存可以指向同一个物理地址

2)虚拟内存空间可以远远大于物理内存空间

利用第一条特性可以优化一下上面的设计思路,就是把内核空间和用户空间的虚拟地址映射到同一个物理地址,这样就不需要来回复制了:


2、mmap+write方式

使用 mmap+write 方式替换原来的传统 IO 方式,就是利用了虚拟内存的特性。mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系;这样就可以省掉原来内核 Read Buffer copy 数据到用户缓冲区,但是还是需要内核 Read Buffer 将数据 copy 到内核 Socket Buffer,如下图:

整体流程的核心区别就是,把数据读取到内核缓冲区后,应用程序进行写入操作时,直接是把内核 Read Buffer 的数据复制到 Socket Buffer 以便进行写入,这次内核之间的复制也是需要 CPU 参与的。

这个流程就少了一个CPU Copy,提升了 IO 的速度。不过发现上下文的切换还是 4 次,没有减少,因为还是要应用程序发起 write 操作。那能不能减少上下文切换呢?


3、sendfile方式

为了简化用户接口,同时减少 CPU 的拷贝次数,Linux 在版本 2.1 中引入了 sendfile() 这个系统调用。通过 sendfile 传送文件只需要一次系统调用,当调用 sendfile 时:

1)首先(通过 DMA )将数据从磁盘读取到内核 Read Buffer 中;

2)然后将内核 Read Buffer 的数据拷贝到 Socket buffer 中;

3)最后将 Socket buffer 中的数据 copy 到网卡设备中发送;

到这里就只有 3 次 Copy,其中只有 1 次 CPU Copy;3 次上下文切换。那能不能把CPU Copy减少到没有呢?

Linux2.4内核进行了优化,提供了gather操作,这个操作可以把最后一次CPU Copy去除,什么原理呢?就是在内核空间 Read Buffer 和 Socket Buffer 不做数据复制,而是将 Read Buffer 的内存地址、偏移量记录到相应的 Socket Buffer 中,这样就不需要复制(其实本质就是和虚拟内存的解决方法思路一样,就是内存地址的记录)

三、Java Zero Copy

Java 的 Zero Copy 是由 Java NIO 来提供的,NIO 三大核心要素 :Buffer(缓冲区)、Channel(通道)和 Selector(选择器),Buffer 和Channel 组合实现了Java 的 Zero Copy,主要是由 MappedByteBuffer、DirectByteBuffer 以及 FileChannel来完成的。


  • MappedByteBuffer

MappedByteBuffer 是 NIO 基于内存映射(mmap)这种零拷贝方式的提供的一种实现,它继承自 ByteBuffer。FileChannel 定义了一个 map() 方法,它可以把一个文件从 position 位置开始的 size 大小的区域映射为内存映像文件。

map() 方法是 java.nio.channels.FileChannel 的抽象方法,由子类 FileChannelImpl实现,下面是和内存映射相关的核心代码:

public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {    
  int pagePosition = (int)(position % allocationGranularity);    
  long mapPosition = position - pagePosition;    
  long mapSize = size + pagePosition;    
  try {        
    addr = map0(imode, mapPosition, mapSize);    
  } catch (OutOfMemoryError x) {        
    System.gc();        
    try {            
      Thread.sleep(100);        
    } catch (InterruptedException y) {            
      Thread.currentThread().interrupt();        
    }        
    try {            
      addr = map0(imode, mapPosition, mapSize);        
    } catch (OutOfMemoryError y) {            
      throw new IOException("Map failed", y);        
    }    
  }        
  int isize = (int)size;    
  Unmapper um = new Unmapper(addr, mapSize, isize, mfd);    
  if ((!writable) || (imode == MAP_RO)) {        
    return Util.newMappedByteBufferR(isize, addr + pagePosition, mfd, um);    
  } else {        
    return Util.newMappedByteBuffer(isize, addr + pagePosition, mfd, um);    
  }
}

map() 方法通过本地方法 map0() 为文件分配一块虚拟内存,作为它的内存映射区域,然后返回这块内存映射区域的起始地址。

1)文件映射需要在 Java 堆中创建一个 MappedByteBuffer 的实例。如果第一次文件映射导致 OOM,则手动触发垃圾回收,休眠 100ms 后再尝试映射,如果失败则抛出异常。

2)通过 Util 的 newMappedByteBuffer方法或者 newMappedByteBufferR方法反射创建一个 DirectByteBuffer 实例,其中 DirectByteBuffer 是 MappedByteBuffer 的子类。

map() 方法返回的是内存映射区域的起始地址,通过(起始地址 + 偏移量)就可以获取指定内存的数据。这样一定程度上替代 read() 或 write() 方法,底层直接采用 Unsafe 类的 getByte() 和 putByte() 方法对数据进行读写。


  • DirectByteBuffer

DirectByteBuffer 继承于 MappedByteBuffer ,DirectByteBuffer 的对象引用位于 Java 内存模型的堆里面,JVM 可以对 DirectByteBuffer 的对象进行内存分配和回收管理,一般使用 DirectByteBuffer 的静态方法 allocateDirect() 创建 DirectByteBuffer 实例并分配内存。

DirectByteBuffer 内部的字节缓冲区位在于堆外的(用户态)直接内存,它是通过 Unsafe 的本地方法 allocateMemory() 进行内存分配,底层调用的是操作系统的 malloc() 函数。

DirectByteBuffer(int cap) {    
  super(-1, 0, cap, cap);    
  boolean pa = VM.isDirectMemoryPageAligned();    
  int ps = Bits.pageSize();    
  long size = Math.max(1L, (long)cap + (pa ? ps : 0));    
  Bits.reserveMemory(size, cap);     
  long base = 0;    
  try {        
    base = unsafe.allocateMemory(size);    
  } catch (OutOfMemoryError x) {        
    Bits.unreserveMemory(size, cap);        
    throw x;    
  }    
  unsafe.setMemory(base, size, (byte) 0);    
  if (pa && (base % ps != 0)) {        
    address = base + ps - (base & (ps - 1));    
  } else {        
    address = base;    
  }    
  cleaner = Cleaner.create(this, new Deallocator(base, size, cap));    
  att = null;
}

除此之外,初始化 DirectByteBuffer 时还会创建一个 Deallocator 线程,并通过 Cleaner 的 freeMemory() 方法来对直接内存进行回收操作,freeMemory() 底层调用的是操作系统的 free() 函数。


  • FileChannel

FileChannel 是一个用于文件读写、映射和操作的通道,同时它在并发环境下是线程安全的,基于 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel() 方法可以创建并打开一个文件通道。FileChannel 定义了 transferFrom() 和 transferTo() 两个抽象方法,它通过在通道和通道之间建立连接实现数据传输的。

transferTo() 和 transferFrom() 方法的底层实现是由 FileChannelImpl 提供的,底层原理是基于 sendfile 实现数据传输的。

以 transferTo() 的源码实现为例。FileChannelImpl 首先执行 transferToDirectly() 方法,以 sendfile 的零拷贝方式尝试数据拷贝。如果系统内核不支持 sendfile,进一步执行 transferToTrustedChannel() 方法,以 mmap 的零拷贝方式进行内存映射,这种情况下目的通道必须是 FileChannelImpl 或者 SelChImpl 类型。如果以上两步都失败了,则执行 transferToArbitraryChannel() 方法,基于传统的 I/O 方式完成读写,具体步骤是初始化一个临时的 DirectBuffer,将源通道 FileChannel 的数据读取到 DirectBuffer,再写入目的通道 WritableByteChannel 里面。

public long transferTo(long position, long count, WritableByteChannel target)        throws IOException {    
  // 计算文件的大小    
  long sz = size();    
  // 校验起始位置    
  if (position > sz)        
    return 0;    
  int icount = (int)Math.min(count, Integer.MAX_VALUE);    
  // 校验偏移量    
  if ((sz - position) < icount)        
    icount = (int)(sz - position);     
  long n;     
  if ((n = transferToDirectly(position, icount, target)) >= 0)        
    return n;     
  if ((n = transferToTrustedChannel(position, icount, target)) >= 0)        
    return n;     
  return transferToArbitraryChannel(position, icount, target);
}

小结

本文开篇详述了为什么需要 Zero Copy以及其底层原理。从源码着手分析了 Java NIO 对零拷贝的实现,主要包括基于内存映射(mmap)方式的 MappedByteBuffer 以及基于 sendfile 方式的 FileChannel。

PS:这个坑已经越挖越大了,在这里又引入了虚拟内存、mmap 以及 DMA (Direct Memory Access),甚至 Java 的 NIO 等概念。


挖坑序列文章

10分钟看懂 Java IO 底层原理

深入分析 Java 需要编码的场景

Java 编码很难吗?看完这篇文章你就懂了

编码字符集和字符集编码傻傻分不清楚!看完这篇文章你就懂了?

为什么 String 要设计成 final ,又如何设计一个不可变类呢?

你真的懂 Java 的 String 吗?

相关推荐

Vue 技术栈(全家桶)(vue technology)

Vue技术栈(全家桶)尚硅谷前端研究院第1章:Vue核心Vue简介官网英文官网:https://vuejs.org/中文官网:https://cn.vuejs.org/...

vue 基础- nextTick 的使用场景(vue的nexttick这个方法有什么用)

前言《vue基础》系列是再次回炉vue记的笔记,除了官网那部分知识点外,还会加入自己的一些理解。(里面会有部分和官网相同的文案,有经验的同学择感兴趣的阅读)在开发时,是不是遇到过这样的场景,响应...

vue3 组件初始化流程(vue组件初始化顺序)

学习完成响应式系统后,咋们来看看vue3组件的初始化流程既然是看vue组件的初始化流程,咋们先来创建基本的代码,跑跑流程(在app.vue中写入以下内容,来跑流程)...

vue3优雅的设置element-plus的table自动滚动到底部

场景我是需要在table最后添加一行数据,然后把滚动条滚动到最后。查网上的解决方案都是读取html结构,暴力的去获取,虽能解决问题,但是不喜欢这种打补丁的解决方案,我想着官方应该有相关的定义,于是就去...

Vue3为什么推荐使用ref而不是reactive

为什么推荐使用ref而不是reactivereactive本身具有很大局限性导致使用过程需要额外注意,如果忽视这些问题将对开发造成不小的麻烦;ref更像是vue2时代optionapi的data的替...

9、echarts 在 vue 中怎么引用?(必会)

首先我们初始化一个vue项目,执行vueinitwebpackechart,接着我们进入初始化的项目下。安装echarts,npminstallecharts-S//或...

无所不能,将 Vue 渲染到嵌入式液晶屏

该文章转载自公众号@前端时刻,https://mp.weixin.qq.com/s/WDHW36zhfNFVFVv4jO2vrA前言...

vue-element-admin 增删改查(五)(vue-element-admin怎么用)

此篇幅比较长,涉及到的小知识点也比较多,一定要耐心看完,记住学东西没有耐心可不行!!!一、添加和修改注:添加和编辑用到了同一个组件,也就是此篇文章你能学会如何封装组件及引用组件;第二能学会async和...

最全的 Vue 面试题+详解答案(vue面试题知识点大全)

前言本文整理了...

基于 vue3.0 桌面端朋友圈/登录验证+60s倒计时

今天给大家分享的是Vue3聊天实例中的朋友圈的实现及登录验证和倒计时操作。先上效果图这个是最新开发的vue3.x网页端聊天项目中的朋友圈模块。用到了ElementPlus...

不来看看这些 VUE 的生命周期钩子函数?| 原力计划

作者|huangfuyk责编|王晓曼出品|CSDN博客VUE的生命周期钩子函数:就是指在一个组件从创建到销毁的过程自动执行的函数,包含组件的变化。可以分为:创建、挂载、更新、销毁四个模块...

Vue3.5正式上线,父传子props用法更丝滑简洁

前言Vue3.5在2024-09-03正式上线,目前在Vue官网显最新版本已经是Vue3.5,其中主要包含了几个小改动,我留意到日常最常用的改动就是props了,肯定是用Vue3的人必用的,所以针对性...

Vue 3 生命周期完整指南(vue生命周期及使用)

Vue2和Vue3中的生命周期钩子的工作方式非常相似,我们仍然可以访问相同的钩子,也希望将它们能用于相同的场景。...

救命!这 10 个 Vue3 技巧藏太深了!性能翻倍 + 摸鱼神器全揭秘

前端打工人集合!是不是经常遇到这些崩溃瞬间:Vue3项目越写越卡,组件通信像走迷宫,复杂逻辑写得脑壳疼?别慌!作为在一线摸爬滚打多年的老前端,今天直接甩出10个超实用的Vue3实战技巧,手把...

怎么在 vue 中使用 form 清除校验状态?

在Vue中使用表单验证时,经常需要清除表单的校验状态。下面我将介绍一些方法来清除表单的校验状态。1.使用this.$refs...

取消回复欢迎 发表评论: