java 内存拷贝函数_java IO专题二: NIO读取文件并通过socket发送,最少拷贝了几次?堆外内存和所谓的零拷贝到底是什么关系...

本文属于 java InputStream和OutputStream读取文件并通过socket发送,到底涉及几次拷贝 的后话,从BIO过度到NIO,可以更好的理解堆外内存的作用和所谓的零拷贝,首先还是解释一下零拷贝的概念。

内核的零拷贝

内核的零拷贝,指的是不需要消耗CPU资源,完全交给DMA来处理,内核空间的数据没有多余的拷贝。主要经历了这么几个发展历程:

一、传统的read + send

1、先调用操作系统的read函数,由DMA将文件拷贝到内核,然后CPU把内核数据拷贝到用户缓冲区(堆外内存)

2、调用操作系统的send函数,由CPU把用户缓冲区的数据拷贝到socket缓冲区,最后DMA把socket缓冲区数据拷贝到网卡进行发送。

这个过程中内核数据拷贝到用户空间,用户空间又拷贝回内存,有两次多余的拷贝。

二、sendfile初始版本

直接调用sendfile来发送文件,流程如下:

1、首先通过 DMA将数据从磁盘读取到内核

2、然后通过 CPU将数据从内核拷贝到socket缓冲区

3、最终通过 DMA将socket缓冲区数据拷贝到网卡发送

sendfile 与 read + send 方式相比,少了一次 CPU的拷贝。但是从上述过程中也可以发现从内核缓冲区拷贝到socket缓冲区是没必要的。

三、sendfile改进版本,真正的零拷贝

内核为2.4或者以上版本的linux系统上,改进后的处理过程如下:

1、DMA 将磁盘数据拷贝到内核缓冲区,向socket缓冲区中追加当前要发送的数据在内核缓冲区中的位置和偏移量

2、DMA gather copy 根据 socket缓冲区中的位置和偏移量,直接将内核缓冲区中的数据拷贝到网卡上。

经过上述过程,数据只经过了 2 次 copy 就从磁盘传送出去了。并且没有CPU的参与。

java的零拷贝

一、利用directBuffer

在上一篇文章 java InputStream和OutputStream读取文件并通过socket发送,到底涉及几次拷贝 中,我们提到了基于BIO读取文件发送消息,一共涉及六次拷贝,其中堆外和堆内内存的拷贝是多余的,我们可以利用directBuffer来减少这两次拷贝://打开文件通道FileChannel fileChannel = FileChannel.open(Paths.get("/test.txt"));//申请堆外内存ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);//读取到堆外内存fileChannel.read(byteBuffer);//打开socket通道SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("localhost", 9099));//堆外内存写入socket通道socketChannel.write(byteBuffer);复制代码

每一行代码都有清楚的注释,我们主要来看一下fileChannel.read、socketChannel.write做了什么:fileChannel.read 分析//FileChannelImplpublic int read(ByteBuffer dst) throws IOException {

... 忽略了一堆不重要代码synchronized (positionLock) {int n = 0;int ti = -1;try {do {                   // 调用IOUtil,根据文件描述符fd读取数据到直接缓冲区dst中n = IOUtil.read(fd, dst, -1, nd);

} while ((n == IOStatus.INTERRUPTED) && isOpen());return IOStatus.normalize(n);

} finally {

threads.remove(ti);

end(n > 0);assert IOStatus.check(n);

}

}

}//IOUtilstatic int read(FileDescriptor fd, ByteBuffer dst, long position,

NativeDispatcher nd)throws IOException{

ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining());try {int n = readIntoNativeBuffer(fd, bb, position, nd);

bb.flip();if (n > 0)

dst.put(bb);return n;

} finally {

Util.offerFirstTemporaryDirectBuffer(bb);

}

}private static int readIntoNativeBuffer(FileDescriptor fd, ByteBuffer bb,long position, NativeDispatcher nd)throws IOException{int pos = bb.position();int lim = bb.limit();assert (pos <= lim);int rem = (pos <= lim ? lim - pos : 0);if (rem == 0)return 0;int n = 0;if (position != -1) {

n = nd.pread(fd, ((DirectBuffer)bb).address() + pos,

rem, position);

} else {           //第一次读取会走到这里,否则走上面的分支n = nd.read(fd, ((DirectBuffer)bb).address() + pos, rem);

}if (n > 0)

bb.position(pos + n);return n;

}//FileDispatcherImpl

int read(FileDescriptor fd, long address, int len) throws IOException {return read0(fd, address, len);

}复制代码

这里的调用链比较深,我们一步一步梳理:调用fileChannel.read实际是走到了FileChannelImpl.read方法,然后走到n = IOUtil.read(fd, dst, -1, nd);调用IOUtil的read,传入了文件描述符、directBuffer

IOUtil 调用自己的readIntoNativeBuffer 方法,字面意思是讲数据读取到native缓存,即堆外内存

IOUtil 的 readIntoNativeBuffer 方法调用n = nd.read(fd, ((DirectBuffer)bb).address() + pos, rem);,即NativeDispatcher 的read方法,传入文件描述符,堆外内存地址以及要读取的长度

这里的 NativeDispatcher 实现类为 FileDispatcherImpl,实际调用的是native方法read0,并传入了文件描述符、堆外内存地址和读取长度

我们简单看一下native的read0方法做了什么:// 以下内容来自于 jdk/src/solairs/native/sun/nio/ch/FileDispatcherImpl.cJNIEXPORT jint JNICALLJava_sun_nio_ch_FileDispatcherImpl_read0(JNIEnv *env, jclass clazz,

jobject fdo, jlong address, jint len){//拿到文件描述符jint fd = fdval(env, fdo);//根据地址拿到堆外内存的指针void *buf = (void *)jlong_to_ptr(address);  //直接调用系统函数read把文件描述符读取到buf中return convertReturnVal(env, read(fd, buf, len), JNI_TRUE);

}复制代码

可以看到native的read0方法是直接调用系统函数read,根据jvm传过来的堆外内存地址,将文件数据读取到堆外内存中(read方法的作用在内核零拷贝小节里已经提到了)。即直接操作堆外内存,而不使用DirectByteBuffer的时候,还需要将堆外内存拷贝到堆内进行读写(具体见 java InputStream和OutputStream读取文件并通过socket发送,到底涉及几次拷贝 ),因此使用堆外内存+channel的方式,可以避免堆内外内存拷贝,一定程度上也能提高效率。socketChannel.write 分析//SocketChannelImpl.java

public int write(ByteBuffer buf) throws IOException {synchronized (writeLock) {

... 忽略不重要代码int n = 0;try {for (;;) {//调用IOUtil.write写数据n = IOUtil.write(fd, buf, -1, nd);if ((n == IOStatus.INTERRUPTED) && isOpen())continue;return IOStatus.normalize(n);

}

} finally {

writerCleanup();

}

}

}//IOUtil.javastatic int write(FileDescriptor fd, ByteBuffer src, long position,

NativeDispatcher nd)throws IOException{if (src instanceof DirectBuffer)             //directBuffer直接走这里return writeFromNativeBuffer(fd, src, position, nd);

}  private static int writeFromNativeBuffer(FileDescriptor fd, ByteBuffer bb,                                             long position, NativeDispatcher nd) throws IOException{int pos = bb.position();int lim = bb.limit();assert (pos <= lim);int rem = (pos <= lim ? lim - pos : 0);int written = 0;if (rem == 0)return 0;if (position != -1) {

written = nd.pwrite(fd,

((DirectBuffer)bb).address() + pos,

rem, position);

} else {//调用SocketDispatcher写数据written = nd.write(fd, ((DirectBuffer)bb).address() + pos, rem);

}if (written > 0)

bb.position(pos + written);return written;

}//SocketDispatcher.javaint write(FileDescriptor fd, long address, int len) throws IOException {//直接调用了FileDispatcherImpl的native方法write0return FileDispatcherImpl.write0(fd, address, len);

}复制代码

在看native方法之前还是先做简单的梳理:socketChannel.write 实际调用了SocketChannelImpl.write,然后调用 IOUtil.write(fd, buf, -1, nd); 传入文件描述符和堆外内存引用

IOUtil.write调用自己的私有方法 writeFromNativeBuffer ,内部调用了written = nd.write(fd, ((DirectBuffer)bb).address() + pos, rem);,将文件描述符、堆外内存地址交给了NativeDispatcher

此处的NativeDispatcher实际是 SocketDispatcher,里面直接调用了FileDispatcherImpl.write0(fd, address, len);native方法

接着跟踪FileDispatcherImpl.write0(fd, address, len);这个native方法:// 以下内容来自于 jdk/src/solairs/native/sun/nio/ch/FileDispatcherImpl.cJNIEXPORT jint JNICALLJava_sun_nio_ch_FileDispatcherImpl_write0(JNIEnv *env, jclass clazz,

jobject fdo, jlong address, jint len){//转换文件描述符jint fd = fdval(env, fdo);//转换为堆外内存指针void *buf = (void *)jlong_to_ptr(address);//直接调用系统函数write将堆外内存数据发送出去return convertReturnVal(env, write(fd, buf, len), JNI_FALSE);

}复制代码

可以看到native的write0方法是直接调用系统函数write将堆外内存数据发送出去(write方法的作用在内核零拷贝小节里已经提到了)。小结

fileChannel和socketChannel配合directBuffer,本质上区别不大,都是配合系统函数write和read对文件描述符,直接操作堆外内存。因此相比较于BIO可以省去两次拷贝。

二、channel.transferTo

java中的零拷贝就是依赖操作系统的sendfile函数来实现的,提供了channel.transferTo方法,允许将一个channel的数据直接发送到另一个channel,接下来我们通过示例代码和具体的源码来分析和验证前面的说法。

示例代码如下://打开socketChannelSocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("localhost", 9099));//FileChannel fileChannel = FileChannel.open(Paths.get("/test.txt"));

fileChannel.transferTo(0, fileChannel.size(), socketChannel);复制代码

只用了一行代码fileChannel.transferTo(0, fileChannel.size(), socketChannel);就把文件数据写到了socket,继续看源码://FileChannelImpl.javapublic long transferTo(long position, long count,

WritableByteChannel target)throws IOException{

... 忽略不重要代码long sz = size();if (position > sz)return 0;int icount = (int)Math.min(count, Integer.MAX_VALUE);if ((sz - position) 

icount = (int)(sz - position);long n;          //先尝试直接tranfer,如果内核支持的话if ((n = transferToDirectly(position, icount, target)) >= 0)return n;//尝试mappedTransfer,只适用于受信任的channel类型if ((n = transferToTrustedChannel(position, icount, target)) >= 0)return n;          //channel不受信任的话,会走最慢的方式return transferToArbitraryChannel(position, icount, target);

}// FileChannelimpl.javaprivate long transferToDirectly(long position, int icount,

WritableByteChannel target)throws IOException{if (!transferSupported)//系统不支持就直接返回return IOStatus.UNSUPPORTED;

FileDescriptor targetFD = null;if (target instanceof FileChannelImpl) { //如果目标是fileChannel则走这里if (!fileSupported)return IOStatus.UNSUPPORTED_CASE;

targetFD = ((FileChannelImpl)target).fd;

} else if (target instanceof SelChImpl) { //SocketChannel实现了SelChImpl接口,因此会走这里if ((target instanceof SinkChannelImpl) && !pipeSupported)return IOStatus.UNSUPPORTED_CASE;//给targetFD赋值targetFD = ((SelChImpl)target).getFD();

}if (targetFD == null)return IOStatus.UNSUPPORTED;//将fileChannel和socketChannel对应的fd转换为具体的值int thisFDVal = IOUtil.fdVal(fd);int targetFDVal = IOUtil.fdVal(targetFD);//不支持自己给自己传输if (thisFDVal == targetFDVal) return IOStatus.UNSUPPORTED;long n = -1;int ti = -1;try {

begin();

ti = threads.add();if (!isOpen())return -1;do { //调用native方法transferTo0n = transferTo0(thisFDVal, position, icount, targetFDVal);

} while ((n == IOStatus.INTERRUPTED) && isOpen());if (n == IOStatus.UNSUPPORTED_CASE) {if (target instanceof SinkChannelImpl)

pipeSupported = false;if (target instanceof FileChannelImpl)

fileSupported = false;return IOStatus.UNSUPPORTED_CASE;

}if (n == IOStatus.UNSUPPORTED) {// Don't bother trying againtransferSupported = false;return IOStatus.UNSUPPORTED;

}return IOStatus.normalize(n);

} finally {

threads.remove(ti);

end (n > -1);

}

}复制代码

代码有点长:调用FileChannelImpl的transferTo,会尝试三种情况,如果系统支持零拷贝,则走 transferToDirectly

transferToDirectly 方法前面做了各种判断,其实可以理解为直接调用了n = transferTo0(thisFDVal, position, icount, targetFDVal);native方法

再来跟踪transferTo0:// 以下内容来自于 jdk/src/solairs/native/sun/nio/ch/FileChannelImpl.cJNIEXPORT jlong JNICALLJava_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,

jint srcFD,

jlong position, jlong count,

jint dstFD){#if defined(__linux__)off64_t offset = (off64_t)position;//直接调用sendfilejlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count);if (n = 0))return IOS_UNSUPPORTED_CASE;if (errno == EINTR) {return IOS_INTERRUPTED;

}

JNU_ThrowIOExceptionWithLastError(env, "Transfer failed");return IOS_THROWN;

}return n;

}复制代码

这个方法里其实有linux、solaris、APPLE等多个平台的实现,这里只截取linux下的实现,可以看到是直接调用了系统函数sendfile来实现的数据发送,具体的拷贝次数则要看linux内核的版本了。

总结NIO读取文件并通过socket发送,最少拷贝几次?

直接调用channel.transferTo,同时linux内核版本大于等于2.4,则可以将拷贝次数降低到2次,并且CPU不参与拷贝。堆外内存和所谓的零拷贝到底是什么关系

笔者理解网上说的零拷贝,可以认为有内核层面的零拷贝和java层面的零拷贝两种。而所谓的"零"并不是一次拷贝都没有,而是在不同的场景下尽可能减少拷贝次数。因此利用DirectBuffer减少拷贝次数和利用channel.transferTo,都可以算作是"零拷贝"的一种表现。当然真正被认可的零拷贝可能就只有transferTo了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值