有关Zero-Copy、MMap以及DirectByteBuffer的一切
有一个很常见的场景,比如需要将文件从磁盘上原封不动地发送到网络的另一端。这通过代码实现起来很简单:对于Java而言,你可以使用InputStream
的某个实现类将文件一块块地读取到小的缓冲区(通常我们都将缓冲区大小设置为8KB),然后再将缓冲区数据输出到OutputStream
中。更好的做法是你可以创建一个PipedInputStream
实例,让它来管理缓冲区。但是,如果你的应用对性能有要求,那么通过这种方式去读取文件再发送在操作系统层面来看就显得有些太耗资源了。
为什么这样说呢?结合下图我来解释下原因
- JVM执行
read()
系统调用; - 操作系统从用户态切换到内核态,然后把数据读到内核缓冲区;
- 内核将数据拷贝到应用缓冲区,并切换回用户态,
read()
调用返回; - JVM处理代码逻辑,然后执行
write()
系统调用; - 操作系统切换到内核态,将数据从应用缓冲区拷贝到socket内核缓冲区;
- 操作系统返回到用户态,JVM继续执行后面的业务逻辑。
如果你的应用不关心延时和吞吐量等性能指标,那么以上做法是没问题的,但是如果你的应用有这方面要求,比如静态资源服务器,那么这样做将会无法满足性能要求。上图中有4次上下文切换以及2次不必要的内存拷贝。
系统级别的Zero-Copy(零拷贝)
从上面的方式中可以很清楚的看到,将数据从内核缓冲区拷贝到应用缓冲区,以及从应用缓冲区拷贝到socket内核缓冲区是完全没必要的,因为我们没有对数据作任何处理,仅仅只是将数据从一个socket倒腾到另一个socket。零拷贝技术就能消除这两次额外的内存拷贝。零拷贝技术的实现方式没有一个统一的标准,它取决于不同的操作系统。典型地,那些UNIX LIKE系统用sendfile()
来实现零拷贝功能。
使用零拷贝方式实现上面场景的图示如下
你可能会说,操作系统还是要在内核内存空间做一次拷贝呀!是的。但是从操作系统的角度来说,它已经是零拷贝了,因为已经没有数据从内核空间拷贝到用户空间了。内核需要做一次拷贝的原因是一般的硬件DMA方式只能存取连续的内存空间(所以才有了缓冲区)。但是如果硬件支持scatter-n-gather特性,这次的拷贝就可以避免。
支持scatter-n-gather特性时的图示如下
很多WEB服务器都支持零拷贝,比如Tomcat和Apache。默认情况下Apache的这个特性是关闭的。
注意: Java的NIO通过transferTo
方法提供零拷贝。
MMap
上面的零拷贝方案有个问题,因为没有涉及到用户态,所以除了打通流管道,我们无法通过代码来修改流管道里的数据。不过现在有个比零拷贝昂贵但优于传统I/O的方案——内存映射,简称MMap。
MMap允许代码将文件映射到内核内存,应用可以直接访问这个内核内存,就像访问用户态的内存空间一样,这样就不会产生内核空间到用户空间的内存拷贝。不过这种方式仍然需要4次上下文切换以及3次数据拷贝(其中有一次是CPU参与的内核内存拷贝)。操作系统将文件的某块数据映射到内存,受益于操作系统的虚拟内存管理,热点数据能被提前载入到内存,所有的数据是页对齐的,因此不需要缓冲区拷贝就能将数据倒腾到目标socket。
虽然,MMap避免了额外的内存拷贝,但是使用了MMap不一定会比普通的方式快,这取决于不同的操作系统。因为这涉及到MMap的创建和销毁所需要的性能开销以及页缺失时的负面影响。
Java中实现MMap方式的类是MappedByteBuffer
,它其实也是一种DirectByteBuffer
(DirectByteBuffer
是MappedByteBuffer
的子类),不过这两个类并没有直接的关系。我们通常所说的直接内存并没有MMap的特性。
DirectByteBuffer
Java NIO中有三种ByteBuffer
-
HeapByteBuffer
:ByteBuffer.allocate()
使用的就是这种缓冲区,叫堆缓冲区,因为它是在JVM堆内存的,支持GC和缓存优化。但是它不是页对齐的,也就是说如果要使用JNI的方式调用native代码时,JVM会先将它拷贝到页对齐的缓冲空间。 -
DirectByteBuffer
:ByteBuffer.allocateDirect()
方法被调用时,JVM使用C语言的malloc()
方法分配堆外内存。由于不受JVM管理,这个内存空间是页对齐的且不支持GC,和native代码交互频繁时使用这种缓冲区能提高性能。不过内存分配和销毁的事就要靠你自己了。 -
MappedByteBuffer
:FileChannel.map()
调用返回的就是这种缓冲区,这种缓冲区用的也是堆外内存,本质上其实就是对系统调用mmap()
的封装,以便通过代码直接操纵映射物理内存数据。