JAVA IO专题一:java InputStream和OutputStream读取文件并通过socket发送,到底涉及几次拷贝

笔者相关文章

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;
}

整个过程如下(其实注释已经写的很清楚了):

  1. 先开辟一个native内存buf,即堆外内存
  2. 获取文件描述符,调用IO_Read将文件数据拷贝到buf中
  3. 调用SetByteArrayRegion将buf拷贝到java堆内存中

这个过程中,还存在两个疑问:

  1. 调用IO_Read的时候发生了什么
  2. 为什么要先拷贝到堆外内存,再拷贝到堆内

还是按顺序解答:

  • 问题一: 调用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++都无法直接操作内核缓冲区的数据,只有用户空间的操作权限,在这个前提下,理想情况分为两种
    1. 直接将内核缓冲区的数据拷贝到堆内存。为啥不行?因为jvm的gc一直在不断的整理内存,内存地址可能会发生变化,如果native希望将数据拷贝到堆内存,那么每一次拷贝都必须将jvm暂停来保证gc不出错。而IO_Read方法中拿到的java传过来的byte数组的引用,在将buf拷贝给这个引用的时候,即使内存地址变了,jvm也能够知道它真正的地址在哪里。
    2. 内核缓冲区拷贝数据到堆外内存,由jvm直接操作,nio的directBuffer就是这么做的。

所以,将文件读取到jvm堆内存,一共涉及三次拷贝:

  1. DMA拷贝文件数据到内核缓冲区
  2. CPU拷贝内核数据到堆外内存
  3. 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;
        }
    }
}

我们主要关注三个部分:

  1. bufP = (char *)malloc((size_t)buflen); 开辟native缓存bufP
  2. (*env)->GetByteArrayRegion(env, data, off, chunkLen, (jbyte *)bufP); 把java传过来的data拷贝给bufP
  3. 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的工作包括以下几个步骤:

  1. CPU将堆外内存拷贝到内核的套接字缓冲区
  2. DMA将套接字缓冲区数据拷贝到协议引擎进行传输

总结

  • java BIO读取文件再通过socket发送,一共包含六次拷贝:
    1. 调用系统函数read读取磁盘文件,用户态切换到内核态,底层调用DMA读取磁盘文件,把内容拷贝到内核的读写缓冲区(不消耗CPU)
    2. 内核缓冲区数据拷贝到堆外内存,内核态转换为用户态,消耗CPU
    3. 堆外内存拷贝到堆内内存,消耗CPU
    4. 堆内又拷贝到堆外内存,消耗CPU
    5. 调用socket的send,用户态切换到内核态,堆外再拷贝到套接字缓冲区,消耗CPU
    6. send返回,内核态切换回用户态,同时DMA把数据从套接字缓冲区拷贝到协议引擎进行发送
  • 文章开头提到的其他博客所说的四次、五次到底对不对呢?其实也不算错,因为很多人都是基于read函数这一层做分析,即从内核缓冲区拷贝到native缓冲区,就没有后面所谓堆内内存的概念了。因此对于C++程序员来说,确实是只拷贝了四次。

参考文章

JavaIO原理剖析之 磁盘IO
JavaIO原理剖析之 网络IO
C++ Socket编程(二) send与recv缓冲区与阻塞

你可能感兴趣的:(JAVA IO专题一:java InputStream和OutputStream读取文件并通过socket发送,到底涉及几次拷贝)