1、传统IO底层发生了什么?为什么OS层面是相当昂贵的操作?
- JVM发送read()系统调用
- OS进行上下文切换到内核态,从socket buffer中读取数据。
- OS内核拷贝数据到user buffer,然后进行上下文切换,回到用户态,read()方法返回。
- JVM进程继续执行代码逻辑,然后发送write()系统调用。
- OS进行上下文切换到内核态,将user buffer的数据拷贝到socket buffer。
- OS进行上下文切换到用户态,write方法返回,JVM继续执行代码逻辑。
可以看到,一个简单的传统IO操作,涉及了4次上下文切换和2次数据拷贝,所以说这个效率是相当低下的。如果在并发要求极高的中间件系统中,这样的效率是无法容忍的。
2、JDK是如何优化传统的IO操作,实现zero-copy效果的?
zero-copy的意思是不会将数据从内核态拷贝到用户态,而非不进行数据拷贝。
之所以内核态还会发生数据拷贝,是因为DMA(Direct Memory Access)希望访问的是一段连续的内存空间。
3、MMAP
zero-copy,因为没有发生用户态的调用,所以我们的代码不能做除了管道流(stream piping)之外的事情。但是有一种比zero-copy更友好,也更昂贵的方式,短暂的内存映射「mmap」。
mmap允许直接将文件映射到内存(映射的是文件地址,而非文件本身),在用户态就可以直接访问,避免了不必要的数据拷贝,但是这种方式仍然无法避免上下文切换。
另外OS直接将文件映射到了内存,所以mmap的方式可以获得OS虚拟内存管理的所有优势(依赖底层操作系统)。
- 热点数据智能缓存,还会预读取相邻的data page。
- 数据都存在于一段连续的内存空间中,无需在各种buffer间来回拷贝。
JavaNIO包下的MappedByteBuffer
类就可以实现「mmap」,他是DirectByteBuffer
的变种。
4、ByteBuffer
Java NIO引入了ByteBuffer作为channel的缓冲区,ByteBuffer有三种主要实现:
- HeadByteBuffer
- DirectByteBuffer
- MappedByteBuffer
HeadByteBuffer
使用ByteBuffer.allocate()
创建,该ByteBuffer存在于堆空间,因此获得了GC的支持(可以被垃圾回收掉)以及进行了缓存的优化。但是他不是一段连续的内存空间,也就意味着如果你通过JNI的方式访问native代码,JVM会先拷贝到对其的buffer空间中。
DirectByteBuffer
使用ByteBuffer.allocateDirect()
创建,JVM将会使用malloc()函数,分配堆空间之外的内存空间。好处是分配的内存空间是连续的,坏处是没有被JVM管理,这意味着你需要小心内存泄漏。
MappedByteBuffer
使用 FileChannel.map()
映射,分配堆空间之外的内存空间。本质上就是围绕mmap()
的系统调用,让我们的java代码可以直接操纵映射的内存数据。
5、总结
虽然sendfile()
和mmap()
提供了高效和低延迟。但需要注意的是软件技术没有银弹,带来高效的同时必将带来些许问题。在复杂的实际情况中,需认真考虑技术的引入是否值得(tradeoff)。脱离了JVM的管理和保护,代码复杂度的提升,更容易让软件崩溃(我的意思是崩溃,而非异常)。