深入理解零拷贝

深入理解零拷贝

title: 深入理解零拷贝 date: 2021/5/21 16:11 一、I/O 概念 1.1 缓冲区 缓冲区是所有 I/O 的基础,I/O 讲的无非就是把数据移进或移出缓冲区;进程执行 I/O 操作,就是向操作系统发出请求,让它要么把缓冲区的数据排干(写),要么填充缓冲区(读)。 Java 进程...

title: 深入理解零拷贝
date: 2021/5/21 16:11


一、I/O 概念

1.1 缓冲区

缓冲区是所有 I/O 的基础,I/O 讲的无非就是把数据移进或移出缓冲区;进程执行 I/O 操作,就是向操作系统发出请求,让它要么把缓冲区的数据排干(写),要么填充缓冲区(读)。

Java 进程发起 Read 请求加载数据大致的流程图

进程发起 Read 请求之后,内核接收到 Read 请求之后,会先检查内核空间中是否已经存在进程所需要的数据,如果已经存在,则直接把数据 Copy 给进程的缓冲区。

如果没有内核随即向磁盘控制器发出命令,要求从磁盘读取数据,磁盘控制器把数据直接写入内核 Read 缓冲区,这一步通过 DMA(直接存储器访问,可以理解为硬件单元,用来解放 CPU 完成文件 IO)完成。

接下来就是内核将数据 Copy 到进程的缓冲区;如果进程发起 Write 请求,同样需要把用户缓冲区里面的数据 Copy 到内核的 Socket 缓冲区里面,然后再通过 DMA 把数据 Copy 到网卡中,发送出去。

你可能觉得这样挺浪费空间的,每次都需要把内核空间的数据拷贝到用户空间中,所以零拷贝的出现就是为了解决这种问题的。

这里简单提一嘴,关于零拷贝提供了两种方式分别是:

  • mmap+write
  • Sendfile

1.2 虚拟内存 & 虚拟地址空间

CPU是通过寻址来访问内存的。32位CPU的寻址宽度是 0~0xFFFFFFFF ,计算后得到的大小是4G,也就是说可支持的物理内存最大是4G。

但在实践过程中,碰到了这样的问题,程序需要使用4G内存,而可用物理内存小于4G,导致程序不得不降低内存占用。

为了解决此类问题,现代CPU引入了 MMU(Memory Management Unit 内存管理单元)。

MMU 的核心思想是利用虚拟地址替代物理地址,即 CPU 寻址时使用虚址,由 MMU 负责将虚址映射为物理地址。

MMU的引入,解决了对物理内存的限制,对程序来说,就像自己在使用 4G 内存一样

虚拟内存:虚拟内存是一种逻辑上扩充物理内存的技术。基本思想是用软、硬件技术把内存与外存这两级存储器当做一级存储器来用。虚拟内存技术的实现利用了自动覆盖和交换技术。简单的说就是将硬盘的一部分作为内存来使用。(例如 Linux 中的 Swap 区)

虚拟地址空间:虚拟地址空间指的是CPU 能够寻址到的虚拟内存的范围,Linux 系统会给每个进程提供一份虚拟地址空间(只有当它实际被使用时才分配物理内存),在 32 位 CPU 的机器上他的寻址范围在 0x00000000 ~ 0xFFFFFFFF 这一段地址中(约 4G),其中高1G的空间为内核空间,由操作系统调用,低3G的空间为用户空间,由用户使用。

虚拟空间的划分

CPU在寻址的时候,是按照虚拟地址来寻址,然后通过MMU(内存管理单元)将虚拟地址转换为物理地址。

CPU 虚拟地址寻址

缺页中断:因为只有程序的一部分加入到内存中,所以会出现所寻找的地址不在内存中的情况(CPU产生缺页异常),如果在内存不足的情况下,就会通过页面置换算法来将内存中的页面置换出来,然后将在外存中的页面加入到内存中,使程序继续正常运行。

常见的页面置换算法

  • OPT页面置换算法(最佳页面置换算法) :理想情况,不可能实现,一般作为衡量其他置换算法的方法。
  • FIFO页面置换算法(先进先出页面置换算法) : 总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。
  • LRU页面置换算法(最近未使用页面置换算法) :LRU(Least Currently Used)算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间T,当须淘汰一个页面时,选择现有页面中其T值最大的,即最近最久未使用的页面予以淘汰。
  • LFU页面置换算法(最少使用页面排序算法) : LFU(Least Frequently Used)算法会让系统维护一个按最近一次访问时间排序的页面链表,链表首节点是最近刚刚使用过的页面,链表尾节点是最久未使用的页面。访问内存时,找到相应页面,并把它移到链表之首。缺页时,置换链表尾节点的页面。也就是说内存内使用越频繁的页面,被保留的时间也相对越长。
image

💡使用虚拟的地址取代物理地址的好处

  1. 一个以上的虚拟地址可以指向同一个物理内存地址(多对一)。

  2. 虚拟地址空间可大于实际可用的物理地址(通过虚拟内存进行扩展)。

利用第一条特性可以把内核空间地址和用户空间的虚拟地址映射到同一个物理地址这样 DMA 就可以填充对内核和用户空间进程同时可见的缓冲区了

image

省去了内核与用户空间的往来拷贝,Java 的 java.nio.channels.FileChannel#map 也利用操作系统的此特性来提升性能。

💡虚拟地址空间与虚拟内存之间的关系

通过虚拟内存技术,我们使用的内存可以远大于物理内存的大小,例如物理内存 256M,但是我们可以为每个进程分配 4G 大小的虚拟地址空间,当 CPU 执行虚拟地址空间里面的代码时,在内存中寻找不到所需要的页面,就需要到外存中寻找,外存的这一部分,我们可以当成内存来使用,这也就是虚拟内存。(用虚拟内存来解释什么是虚拟内存 /笑哭)

虚拟地址空间不等于虚拟内存。虚拟地址空间是一个空间,不是真正存在的,只是通过CPU的寻址虚拟出来的一个范围。而虚拟内存是实实在在的硬盘的空间。

说白了,虚拟内存就是为了解决内存不够用的情况;而虚拟地址空间是对虚拟内存的使用进行了一层封装,让进程以为他有一段很大的连续内存空间。

我认为,现代计算机的内存已经够用了(上面说的好处2),完全可以抛弃虚拟内存和虚拟地址空间的概念,但是虚拟内存和虚拟地址空间还有另一个好处,他可以将一个以上的虚拟地址可以指向同一个物理内存(虚拟内存[磁盘])地址(上面说的好处1),从而实现 Java 的零拷贝。

💡虚拟内存有没有大小限制?

从硬件上看,Linux系统的内存空间由两个部分构成:物理内存和 SWAP(位于磁盘)。其中 SWAP 就是虚拟空间,在 Linux 中叫做交换空间,如果你装过 Linux 的话,其中有一个步骤就是让你分配交换空间(SWAP)的大小,所以从理论上讲,虚拟空间的大小取决于磁盘的大小

什么是 SWAP?

swap space是磁盘上的一块区域,可以是一个分区,也可以是一个文件,或者是他们的组合。简单点说,当系统物理内存吃紧时,Linux会将内存中不常访问的数据保存到swap上(换页),这样系统就有更多的物理内存为各个进程服务,而当系统需要访问swap上存储的内容时,再将swap上的数据加载到内存中,这就是我们常说的swap out和swap in。

💡虚拟地址空间有没有大小限制?

虚拟地址空间它表示的是 CPU 能够寻址到虚拟内存的范围,由于系统会给每个进程提供一份虚拟内存空间(只有当虚拟内存实际被使用时才分配物理内存),所以在 32 位 CPU 的机器上他的寻址范围在 0x00000000 ~ 0xFFFFFFFF 这一段地址中(约 4G),也就是说可以寻找到4G的地址空间。

当然,64 位机器基本就没有限制了,但是 64 位机器内存一般都在 16G 以上,基本上内存已经够用了,“换页”操作出现的可能性也比较小,所以 SWAP 设的很小也行。

1.2.1 页 & 页帧 & 页表

MMU:CPU 寻址时使用虚拟地址替代物理地址,然后再由 MMU(Memory Management Unit,内存管理单元)转换成物理地址。

内存分页(Paging)是在使用MMU的基础上,提出的一种内存管理机制。它将虚拟地址和物理地址按固定大小 4K(这个大小好像可以修改)分割成页(page)和页帧(page frame),并保证页与页帧的大小相同。

一块 4K 大小的虚拟地址范围称为

一块 4K 大小的物理地址范围称为页帧

页表:页表就像一个函数,输入是页号,输出是页桢。操作系统给每一个进程维护一个页表。所以不同进程的虚拟地址可能一样。页表给出了进程中每一页所对应的页帧的位置。

三者间关系

💡为什么要有分页机制?

假设内存是连续分配的(也就是程序在物理内存上是连续的)
1.进程A进来,向os申请了200的内存空间,于是os把0~199分配给A
2.进程B进来,向os申请了5的内存空间,os把200~204分配给它
3.进程C进来,向os申请了100的内存空间,os把205~304分配给它
4.这个时候进程B运行完了,把200~204还给os
但是很长时间以后,只要系统中的出现的进程的大小>5的话,200~204这段空间都不会被分配出去(只要A和C不退出)。
过了一段更长的时间,内存中就会出现许许多多200~204这样不能被利用的碎片……
而分页机制让程序可以在逻辑上连续、物理上离散。也就是说在一段连续的物理内存上,可能04(这个值取决于页面的大小)属于A,而59属于B,10~14属于C,从而保证任何一个“内存片段”都可以被分配出去。

💡页帧的生命周期

虽然每个进程拥有4g的虚拟地址空间,但显然在它运行的每个小段时间内,它需要访问都是少量的空间,并且这些空间一般都是地址连续的如果真的给这个进程分配全部的物理内存,那绝大部分物理内存就浪费了。所以现在一般采用分页的技术(将虚拟地址空间和物理内存划分成固定大小的小块),建立页表,把进程的虚拟地址空间页映射到物理内存的页帧上。这里页表保存的就是映射关系。然后随着进程的运行,就会按需分配页,那些长时间未使用的页帧又会被操作系统回收

1.2.2 Page Cache & Swap Cache & Buffer Cache

局部性原理

程序在执行时呈现出局部性规律,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域,具体来说,局部性通常有两种形式:时间局部性和空间局部性。

  • 时间局部性:被引用过一次的存储器位置在未来会被多次引用。
  • 空间局部性:如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。

Page Cache

在内存中(包括物理内存和虚拟内存)

Page Cache 以页(4K)为单位,缓存文件内容。缓存在Page Cache中的文件数据,能够更快的被用户读取。同时对于带buffer的写入操作,数据在写入到Page Cache中即可立即返回,而不需等待数据被实际持久化到磁盘,进而提高了上层应用读写文件的整体性能。

其实内存中的内容无非就两种

  1. 从文件加载进来的,例如 .class 文件、FileChannel#map、FileChannel#transforTo 等(PageCache)
  2. 代码运行时使用到的内存,map.put()(用户空间内存)

Swap Cache

在磁盘上

系统中常常会有一些进程在初始化时要了很多memory(主要是通过malloc获取的匿名page),初始化完成之后,这部分memory该进程不会经常用到,也没有释放。这就造成了内存的浪费。Linux就想了个办法要把这些memory中的数据置换到磁盘中,然后将这个memory标记为可回收,然后Linux中页框回收机制(不准备介绍)就会将这些page回收然后将这些page让给有需要的进程来用。

Buffer Cache

在内存中

磁盘的最小数据单位为sector,每次读写磁盘都是以sector为单位对磁盘进行操作。sector大小跟具体的磁盘类型有关,有的为512Byte, 有的为4K Bytes。无论用户是希望读取1个byte,还是10个byte,最终访问磁盘时,都必须以sector为单位读取,如果裸读磁盘,那意味着数据读取的效率会非常低。同样,如果用户希望向磁盘某个位置写入(更新)1个byte的数据,他也必须整个刷新一个sector,言下之意,则是在写入这1个byte之前,我们需要先将该1byte所在的磁盘sector数据全部读出来,在内存中,修改对应的这1个byte数据,然后再将整个修改后的sector数据,一口气写入磁盘。为了降低这类低效访问,尽可能的提升磁盘访问性能,内核会在磁盘sector上构建一层缓存,他以sector的整数倍力度单位(block),缓存部分sector数据在内存中,当有数据读取请求时,他能够直接从内存中将对应数据读出。当有数据写入时,他可以直接再内存中直接更新指定部分的数据,然后再通过异步方式,把更新后的数据写回到对应磁盘的sector中。这层缓存则是块缓存Buffer Cache。

💡两类缓存的逻辑关系

Page Cache和Buffer Cache是一个事物的两种表现:对于一个Page而言,对上,他是某个File的一个Page Cache,而对下,他同样是一个Device上的一组Buffer Cache

在虚拟内存机制出现以前,操作系统使用块缓存系列,但是在虚拟内存出现以后,操作系统管理IO的粒度更大,因此采用了页缓存机制,页缓存是基于页的、面向文件的缓存机制。

目前Linux Kernel代码中,Page Cache和Buffer Cache实际上是统一的,无论是文件的Page Cache还是Block的Buffer Cache最终都统一到Page上

💡Java 与 Page Cache
image

由上图可知 FileChannel#map 方法映射出来的文件并没有使用虚拟内存的空间(SwapCache),那总不能 2G 文件全部放内存吧,所以他是不是把那个文件本身当做了虚拟空间的一部分??所以我认为虚拟内存真正的含义应该是可以把磁盘当做内存来使的一种技术,让应用程序以为在内存中(通过虚拟地址空间),实际上是用到了才会取

二、JVM 与 Linux 的内存关系

2.1 Linux 的进程内存模型

JVM以一个进程(Process)的身份运行在Linux系统上,了解Linux与进程的内存关系,是理解JVM与Linux内存的关系的基础。下图给出了硬件、系统、进程三个层面的内存之间的概要关系。

image

从硬件上看,Linux系统的内存空间由两个部分构成:物理内存和SWAP(位于磁盘)

物理内存是Linux活动时使用的主要内存区域;当物理内存不够使用时,Linux会把一部分暂时不用的内存数据放到磁盘上的SWAP中去,以便腾出更多的可用内存空间;而当需要使用位于SWAP的数据时,必须 先将其换回到内存中。

从Linux系统上看,除了引导系统的BIN区,整个内存空间主要被分成两个部分:内核内存(Kernel space)、用户内存(User space)。

内核内存是Linux自身使用的内存空间,主要提供给程序调度、内存分配、连接硬件资源等程序逻辑使用。

用户内存是提供给各个进程主要空间,Linux给各个进程提供相同的虚拟内存空间;这使得进程之间相互独立,互不干扰。它给每一个进程一定虚拟地址空间,而只有当实际被使用时,才分配物理内存

如下图所示,对于32的Linux系统来说,一般将0~3G的虚拟内存空间分配做为用户空间将3~4G的虚拟内存空间分配 为内核空间;64位系统的划分情况是类似的。

image

从进程的角度来看,进程能直接访问的用户内存(虚拟内存空间)被划分为5个部分:代码区、数据区、堆区、栈区、未使用区。

  • 代码区中存放应用程序的机器代码,运行过程中代码不能被修改,具有只读和固定大小的特点。

  • 数据区中存放了应用程序中的全局数据,静态数据和一些常量字符串等,其大小也是固定的。

  • 堆是运行时程序动态申请的空间,属于程序运行时直接申请、释放的内存资源。

  • 栈区用来存放函数的传入参数、临时变量,以及返回地址等数据。

  • 未使用区是分配新内存空间的预备区域。

2.2 进程与JVM内存空间

JVM本质就是一个进程,因此其内存空间(也称之为运行时数据区,注意与JMM的区别)也有进程的一般特点。

但是,JVM又不是一个普通的进程,其在内存空间上有许多崭新的特点,主要原因有两个:

  • JVM将许多本来属于操作系统管理范畴的东西,移植到了JVM内部,目的在于减少系统调用的次数;
  • Java NIO,目的在于减少用于读写IO系统调用的开销
JVM进程与普通进程内存模型比较

2.2.1 用户内存

永久代

永久代本质上是Java程序的代码区和数据区。Java程序中类(class),会被加载到整个区域的不同数据结构中去,包括常量池、域、方法数据、方法体、构造函数、以及类中的专用方法、实例初始化、接口初始化等。这个区域对于操作系统来说,是堆的一个部分;而对于Java程序来 说,这是容纳程序本身及静态资源的空间,使得JVM能够解释执行Java程序。

新生代 & 老年代

新生代和老年代才是Java程序真正使用的堆空间,主要用于内存对象的存储;但是其管理方式和普通进程有本质的区别

普通进程在运行时给内存对象分配空间时,比如C++执行new操作时,会触发一次分配内存空间的系统调用,由操作系统的线程根据对象的大小分配好空间后返 回;同时,程序释放对象时,比如C++执行delete操作时,也会触发一次系统调用,通知操作系统对象所占用的空间已经可以回收。

JVM对内存的使用和一般进程不同。JVM向操作系统申请一整段内存区域(具体大小可以在JVM参数调节)作为Java程序的堆(分为新生代和老年代);当Java程序申请内存空间,比如执行new操作,JVM将在这段空间中按所需大小分配给Java程序,并且Java程序不负责通知JVM何时可以释放这个对象的空间,垃圾对象内存空间的回收由JVM进行。

JVM的内存管理方式的优点是显而易见的,包括:

  1. 减少系统调用的次数,JVM在给Java程序分配内存空间时不需要操作系统干预,仅仅在 Java堆大小变化时需要向操作系统申请内存或通知回收,而普通程序每次内存空间的分配回收都需要系统调用参与;
  2. 减少内存泄漏,普通程序没有(或者 没有及时)通知操作系统内存空间的释放是内存泄漏的重要原因之一,而由JVM统一管理,可以避免程序员带来的内存泄漏问题。
未使用区

未使用区是分配新内存空间的预备区域。对于普通进程来说,这个区域被可用于堆和栈空间的申请及释放,每次堆内存分配都会使用这个区 域,因此大小变动频繁;对于JVM进程来说,调整堆大小及线程栈时会使用该区域,而堆大小一般较少调整,因此大小相对稳定。操作系统会动态调整这个区域的大小,并且这个区域通常并没有被分配实际的物理内存,只是允许进程在这个区域申请堆或栈空间。

2.2.2 内核内存

应用程序通常不直接和内核内存打交道,内核内存由操作系统进行管理和使用;不过随着Linux对性能的关注及改进,一些新的特性使得应用程序可以使用内核内存,或者是映射到内核空间

Java NIO正是在这种背景下诞生的,其充分利用了Linux系统的新特性,提升了Java程序的IO性能。

image

上图给出了Java NIO使用的内核内存在linux系统中的分布情况。nio buffer主要包括:nio使用各种channel时所使用的ByteBuffer、Java程序主动使用 ByteBuffer.allocateDirector申请分配的Buffer。

而在PageCache里面,nio使用的内存主要包 括:FileChannel.map方式打开文件占用mapped、FileChannel.transferTo和 FileChannel.transferFrom所需要的Cache(图中标示 nio file)。

通过JMX可以监控到NIO Buffer和 mapped 的使用情况,如下图所示。不过,FileChannel的实现是通过系统调用使用原生的PageCache,过程对于Java是透明的,无法监控到这部分内存的使用大小。

image

Linux和Java NIO在内核内存上开辟空间给程序使用,主要是减少不要的复制,以减少IO操作系统调用的开销。例如,将磁盘文件的数据发送网卡,使用普通方法和NIO时,数据流动比较下图所示:

image

将数据在内核内存和用户内存之间拷贝是比较消耗资源和时间的事情,而从上图我们可以看到,通过NIO的方式减少了2次内核内存和用户内存之间的数据拷贝。这是Java NIO高性能的重要机制之一(另一个是异步非阻塞)。

从上面可以看出,内核内存对于Java程序性能也非常重要,因此在划分系统内存使用时候,一定要给内核留出一定可用空间。

三、零拷贝

传统 IO 将一个文件通过 socket 写出

File f = new File("helloword/data.txt");
RandomAccessFile file = new RandomAccessFile(file, "r");

byte[] buf = new byte[(int)f.length()];
file.read(buf);

Socket socket = ...;
socket.getOutputStream().write(buf);

内部工作流程是这样的:

image

用户态与内核态的切换发生了 3 次(这个操作比较重量级),数据 copy 了 4 次。

  1. java 本身并不具备 IO 读写能力,因此 read 方法调用后,要从 java 程序的用户态切换至内核态,去调用操作系统(Kernel)的读能力,将数据读入内核缓冲区这期间用户线程阻塞,操作系统使用 DMA(Direct Memory Access)来实现文件读,其间也不会使用 cpu

    DMA 也可以理解为硬件单元,用来解放 cpu 完成文件 IO

  2. 内核态切换回用户态,将数据从内核缓冲区读入用户缓冲区(即 byte[] buf),这期间 cpu 会参与拷贝,无法利用 DMA

  3. 调用 write 方法,这时将数据从用户缓冲区(byte[] buf)写入 socket 缓冲区,cpu 会参与拷贝

  4. 接下来要向网卡写数据,这项能力 java 又不具备,因此又得从用户态切换至内核态,调用操作系统的写能力,使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu

可以看到中间环节较多,java 的 IO 实际不是物理设备级别的读写,而是缓存的复制,底层的真正读写是操作系统来完成的。

1.0 mmap + write 方式

使用 mmap+write 方式代替原来的 read+write 方式,mmap 是一种内存映射文件的方法,即将一个文件或者其他对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对应关系

这样就可以省掉原来内核 Read 缓冲区 Copy 数据到用户缓冲区,但是还是需要内核 Read 缓冲区将数据 Copy 到内核 Socket 缓冲区。

image

这种方式减少了一次数据拷贝(从内核空间 copy 缓存到用户空间),用户态与内核态的切换次数没有减少(还是三次)

代码实现:

File f = new File("helloword/data.txt");
RandomAccessFile file = new RandomAccessFile(file, "r");
// 其实这时并没有把文件加载内存中,而是把这个文件当做了虚拟内存中的一部分,等使用的时候才加载到内存中
MappedByteBuffer buf = file.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, f.length());

Socket socket = ...;
socket.getOutputStream().write(buf);

MappedByteBuffer

image

java.nio.channels.FileChannel#map

该方法有三个参数,MapMode,Position 和 Size,分别表示:

  • MapMode:映射的模式,可选项包括:READ_ONLY,READ_WRITE,PRIVATE。
  • Position:从哪个位置开始映射,字节数的位置。
  • Size:从 Position 开始向后多少个字节。

重点看一下 MapMode,前两个分别表示只读和可读可写,当然请求的映射模式受到 Filechannel 对象的访问权限限制(就是如果你这个 FileChannel 是只读的那么 model 为 READ_WRITE 也会报错),如果在一个没有读权限的文件上启用 READ_ONLY,将抛出 NonReadableChannelException

PRIVATE 模式表示写时拷贝的映射,意味着通过 put() 方法所做的任何修改都会导致产生一个私有的数据拷贝并且该拷贝中的数据只有 MappedByteBuffer 实例可以看到。该过程不会对底层文件做任何修改,而且一旦缓冲区被施以垃圾收集动作(garbage collected),那些修改都会丢失。

大致浏览一下 map() 方法的源码:

    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 {
                // If no exception was thrown from map0, the address is valid
                addr = map0(imode, mapPosition, mapSize);
            } catch (OutOfMemoryError x) {
                // An OutOfMemoryError may indicate that we've exhausted memory
                // so force gc and re-attempt map
                System.gc();
                try {
                    Thread.sleep(100);
                } catch (InterruptedException y) {
                    Thread.currentThread().interrupt();
                }
                try {
                    addr = map0(imode, mapPosition, mapSize);
                } catch (OutOfMemoryError y) {
                    // After a second OOME, fail
                    throw new IOException("Map failed", y);
                }
            }

            // On Windows, and potentially other platforms, we need an open
            // file descriptor for some mapping operations.
            FileDescriptor mfd;
            try {
                mfd = nd.duplicateForMapping(fd);
            } catch (IOException ioe) {
                unmap0(addr, mapSize);
                throw ioe;
            }

            assert (IOStatus.checkAll(addr));
            assert (addr % allocationGranularity == 0);
            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);
            }
     }

大致意思就是通过 Native 方法获取内存映射的地址,如果失败(内存不够用,OutOfMemoryError),手动 GC 再次映射

最后通过内存映射的地址实例化出 MappedByteBuffer,MappedByteBuffer 本身是一个抽象类,其实这里真正实例化出来的是 DirectByteBuffer。

注1:这块内存(指堆外内存,不是 MappedByteBuffer)不受 jvm 垃圾回收的影响,因此内存地址固定,有助于 IO 读写

java 中的 DirectByteBuf 对象仅维护了此内存的虚引用(PhantomReference),内存回收分成两步

  1. DirectByteBuf 对象被垃圾回收,将虚引用加入引用队列
  2. 通过专门线程访问引用队列(sun.misc.Cleaner),根据虚引用释放堆外内存

注2:因为 MappedByteBuffer 是通过虚拟内存技术实现的,所以你的修改什么时候刷新到磁盘是由操作系统决定的。不过这也有一个好处就是即使你的Java程序在写入内存后就挂掉了,只要操作系统工作正常,数据就会写入磁盘。

java.nio.MappedByteBuffer#force 可以强制操作系统将内存中的内容写入硬盘,不过最好少用。

MappedByteBuffer#get 过程

public byte get() {
    return ((unsafe.getByte(ix(nextGetIndex()))));
}
public byte get(int i) {
    return ((unsafe.getByte(ix(checkIndex(i)))));
}
private long ix(int i) {
    return address + (i 

map0()函数返回一个地址address,这样就无需调用read或write方法对文件进行读写,通过address就能够操作文件。底层采用unsafe.getByte方法,通过(address + 偏移量)获取指定内存的数据。

  1. 第一次访问address所指向的内存区域,导致缺页中断,中断响应函数会在交换区中查找相对应的页面,如果找不到(也就是该文件从来没有被读入内存的情况),则从硬盘上将文件指定页读取到物理内存中(非jvm堆内存)。
  2. 如果在拷贝数据时,发现物理内存不够用,则会通过虚拟内存机制(swap)将暂时不用的物理页面交换到硬盘的虚拟内存中。

总结

  1. MappedByteBuffer 使用虚拟内存,因此分配(map)的内存大小不受JVM的-Xmx参数限制,但是也是有大小限制的(通过源码可知最大为 Integer.MAX_VALUE,2G)。

    源码参见:sun.nio.ch.FileChannelImpl#map

    本质上是由于 java.nio.MappedByteBuffer 直接继承自 java.nio.ByteBuffer,而 ByteBuffer 的索引是 int 类型的,所以 MappedByteBuffer 也只能最大索引到 Integer.MAX_VALUE 的位置,所以 FileChannel 的 map 方法会做参数合法性检查。

  2. 一次 map 的大小最好限制在 1.5G 左右

  3. 当文件超出限制时,可以通过position参数重新map文件后面的内容。

  4. 使用 MappedByteBuffer 操作大文件比 IO 流要快(对于小文件,内存映射文件反而会导致碎片空间的浪费,因为内存映射总是要对齐页边界,最小单位是 4 KiB,一个 5 KiB 的文件将会映射占用 8 KiB 内存,也就会浪费 3 KiB 内存。)

  5. 加载文件的内存在 Java 的堆内存之外,允许两个不同进程访问文件。

  6. 不要经常调用MappedByteBuffer.force()方法,这个方法强制操作系统将内存中的内容写入硬盘,所以如果你在每次写内存映射文件后都调用force()方法,你就不能真正从内存映射文件中获益,而是跟disk IO差不多。

2.0 Sendfile 方式

Sendfile 系统调用在内核版本 2.1 中被引入,目的是简化通过网络在两个通道之间进行的数据传输过程。

Sendfile 系统调用的引入,不仅减少了数据复制,还减少了上下文切换的次数。

image

java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据;比上面的方式减少了2次 java 代码(用户态)到操作系统(内核态)的切换,连 ByteBuffer 对象都不创建了。

image
  1. java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 cpu
  2. 数据从内核缓冲区传输到 socket 缓冲区,cpu 会参与拷贝
  3. 最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu

代码实现:

File f = new File("helloword/data.txt");
RandomAccessFile file = new RandomAccessFile(file, "r");

Socket socket = ...;
file.getChannel().transferTo(0, f.length(), socket.getChannel());

3.0 Linux 2.4的进一步优化

我们看到上一种方式的内核空间中还有一次 copy 使用到了 CPU,那么能不能把这一次 copy 也节省掉呢?

Linux2.4 内核中做了改进,将 Kernel buffer 中对应的数据描述信息(内存地址,偏移量)记录到相应的 Socket 缓冲区当中,这样连内核空间中的一次 CPU Copy 也省掉了。

image
  1. java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 cpu
  2. 只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗
  3. 使用 DMA 将 内核缓冲区的数据写入网卡,不会使用 cpu

整个过程仅只发生了一次用户态与内核态的切换,数据拷贝了 2 次。

总结

所谓的【零拷贝】,并不是真正无拷贝,而是在不会拷贝重复数据到 jvm 内存中,零拷贝的优点有:

  • 更少的用户态与内核态的切换
  • 不利用 cpu 计算(只要涉及到内存之间的 copy 都要用 CPU),减少 cpu 缓存伪共享(因为零拷贝会使用 DMA 进行数据的 copy,根本没有放入内存,所以 cpu 无法参与计算)
  • 零拷贝适合小文件传输(文件较大会把内核缓冲区占满,https://www.cnblogs.com/-wenli/p/13380616.html

四、其他零拷贝

4.1 Netty

Netty 中的 Zero-copy 与上面我们所提到到 OS 层面上的 Zero-copy 不太一样, Netty的 Zero-coyp 完全是在用户态(Java 层面)的, 它的 Zero-copy 的更多的是偏向于 优化数据操作 这样的概念。

Netty 提供了零拷贝的 Buffer,在传输数据时,最终处理的数据会需要对单个传输的报文,进行组合和拆分,NIO 原生的 ByteBuffer 无法做到,Netty 通过提供的 Composite(组合)和 Slice(拆分)两种 Buffer 来实现零拷贝(减少数据组合时的 copy)。

image

TCP 层 HTTP 报文被分成了两个 ChannelBuffer,这两个 Buffer 对我们上层的逻辑(HTTP 处理)是没有意义的。

但是两个 ChannelBuffer 被组合起来,就成为了一个有意义的 HTTP 报文,这个报文对应的 ChannelBuffer,才是能称之为“Message”的东西,这里用到了一个词“Virtual Buffer”。

可以看一下 Netty 提供的 CompositeChannelBuffer 源码:

public class CompositeChannelBuffer extends AbstractChannelBuffer {

    private final ByteOrder order;
    private ChannelBuffer[] components;
    private int[] indices;
    private int lastAccessedComponentId;
    private final boolean gathering;

    public byte getByte(int index) {
        int componentId = componentId(index);
        return components[componentId].getByte(index - indices[componentId]);
    }
    ...省略...

Components 用来保存的就是所有接收到的 Buffer,Indices 记录每个 buffer 的起始位置,lastAccessedComponentId 记录上一次访问的 ComponentId。

CompositeChannelBuffer 并不会开辟新的内存并直接复制所有 ChannelBuffer 内容,而是直接保存了所有 ChannelBuffer 的引用,并在子 ChannelBuffer 里进行读写,实现了零拷贝。

其他

4.2 其他零拷贝

RocketMQ 的消息采用顺序写到 commitlog 文件,然后利用 consume queue 文件作为索引。

RocketMQ 采用零拷贝 mmap+write 的方式来回应 Consumer 的请求。

同样 Kafka 中存在大量的网络数据持久化到磁盘和磁盘文件通过网络发送的过程,Kafka使用了 Sendfile 零拷贝方式。

五、测试几种 copy file 的 api 速度

参赛选手 3.8M 394M 800M
FileInputStream 16s 870ms
BufferedInputStream 170 ~ 180ms 15s 989ms 32s 325ms
BufferedInputStream with byte[1024] 50 ~ 65ms 1s 243ms 3s 418ms
RandomAccessFile with byte[1024] 50 ~ 65ms 2s 663ms 5s 782ms
FileChannel#write() 80 ~ 90ms 3s 5s 494ms
FileChannel#transferTo() 30ms 593ms 2s 404ms
MappedByteBuffer 30~50ms 1s 286ms 4s 968ms

第一名:FileChannel#transferTo()

第二名:BufferedInputStream with byte[1024],没想到

第三名:MappedByteBuffer

第四名:RandomAccessFile with byte[1024]

第五名:FileChannel#write(),也没想到

参考文章

虚拟地址与虚拟内存

内存分页详解 -> 部分错误

Linux磁盘缓存机制

页面缓存、内存和文件之间的那些事 -> 接近底层,我看不懂

Linux内核Page Cache和Buffer Cache关系及演化历史

Linux内存管理 — 白话页框回收

JVM 与 Linux 的内存关系详解

Java内存映射,上G大文件轻松处理

为何要在Java中使用内存映射文件

MappedByteBuffer多大的文件我都装得下 -> 部分错误

Netty、Kafka中的零拷贝技术到底有多牛?

文章来源于互联网:深入理解零拷贝

0

评论0

鱼翔浅底,鹰击长空,驼走大漠
没有账号? 注册  忘记密码?