1. 直接内存(堆外内存) DirectByteBuffer
points:
- 分配在jvm heap外面,但是也是在用户空间
- 不由GC控制,通过Cleaner来辅助进行堆外直接内存回收,参考引用篇章
- 通过Unsafe类的allocateMemory和freeMemory方法进行堆外内存申请和释放
2. 堆内内存 HeapByteBuffer
points:
- 创建就直接就是在JVM堆内新建一个byte[]数组
- 受GC控制,JVM会负责这部分区域内存的回收
- 读写时候相比直接内存性能差,因为会多一次从JVM堆内到堆外的拷贝
3. 为什么堆内内存执行网络IO或者文件IO时候必须通过堆外内存多一次交互?
如果是非DirectBuffer,JDK会先创建一个DirectBuffer,再去执行真正的写操作(write等底层调用),多做了一次拷贝。
原因:
-
在底层write、read、pwrite、pread系统函数调用时候,需要传入buffer的起始地址和buffer count作为参数。
HeapBuffer在jvm heap中,由于jvm中以byte[]存在,这是一个数组对象,由于GC的存在,该对象在堆内存中的位置可能会发生变化,那么我们传给底层函数调用的参数地址就可能失效了,从而会发生错误。
DirectBuffer是通过c来创建的堆外内存,他相对来说地址稳定,因此可以安全的把该地址传给write等底层函数,而不会发生错误。
-
在底层write、read、pwrite、pread系统函数调用时候,必须要求是连续的地址空间
JVM规范中没有要求byte[]必须是连续的内存空间,
C Heap中分配的地址空间则可以是连续的。
参考:https://linux.die.net/man/2/write 、https://www.zhihu.com/question/60892134/answer/182225677
4. 零拷贝技术
在堆外内存的基础上再集成零拷贝技术,那么性能就更加得到了提升。
kafka里面推荐文章:https://developer.ibm.com/articles/j-zerocopy/
简单翻译下
Efficient data transfer through zero copy(零拷贝的高效数据传输机制)
很多Web应用提供了大量静态资源文件,应用从磁盘中读取(read()函数)静态资源数据然后再写到响应socket中(wirte())。以上这个行为只占用很小的CPU资源,但是它的效率却是很低的,原因是:内核从磁盘读取数据然后越过内核-用户边界推送到用户空间,然后web应用又重新把这个数据从用户空间推送到内核空间的socket中。因此在整个数据传输环节中,web应用变成了一个低效的中间人。
每次数据传输经过内核-用户空间边界时候,都需要进行数据复制,这会消耗CPU周期以及内存带宽资源。幸运的是,你可以通过zero copy
技术来消除这个数据复制的行为。使用了零拷贝技术的应用可以请求内核直接从disk拷贝数据到socket中,不需要跟之前一样经过用户空间。这个技术极大的提升了应用性能,同时减少系统调用(read()/wirte()函数),降低了用户内核态转换时的上下文切换次数。
java库的transferTo()方法在linux和UNIX系统上支持了zore copy。文章首先演示了传统方式读写文件的性能支出,然后介绍使用transferTo()方式使用零拷贝的好处。
Data Transfer:The traditional approach
考虑这样一个场景,从文件里面读取数据然后通过网络传输到另外一个程序中。这个场景涉及的核心java代码如下
File.read(fileDesc, buf, len); //读取文件
Socket.send(socket, buf, len); //写数据到socket
尽管代码看起来很简单,但是实际完成整个数据传输过程需要在用户和内核空间里进行4次上下文切换,以及经过4次数据复制,如下图。
img上图详细流程如下:
-
read()
调用产生了一次context switch(从用户态到内核态,图2)。内部实际是触发了一次sys_read()
或者等价的系统调用来从文件读取数据。然后第一次数据copy发生了(图1),这次数据拷贝由DMA引擎进行处理(direct memory access),DMA从磁盘的文件读取内容并且将内容存储到kernel address space buffer。 - 请求的数据从内核空间的read buffer拷贝到用户空间的英应用的buffer中,然后read()
引起的系统调用
sys_read()`返回。系统调用的返回还会引起另外一次context switch(内核态回到用户态)。至此数据已经被存储到了用户地址空间的buffer中了。 -
send()
到socket的调用引起了一次用户态到内核态的上下文切换。第三次数据拷贝发生了,它把数据再次拷贝到内核地址的socket buffer中。不过这一次跟从disk读取时候放的buffer不一样,他是拷贝到了跟目标socket相关联的一个socket buffer中。 - java的
send()
方法引发的系统调用返回了,因此产生了第4次上下文切换。同时异步且独立的,第4次数据copy发生了,由DMA引擎把第三步中的socket buffer数据拷贝到NIC buffer(网卡缓存)中。
kernel buffer在这其中的使用(而不是DMA直接把数据从disk拷贝到用户buffer)看起来可能很没有效率。但kernel buffer在流程里面引入是用于提高性能的。内核buffer在读数据的时候可以用来进行预读(read ahead cache)来提高性能,这在应用当前只请求了部分数据然后后序会请求更多的接下来连续的数据时候提高性能(翻译有问题)。内核buffer在处理写请求的时候可以在接收应用的写数据后立即返回成功,然后DMA异步完成真正的写文件或者socket的操作(write-behind),以此提升写性能。
不幸的是,上面这种kernel buffer的优化方式它自己也可能成为性能瓶颈,如果传输的数据size大于kernel的buffer size。这种情况下数据需要在disk,kernel buffer 和user buffer中拷贝多次。
Data Transfer:The zero-copy approach
查看上面传统的数据传输方式,可以发现第2,3次数据拷贝是不需要的。应用对这数据没有做任何操作只是把它传回socket buffer。因此数据是不是可以直接用read buffer传送到socket buffer。jdk的transferTo()
方法正是这样做到了。
public void transferTo(long position, long count, WritableByteChannel target);
transferTo()
方法传输数据从file channel到指定的writebale byte channel。内部具体取决于底层的操作系统对zero copy支持。在UNIX和Linux的多种系统中,transferTo()
依赖sendfile()
的系统调用,sendfile()
函数才是实际上完成了数据传输的任务。
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
aaa.png
因此最开始传统方式的read() wirte()
两个java方法可以使用一个transferTo()
方法来替代掉。
-
transferTo()
方法让DMA引擎把文件内容拷贝到read buffer中。然后数据由内核拷贝到跟目标socket关联的socket buffer(CPU参与)。 - 第三次拷贝发生在DMA引擎从内核的socket buffer拷贝到NIC buffer(网卡缓存)
transferTo()
的方式产生了以下收益:上下文切换次数从4次变成2次。数据copy的次数从4次变成3次(只有一次涉及CPU)。但这还没达到我们的零拷贝的目的。我们可以进一步减去内核的这一步数据复制,如果底层的网卡支持gather operations
聚合数据的功能。(上面有提到kernel buffer的这个作用 read-ahead,write-behind)在Linux kernel2.4及之后,socket buffer describer被修改了来适应这种需求,这种处理不仅减少了context switch,还减少了一次CPU负责的数据copy。在改进后用户侧的使用还是一样调用tansferTo()
方法,但是底层的流程改变了。
-
tansferTo()
方法让DMA引擎把文件拷贝到kernel read buffer。 - 跟上面内核改进前不一样,这次不需要把数据拷贝到socket buffer,这次只需要把带有数据地址以及长度的描述符传给socket buffer就行了,不需要进行完整的数据拷贝。然后DMA引擎再直接把数据从read buffer拷贝到NIC buffer完成了最终的处理。
总结
从zore copy到传统传输方式有了如下优化
-
上下文切换从4次编程2次
-
数据拷贝从4次变成2次
-
整体性能提升了大致65%
网友评论