美文网首页
JAVA IO专题一:java InputStream和Outp

JAVA IO专题一:java InputStream和Outp

作者: 挡不住的柳Willow | 来源:发表于2021-01-12 18:42 被阅读0次

    笔者相关文章

    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和Outp

          本文链接:https://www.haomeiwen.com/subject/rxreaktx.html