Java IO零拷贝

1,088 阅读11分钟

IO、零拷贝、ByteBuffer、DirectByteBuffer、MappedByteBuffer

前言

在Java中经常会提到零拷贝,这个词在不同的层面有不同的含义:

  1. Java 堆内和堆外之间的零拷贝
  2. 数据在用户空间和内核空间的零拷贝
  3. 处理分段的数据,拼接、切片时的零拷贝

JVM堆内外之间的数据零拷贝

内存布局基础

JVM虚拟机是由C++语言编写的,对操作系统来说只是一个普通的C++程序,所以除了Java的内存模型外,JVM整体使用的内存也符合C语言的堆栈区分配。看下面这张经典的C语言内存布局图:

不要把JVM的堆栈和C语言的堆栈弄混了,因为JVM属于C++程序,而我们所谓的Java堆实际上是一个C++对象,所以属于C语言的堆之内。至于Java线程的栈分配在C语言的堆还是栈上就看具体的虚拟机实现了,这个其实区别不大。

对于JVM的堆我们暂且叫做:Java Heap,之外的堆叫做C Heap。

对应的,ByteBuffer有allocate方法用来申请堆内和堆外缓冲区。

Java Heap和C Heap哪个效率更高?

其实差别不大,两个都是由malloc申请来的内存,可以理解为Java Heap属于C Heap的一部分,所以两者的读写效率肯定没什么差别。

但Java Heap有一个很重要的特性就是GC,在堆内GC的时候可能会移动其中的对象。 这点很关键,因为这意味着我们的Java对象的内存地址并不是固定的

这也解释了一个常见的疑问:‘Java的hashcode是否是内存地址?’,答案是否定的,Java的HashCode其实不是内存地址。因为Java规范要求应用运行其间hashcode值不能变,但Java对象的内存地址是会变化的。

为什么会在堆内外之间发生数据拷贝

由于JVM只是一个普通的用户程序,所以涉及到系统功能时JVM必须把功能委托给操作系统提供的系统调用执行,这里我们研究一下IO操作中的write和read函数。

结论

由于下面会涉及到源码分析,不感兴趣的可以跳过,这里先说下结论:

由于JVM GC其间会移动对象的地址,包括byte[],而内核无法感知到内存的移动,很可能会导致数据错误。所以我们在以byte[]的形式写入数据时必须先把数据拷贝到不受JVM堆控制的堆外内存中,这部分就是我们说的C heap,而DirectByteBuffer正处于这片区域。

同样的,read的时候,我们也要保证在系统往缓冲区写入的时候我们不能gc移动内存,否则数据不知道写到了哪里,所以也会导致拷贝发生。

源码分析

Java版本:openjdk-17

我们分析两块代码:

FileOutputStream/FileInputStream的读写以及SocketChannel和FileChannel的读写。

首先是FileOutputStream:

 /**

 * Writes a sub array as a sequence of bytes.

 * @param b the data to be written

 * @param off the start offset in the data

 * @param len the number of bytes that are written

 * @param append {@code true} to first advance the position to the

 *     end of file

 * @throws    IOException If an I/O error has occurred.

 */

private native void writeBytes(byte b[], int off, int len, boolean append)

    throws IOException;

我们可以看到,writeBytes调用的是native方法,FileInputStream的readBytes也是一样的。

我们找到对应的C源码文件,src\java.base\share\native\libjava\FileOutputStream.c

#include "io_util.h"



JNIEXPORT void JNICALL

Java_java_io_FileOutputStream_writeBytes(JNIEnv *env,

    jobject this, jbyteArray bytes, jint off, jint len, jboolean append) {

    writeBytes(env, this, bytes, off, len, append, fos_fd);

}

对应的在io_util.c中,其中包含了读写IO的方法,其中读和写的逻辑差不多,都是先开辟缓冲区,然后复制,调用系统调用。



/* The maximum size of a stack-allocated buffer.

 */

#define BUF_SIZE 8192



//读函数

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;

    //省略一些越界检查

    //开辟堆外缓冲区,如果小于BUF_SIZE,直接在栈上,否则用malloc在堆上开辟

    if (len == 0) {

        return 0;

    } else if (len > BUF_SIZE) {

        buf = malloc(len);

        if (buf == NULL) {

            JNU_ThrowOutOfMemoryError(env, NULL);

            return 0;

        }

    } else {

        buf = stackBuf;

    }



    fd = getFD(env, this, fid);

    if (fd == -1) {

        JNU_ThrowIOException(env, "Stream Closed");

        nread = -1;

    } else {

        //执行系统调用,对应handleRead,看下面

        nread = IO_Read(fd, buf, len);

        if (nread > 0) {

            //从堆外(C HEAP)拷贝数据到JVM堆内

            (*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;

}



//写函数

void

writeBytes(JNIEnv *env, jobject this, jbyteArray bytes,

           jint off, jint len, jboolean append, jfieldID fid)

{

    jint n;

    char stackBuf[BUF_SIZE];

    char *buf = NULL;

    FD fd;

    //。。。省略一些检查

    //开辟缓冲区,小数组直接在栈上,大数组用malloc申请堆内存

    if (len == 0) {

        return;

    } else if (len > BUF_SIZE) {

        buf = malloc(len);

        if (buf == NULL) {

            JNU_ThrowOutOfMemoryError(env, NULL);

            return;

        }

    } else {

        buf = stackBuf;

    }

    //关键在此,会把我们传入的数组(Java Heap)拷贝到刚刚申请的堆外内存(C Heap)里

    (*env)->GetByteArrayRegion(env, bytes, off, len, (jbyte *)buf);



    if (!(*env)->ExceptionOccurred(env)) {

        off = 0;

        while (len > 0) {

            fd = getFD(env, this, fid);

            if (fd == -1) {

                JNU_ThrowIOException(env, "Stream Closed");

                break;

            }

            //系统调用

            if (append == JNI_TRUE) {

                n = IO_Append(fd, buf+off, len);

            } else {

                n = IO_Write(fd, buf+off, len);

            }

            if (n == -1) {

                JNU_ThrowIOExceptionWithLastError(env, "Write error");

                break;

            }

            off += n;

            len -= n;

        }

    }

    //清理缓冲区

    if (buf != stackBuf) {

        free(buf);

    }

}



//其中IO_WRITE和IO_APPEND都对应io_util_md.c下的handleWrite方法

ssize_t

handleWrite(FD fd, const void *buf, jint len)

{

    ssize_t result;

    //终于看到了熟悉的write,这里就是系统调用了

    RESTARTABLE(write(fd, buf, len), result);

    return result;

}

//对应IO_READ

ssize_t

handleRead(FD fd, void *buf, jint len)

{

    ssize_t result;

    RESTARTABLE(read(fd, buf, len), result);

    return result;

}

其它IO类:

FileChannelImpl、SocketChannelImpl读写都使用的是IOUtil类,进去看一下:

里面会判断是否为DirectByteBuffer,如果不是就申请一个,当然,这里系统会维持一个DirectByteBuffer的缓存,如果小数据量会直接分配,没必要每次都malloc,大数据量则重新申请一块堆外内存。

然后调用NativeDispatcher的write方法,代码实现在FileDispatcherImpl.c里,也很难简单就不去说了。

static int write(FileDescriptor fd, ByteBuffer src, long position,

                 boolean directIO, boolean async, int alignment,

                 NativeDispatcher nd)

    throws IOException

{

    //关键!!!,这里会判断使用的是否是DirectByteBuffer,如果是调用另一个方法

    if (src instanceof DirectBuffer) {

        return writeFromNativeBuffer(fd, src, position, directIO, async, alignment, nd);

    }



    //否则使用navtiveBuffer拷贝需要写入的数据

    // Substitute a native buffer

 int pos = src.position();

    int lim = src.limit();

    assert (pos <= lim);

    int rem = (pos <= lim ? lim - pos : 0);

    ByteBuffer bb;

    //申请directBuffer

    if (directIO) {

        Util.checkRemainingBufferSizeAligned(rem, alignment);

        bb = Util.getTemporaryAlignedDirectBuffer(rem, alignment);

    } else {

        bb = Util.getTemporaryDirectBuffer(rem);

    }

    try {

        //拷贝

        bb.put(src);

        bb.flip();

        // Do not update src until we see how many bytes were written

 src.position(pos);



        int n = writeFromNativeBuffer(fd, bb, position, directIO, async, alignment, nd);

        if (n > 0) {

            // now update src

 src.position(pos + n);

        }

        return n;

    } finally {

        Util.offerFirstTemporaryDirectBuffer(bb);

    }

}

怎么避免堆内外拷贝

由上面的代码也能分析出来,只要你在支持缓冲区操作的地方使用DirectByteBuffer就可以了,而对于面向流的byte[]数组参数的方法,拷贝是没法避免的。

另外,DirectByteBuffer有一定的缓存,所以小数据的读写不会调用malloc,复制速度也会比较快。

用户空间和内核空间的零拷贝

我们同样先来梳理一下为什么IO时会有拷贝发生,以最典型的文件读写读写为例,当我们读取文件、处理数据、再写回文件的操作流程如下:

  1. 用户态调用系统调用-read
  2. 内核给DMA控制器发送读命令
  3. DMA负责控制硬盘,读取数据到内核缓冲区,完成后通知CPU
  4. CPU将数据搬运到用户态缓冲区
  5. 用户处理数据
  6. 调用write系统调用
  7. 内核复制用户数据到内核缓冲区
  8. 使用DMA将数据写入到硬盘
  9. 返回给用户

网络IO也是一样的,只不过硬盘变成了网卡而已。

具体逻辑如下图:

可以看到,当我们完成一次读,修改,写的流程时,来回内核缓冲区拷贝了两次,如果再加上堆内外的拷贝,又要再加两次,这性能损失是很恐怖的。

为什么要发生拷贝?

这个问题其实很复杂,可以再写一个文章了,简单来说:

用户空间是绝对无法直接访问内核空间的,但是内核空间可以直接访问用户空间或者说任意一块物理内存。

系统调用会涉及到特权等级的切换(由用户态到内核态),内核需要为其开辟新的堆栈空间,然后将参数复制到内核堆栈,交给内核处理。参数的复制很简单也很好理解,因为两者不能共享栈,复制速度也都很快,但为什么要参数中指针指向的缓冲区数据也要复制呢?

这块儿其实很复杂,和安全性有关,但主要的原因应该是:

用户空间的内存可能并不指向物理内存,直接访问时可能产生缺页异常。但是Linux内核禁止在中断时产生缺页异常,否则会产生打印oops错误。这就要求我们用户的缓冲区数据必须要存在内存中,而这个实际上很难控制,所以就会直接把数据复制到内核缓冲区中。

这块儿细说真的可以再开一个坑了,,简单的贴一下linux内核源码,有空再写一篇。

//缺页异常处理函数

asmlinkage void

do_page_fault(unsigned long address, unsigned long mmcsr,

          long cause, struct pt_regs *regs)

{

    //省略一堆。。

    //这里注释已经说的很清楚了,不允许在内核中执行缺页中断

    /* If we're in an interrupt context, or have no user context,

       we must not take the fault.  */

    if (!mm || faulthandler_disabled())

        goto no_context;

}

//打印oops

 no_context:

    /* Oops. The kernel tried to access some bad page. We'll have to

       terminate things with extreme prejudice.  */

    printk(KERN_ALERT "Unable to handle kernel paging request at "

           "virtual address %016lx\n", address);

    die_if_kernel("Oops", regs, cause, (unsigned long*)regs - 16);

    do_exit(SIGKILL);

内存映射:MappedByteBuffer

MappedByteBuffer是DirectByteBuffer的子类,所以MappedByteBuffer也属于堆外内存。

MappedByteBuffer的原理是mmap(),即内存映射。为了避免拷贝数据,我们可以让内核和用户空间共享同一块缓冲区,即:在内核种开辟一个缓冲区,然后将这同一块物理内存同时映射到内核空间和用户空间,这样用户的数据修改对内核是立即可见的,就节省了拷贝,而且可以避免系统调用,相当于共享内存了。

由于我们把文件的内容直接映射到了内存中,修改起来也会非常的快。

sendfile

Java中对应的是FileChannel.transfer();

这个主要用于不要处理数据,单纯读写的情景,如:文件的拷贝

试想一下,如果我们只是单纯的下载文件,从网卡读取数据然后再写入到硬盘中,中间不需要修改数据,所以没有必要让数据在内核和用户空间来回复制,直接让内核帮我们把网卡数据写入到硬盘即可,中间没必要用户态干预,节省了拷贝和系统调用次数。

所以FileChannel.transfer的速度是很快的。

代码测试:

测试环境:windows11,java17

用不同的方式拷贝一个700M的文件,结果

stream用时:1658ms

byteBuffer拷贝用时:1665ms

directByteBuffer拷贝用时:1565ms

channel拷贝用时:910ms

100M文件,结果:

stream用时:422ms

byteBuffer拷贝用时:419ms

directByteBuffer拷贝用时:384ms

transfer拷贝用时:129ms

注意,transfer默认一次最大传输2GB。另外不同的拷贝方式主要区别在于数据在内存中拷贝的次数和系统调用的次数不同,如果磁盘的速度占主要部分,时间差距就没有那么明显了,因为内存中的数据拷贝要比硬盘快上几个量级。

代码如下:



public static void copyByStream(File source, File dest) throws IOException {

    if (!dest.exists()) {

        dest.createNewFile();

    }

    try (var input = new BufferedInputStream(new FileInputStream(source));

         var output = new BufferedOutputStream(new FileOutputStream(dest));) {

        byte[] buf = new byte[1024 * 8];

        int len = 0;

        while ((len = input.read(buf)) != -1) {

            output.write(buf, 0, len);

        }

        output.flush();

    }

}



public static void copyByByteBuffer(File source, File dest) throws IOException {

    if (!dest.exists()) {

        dest.createNewFile();

    }

    try (var in = new FileInputStream(source);

         var out = new FileOutputStream(dest);

         var sourceChannel = in.getChannel();

         var destChannel = out.getChannel();) {

        ByteBuffer buffer = ByteBuffer.allocate(1024 * 8);

        while (sourceChannel.read(buffer) != -1) {

            buffer.flip();

            destChannel.write(buffer);

            buffer.flip();

        }

    }

}



public static void copyByDirectByteBuffer(File source, File dest) throws IOException {

    if (!dest.exists()) {

        dest.createNewFile();

    }

    try (var in = new FileInputStream(source);

         var out = new FileOutputStream(dest);

         var sourceChannel = in.getChannel();

         var destChannel = out.getChannel();) {

        ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 8);

        while (sourceChannel.read(buffer) != -1) {

            buffer.flip();

            destChannel.write(buffer);

            buffer.flip();

        }

    }

}



public static void copyByTransfer(File source, File dest) throws IOException {

    if (!dest.exists()) {

        dest.createNewFile();

    }

    try (var in = new FileInputStream(source);

         var out = new FileOutputStream(dest);

         var sourceChannel = in.getChannel();

         var destChannel = out.getChannel();) {

        long count = 0;

        while (count < source.length()) {

            count += destChannel.transferFrom(sourceChannel,count,source.length());

        }

    }

}

用户空间内的零拷贝

这个主要是指netty的零拷贝,因为网络数据或者说大部分IO数据都是一段一段的,分散成多个ByteBuffer,而我们做数据转换时可能需要获取全部数据才能处理,这中间就会涉及到很多次的拷贝:先把每个ByteBuffer储存起来,然后再统一拷贝到大的ByteBuffer中一块处理。

Netty在这方面做的比较好,自己设计了一个ByteBuf和CompositeByteBuf。

原理也很简单,CompositeByteBuf内部维护了多个ByteBuf,操作时维护一个统一的读写指针,就像操作一个ByteBuf一样,可以快速的进行合并、切片。

ByteBuf header = Unpooled.buffer(1024);

ByteBuf body = Unpooled.buffer(1024);

//合并,并不会复制

CompositeByteBuf buf = Unpooled.compositeBuffer();

buf.addComponent(header);

buf.addComponent(body);