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

前文我们介绍了 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 次的内核态和用户态的切换。具体来说有以下步骤:

image

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)虚拟内存空间可以远远大于物理内存空间
利用第一条特性可以优化一下上面的设计思路,就是把内核空间和用户空间的虚拟地址映射到同一个物理地址,这样就不需要来回复制了:

image

2、mmap+write方式

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

image

整体流程的核心区别就是,把数据读取到内核缓冲区后,应用程序进行写入操作时,直接是把内核 Read Buffer 的数据复制到 Socket Buffer 以便进行写入,这次内核之间的复制也是需要 CPU 参与的。
这个流程就少了一个CPU Copy,提升了 IO 的速度。不过发现上下文的切换还是 4 次,没有减少,因为还是要应用程序发起 write 操作。那能不能减少上下文切换呢?

3、sendfile方式

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

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 等概念,下期,再具体介绍下 Zero Copy 在 Netty 以及 KafKa 的应用吧

挖坑序列文章

10分钟看懂 Java IO 底层原理

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

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

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

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

你真的懂 Java 的 String 吗?

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342