笔者相关文章
java NIO读取文件并通过socket发送,最少拷贝了几次?堆外内存和所谓的零拷贝到底是什么关系
前言
关于这个问题,网上说法很多,有的说四次有的说五次,因此写这篇文章仔细梳理一下
示例代码
File file = new File("/path");
FileInputStream in = new FileInputStream(file);
//假设一次可以读取完
byte[] buf = new byte[1024];
//文件数据读取到buf数组
in.read(buf);
//开启socket
Socket socket = new Socket("localhost", 9099);
OutputStream outputStream = socket.getOutputStream();
//数据写出去
outputStream.write(buf);
字面意思,就是将file读取到buf,再把buf通过socket发送出去。我们一步一步来分析其中的原理
首先是 fileInputStream.read(buf)
public int read(byte b[]) throws IOException {
return readBytes(b, 0, b.length);
}
private native int readBytes(byte b[], int off, int len) throws IOException;
fileInputStream.read方法实际调用了native的readBytes(byte b[], int off, int len)
,那么再来看看native的具体实现:
// 下列内容文件路径: openjdk-jdk8u-jdk8u/jdk/src/share/native/java/io/io_util.c
jint readBytes(JNIEnv *env, jobject this, jbyteArray bytes,
jint off, jint len, jfieldID fid)
{
jint nread;
//事先定义的堆栈内存
char stackBuf[BUF_SIZE];
char *buf = NULL;
FD fd;
... 忽略不重要代码
if (len == 0) {
return 0;
} else if (len > BUF_SIZE) {
//如果希望读取的长度大于BUF_SIZE,则开辟一个新的内存,这个内存是native堆内存,属于用户空间的堆外内存
buf = malloc(len);
if (buf == NULL) {
JNU_ThrowOutOfMemoryError(env, NULL);
return 0;
}
} else {
//如果不大于BUF_SIZE,就直接用这个缓冲区,即在C++层readBytes就已经做了优化,
//无论如何都不会一个一个字节去读取而是批量读取,并缓存起来
buf = stackBuf;
}
//获取文件描述符
fd = GET_FD(this, fid);
if (fd == -1) {
JNU_ThrowIOException(env, "Stream Closed");
nread = -1;
} else {
//调用IO_Read函数将文件数据读取到native的buf数组中
nread = IO_Read(fd, buf, len);
if (nread > 0) {
//将buf拷贝到java传过来的byte数组,即将堆外内存拷贝到堆内存
(*env)->SetByteArrayRegion(env, bytes, off, nread, (jbyte *)buf);
} else if (nread == -1) {
JNU_ThrowIOExceptionWithLastError(env, "Read error");
} else { /* EOF */
nread = -1;
}
}
if (buf != stackBuf) {
free(buf);
}
return nread;
}
整个过程如下(其实注释已经写的很清楚了):
- 先开辟一个native内存buf,即堆外内存
- 获取文件描述符,调用IO_Read将文件数据拷贝到buf中
- 调用
SetByteArrayRegion
将buf拷贝到java堆内存中
这个过程中,还存在两个疑问:
- 调用IO_Read的时候发生了什么
- 为什么要先拷贝到堆外内存,再拷贝到堆内
还是按顺序解答:
- 问题一: 调用IO_Read的时候发生了什么?
IO_Read方法对应的c代码如下:
// 下列内容文件路径: openjdk-jdk8u-jdk8u/jdk/src/solaris/native/java/io/io_util_md.h
#define IO_Read handleRead
#define RESTARTABLE(_cmd, _result) do { \
do { \
_result = _cmd; \
} while((_result == -1) && (errno == EINTR)); \
} while(0)
// 下列内容文件路径: openjdk-jdk8u-jdk8u/jdk/src/solaris/native/java/io/io_util_md.c
ssize_t
handleRead(FD fd, void *buf, jint len)
{
ssize_t result;
//直接调用操作系统的read函数
RESTARTABLE(read(fd, buf, len), result);
return result;
}
可以看到IO_Read其实是直接调用了操作系统的read来读取文件数据到buf中,而这个过程涉及两次拷贝,即DMA将文件读取到内核缓冲区(1次),内核将缓冲区数据读取到堆外内存buf(2次)
- 问题二: 为什么要先在堆外开辟内存,再拷贝到堆内
一个重要的前提是,java和c++都无法直接操作内核缓冲区的数据,只有用户空间的操作权限,在这个前提下,理想情况分为两种:- 直接将内核缓冲区的数据拷贝到堆内存。为啥不行?因为jvm的gc一直在不断的整理内存,内存地址可能会发生变化,如果native希望将数据拷贝到堆内存,那么每一次拷贝都必须将jvm暂停来保证gc不出错。而IO_Read方法中拿到的java传过来的byte数组的引用,在将buf拷贝给这个引用的时候,即使内存地址变了,jvm也能够知道它真正的地址在哪里。
- 内核缓冲区拷贝数据到堆外内存,由jvm直接操作,nio的directBuffer就是这么做的。
所以,将文件读取到jvm堆内存,一共涉及三次拷贝:
- DMA拷贝文件数据到内核缓冲区
- CPU拷贝内核数据到堆外内存
- CPU拷贝堆外内存到堆内
然后是 outputStream.write(buf);
socket.getOutputStream拿到的对象是SocketOutputStream
,因此实际调用的是SocketOutputStream.write(buf)
:
public void write(byte b[]) throws IOException {
socketWrite(b, 0, b.length);
}
private void socketWrite(byte b[], int off, int len) throws IOException {
... 忽略不重要代码
//获取文件描述符对象
FileDescriptor fd = impl.acquireFD();
try {
//调用native的socketWrite0
socketWrite0(fd, b, off, len);
} catch (SocketException se) {
... 忽略不重要代码
} finally {
impl.releaseFD();
}
}
private native void socketWrite0(FileDescriptor fd, byte[] b, int off, int len) throws IOException;
很清晰,可以理解为直接调用了native的socketWrite0方法:
// 下列内容在: openjdk-jdk8u-jdk8u_vscode/jdk/src/solaris/native/java/net/SocketOutputStream.c
JNIEXPORT void JNICALL
Java_java_net_SocketOutputStream_socketWrite0(JNIEnv *env, jobject this,
jobject fdObj,
jbyteArray data,
jint off, jint len) {
char *bufP;
char BUF[MAX_BUFFER_LEN];
int buflen;
int fd;
... 此处忽略获取文件描述符fd的代码
if (len <= MAX_BUFFER_LEN) {
//跟文件读取一个缓存思路
bufP = BUF;
buflen = MAX_BUFFER_LEN;
} else {
buflen = min(MAX_HEAP_BUFFER_LEN, len);
//根据想要读取的内容长度,开辟一个native缓存bufP
bufP = (char *)malloc((size_t)buflen);
if (bufP == NULL) {
bufP = BUF;
buflen = MAX_BUFFER_LEN;
}
}
while(len > 0) {
int loff = 0;
int chunkLen = min(buflen, len);
int llen = chunkLen;
//将java希望写出去的数据拷贝到native缓存bufP数组中
(*env)->GetByteArrayRegion(env, data, off, chunkLen, (jbyte *)bufP);
if ((*env)->ExceptionCheck(env)) {
break;
} else {
while(llen > 0) {
//发送数据
int n = NET_Send(fd, bufP + loff, llen, 0);
if (n > 0) {
llen -= n;
loff += n;
continue;
}
... 忽略多余代码
return;
}
len -= chunkLen;
off += chunkLen;
}
}
}
我们主要关注三个部分:
-
bufP = (char *)malloc((size_t)buflen);
开辟native缓存bufP -
(*env)->GetByteArrayRegion(env, data, off, chunkLen, (jbyte *)bufP);
把java传过来的data拷贝给bufP -
int n = NET_Send(fd, bufP + loff, llen, 0);
调用NET_Send发送数据
那么问题就转换为分析NET_Send到底干了什么:
// 下列内容在: openjdk-jdk8u-jdk8u_vscode/jdk/src/solaris/native/java/net/linux_close.c
int NET_Send(int s, void *msg, int len, unsigned int flags) {
//调用系统函数send
BLOCKING_IO_RETURN_INT( s, send(s, msg, len, flags) );
}
#define BLOCKING_IO_RETURN_INT(FD, FUNC) { \
int ret; \
threadEntry_t self; \
fdEntry_t *fdEntry = getFdEntry(FD); \
if (fdEntry == NULL) { \
errno = EBADF; \
return -1; \
} \
do { \
startOp(fdEntry, &self); \
ret = FUNC; \
endOp(fdEntry, &self); \
} while (ret == -1 && errno == EINTR); \
return ret; \
}
直接通过系统调用send方法,而send的工作包括以下几个步骤:
- CPU将堆外内存拷贝到内核的套接字缓冲区
- DMA将套接字缓冲区数据拷贝到协议引擎进行传输
总结
- java BIO读取文件再通过socket发送,一共包含六次拷贝:
- 调用系统函数read读取磁盘文件,用户态切换到内核态,底层调用DMA读取磁盘文件,把内容拷贝到内核的读写缓冲区(不消耗CPU)
- 内核缓冲区数据拷贝到堆外内存,内核态转换为用户态,消耗CPU
- 堆外内存拷贝到堆内内存,消耗CPU
- 堆内又拷贝到堆外内存,消耗CPU
- 调用socket的send,用户态切换到内核态,堆外再拷贝到套接字缓冲区,消耗CPU
- send返回,内核态切换回用户态,同时DMA把数据从套接字缓冲区拷贝到协议引擎进行发送
- 文章开头提到的其他博客所说的四次、五次到底对不对呢?其实也不算错,因为很多人都是基于read函数这一层做分析,即从内核缓冲区拷贝到native缓冲区,就没有后面所谓堆内内存的概念了。因此对于C++程序员来说,确实是只拷贝了四次。
参考文章
JavaIO原理剖析之 磁盘IO
JavaIO原理剖析之 网络IO
C++ Socket编程(二) send与recv缓冲区与阻塞