物理内存
计算机物理内存条的容量,比如我们买电脑会关注内存大小有多少G,这个容量就是计算机的物理内存。
虚拟内存
操作系统为每个进程分配了独立的虚拟地址空间,也就是虚拟内存,虚拟地址空间又分为用户空间和内核空间,操作系统的位数不同,虚拟地址空间的大小也不同,32位操作系统虚拟地址内核空间为1G,用户空间大小为3G,64位操作系统用户空间和内核空间大小各为128T:
既然每个进程都拥有一块独立的虚拟地址空间,那么所有进程的虚拟地址空间大小加起来必定大于物理内存的大小,所以虚拟地址空间只是一个虚拟的概念,只有需要分配内存的时候才会为虚拟内存分配物理内存,并通过内存映射来管理虚拟地址和物理内存地址之间的映射关系。
用户空间 / 内核空间
用户空间:是运行用户程序代码的地方,为了保证系统内核的安全,它不能直接访问内存等硬件设备,必须通过系统调用进入到内核空间来访问那些受限的资源。
内核空间:是运行内核代码的地方,可以执行任意的指令访问系统资源,既可以访问内核空间也可以访问用户空间。
用户态:进程运行在用户空间时处于用户态。
内核态:程运行在内核空间时处于内核态。
文件I/O
比如我们启动了一个java程序,此时运行在用户空间(用户态),接着准备做一个读取磁盘文件的操作,由于用户空间是无法直接从磁盘读取文件的,所以需要调用内核提供的接口来完成文件的读取,调用内核的接口的过程中由用户空间进入到了内核空间(内核态),通过DMA从磁盘读取文件到内核的缓冲区,之后再将数据从内核的缓冲区拷贝到用户空间完成文件的读取操作。
- 应用程序发起系统调用
- 从用户空间切换到内核空间,内核通过DMA从磁盘拷贝数据到内核缓冲区
- 将内核缓冲区的数据拷贝到用户空间的缓冲区
可以发现,整个读取过程发生了两次数据拷贝,一次是DMA将磁盘上的文件数据拷贝到内核缓冲区,一次是将内核缓冲区的数据拷贝到用户缓冲区。写操作与读取操作类似,只不过是将用户缓冲区的数据拷贝到内核缓冲区,再将内核缓冲区的数据拷贝到文件。
网络I/O
网络I/O与文件I/O的底层原理一致,只不过文件I/O是从磁盘读取文件,网络I/O是从网卡中读取数据,客户端与服务端建立连接,当有数据到达时,从网卡中读取数据到内核缓冲区,再将内核缓冲区的数据复制到用户空间的缓冲区。
缓存I/O
也称标准I/O,上面提到的文件I/O和网络I/O读取数据的方式都是使用的缓存I/O,需要将数据先拷贝到内核缓冲区,再将内核缓冲区的数据拷贝到用户缓冲区,数据经过两次拷贝,内核缓冲区和用户缓冲区分别指向不同的物理内存,其中内核缓存区是在Page Cache层:
标准I/O读取文件:
1 | public class InputStreamTest { |
为什么需要缓存IO?
因为磁盘I/O是比较耗时的操作,如果每次都从磁盘上读取文件,性能将会大大下降,为了提升读取性能,增加了一层Page Cache,用于缓存读取的文件数据,Page Cache占用的是内存,从内存读取的速度远远大于从磁盘读取,内核缓冲区就是在Page Cache中开辟的一块内存,用户空间进行系统调用读取文件内容时,首先会判断Page Cache中是否缓存了文件的内容,如果缓存了直接读取即可,否则再从磁盘读取,所以缓存I/O可以减少磁盘I/O的次数提升性能。
文件的写操作同样如此,进行写操作时,将数据先写到Page Cache的缓冲区中,后续由操作系统将数据刷回到磁盘中。
缓存I/O的优缺点
优点:减少磁盘I/O次数,提升读写性能。
缺点:数据需要在内核空间和用户空间来回拷贝。
直接I/O
缓存I/O经过了Page Cache,读取过程中需要将数据从Page Cache的缓冲区中拷贝到用户空间的缓存区,那么有没有一种方式可以省去这个拷贝的过程?
答案是有的,那就是直接I/O,应用程序直接访问磁盘数据,绕过了Page Cache,省去了从内核缓冲区拷贝到用户缓冲区的过程。
目前JAVA并没有原生的直接/O操作方式,不过公众号博主Kirito提供了在JAVA中进行直接I/O操作的方法,具体参见【Kirito的技术分享】Java 文件 IO 操作之 DirectIO。
内存映射
内存映射就是将虚拟空间地址映射到物理空间地址,每个进程维护了一张页表,记录虚拟地址和物理地址之间的映射关系,当进程访问的虚拟地址在页表中无法查到映射关系时,系统产生缺页异常,进入内核空间为虚拟地址分配物理内存,并更新页表,记录映射关系。
文件映射
内存映射除了映射虚拟空间地址和物理空间地址,还包括将磁盘的文件内容映射到虚拟地址空间,称为文件映射,此时可以通过访问内存来访问文件里面的数据 。
mmap系统调用可以将文件映射到虚拟内存空间。文件映射的流程如下:
- 进行mmap系统调用,将文件和虚拟地址空间建立映射,注意此时还没有分配物理内存空间,只是在逻辑上建立了虚拟地址和文件之间的映射关系,物理内存只有真正使用的时候才会分配。
- 应用程序访问用户空间虚拟内存中的某个地址,发现无法在页表中查到数据,产生缺页异常,此时进入内核空间
- 因为不能直接使用物理地址,所以需要使用内核的虚拟地址临时建立与物理内存的映射关系,将文件内容读取到物理内存中,待数据读取完毕之后取消临时映射即可。
- 缺页异常处理完毕,物理内存中已经加载了文件的数据,此时用户空间就可以通过虚拟地址直接访问物理内存中映射的文件数据。
从文件映射的流程中可以看出它与缓存I/O相比,少了从内核缓冲区将数据拷贝到用户缓冲区的步骤,减少了一次拷贝。
Java NIO中提供了MappedByteBuffer来处理文件映射,下面是一个读取文件的例子:
1 | public class MappedByteBufferTest { |
零拷贝
首先看一下使用传统缓存I/O从磁盘文件读取数据并发送到网络上的过程:
用户发起系统调用,DMA从磁盘上读取数据到内核缓冲区
CPU将内核缓冲区的数据拷贝到用户缓冲区
- CPU将用户缓冲区的数据拷贝到socket缓冲区
- DMA将socket缓冲区的数据拷贝到网卡
使用缓存I/O数据经过了四次拷贝,需要多次在内核空间和用户空间来回切换,影响系统性能。从数据拷贝的过程可以看到有些步骤其实是多余的,比如第二步,如果可以直接将内核缓存区的数据拷贝到socket缓冲区,或者直接将内核缓冲区的数据拷贝到网卡,岂不是减少了数据拷贝的次数?零拷贝就是这样一种致力于减少数据拷贝的技术。
Linux中的sendfile()函数可以实现将数据从一个文件描述符传输到另外一个文件描述符。Java NIO中的FileChannel也可以实现将数据从FileChannel直接传输到另一个Channel。
FileChannel
Java NIO中的FileChannel的transferTo方法将数据从FileChannel传输到另一个Channel:
1 | RandomAccessFile file = new RandomAccessFile(new File("/Users/shanmenglu/test.txt"), "r"); |
使用FileChanel后的数据拷贝:
Linux2.4版本之后通过对Socket Buffer添加一些Descriptor信息可以进一步减少数据的拷贝:
可以看到零拷贝并不是指的数据一次拷贝都没有发生,而是指没有通过CPU进行数据拷贝。Java中的堆外内存DirectByteBuffer和上面提到的mmap内存映射也是零拷贝中的一种,Netty中也提供了零拷贝相关的技术。
堆外内存
传统的缓存I/O,内核缓冲区和用户缓冲区分别占用不同的物理内存,其中用户缓冲区占用的是JVM堆内的内存:
DirectByteBuffer
Java NIO中提供了DirectByteBuffer,可以使用堆外内存,通过ByteBuffer的allocateDirect方法可以分配堆外内存,并返回内存的地址,之后就可以直接操作这块内存,不用将数据在内核缓冲区与用户缓冲区之间拷贝:
1 | public static ByteBuffer allocateDirect(int capacity) { |
堆外内存和直接I/O的区别
直接I/O偏重的是绕过操作系统的Page Cache层,堆外内存偏重的是使用的JVM内存之外的内存,他们的侧重点不同,不过都可以减少数据的拷贝。
关于堆外内存是否在Page Cache中,这个待研究。
参考
【零壹技术栈】深入剖析Linux IO原理和几种零拷贝机制的实现