Java IO零拷贝

IO、零拷贝、ByteBuffer、DirectByteBuffer、MappedByteBuffer

前言

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

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

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

内存布局基础

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

 
image

不要把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对象的内存地址是会变化的。

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

由于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也是一样的,只不过硬盘变成了网卡而已。

具体逻辑如下图:

 
image

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

为什么要发生拷贝?

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

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

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

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

用户空间的内存可能并不指向物理内存,直接访问时可能产生缺页异常。但是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(),即内存映射。为了避免拷贝数据,我们可以让内核和用户空间共享同一块缓冲区,即:在内核种开辟一个缓冲区,然后将这同一块物理内存同时映射到内核空间和用户空间,这样用户的数据修改对内核是立即可见的,就节省了拷贝,而且可以避免系统调用,相当于共享内存了。

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

 
image

sendfile

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

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

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

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

 
image

代码测试:

测试环境: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);


链接:https://www.jianshu.com/p/adc4550317c9

堆外内存

堆外内存是相对于堆内内存的一个概念。堆内内存是由JVM所管控的Java进程内存,我们平时在Java中创建的对象都处于堆内内存中,并且它们遵循JVM的内存管理机制,JVM会采用垃圾回收机制统一管理它们的内存。那么堆外内存就是存在于JVM管控之外的一块内存区域,因此它是不受JVM的管控。

在讲解DirectByteBuffer之前,需要先简单了解两个知识点

java引用类型,因为DirectByteBuffer是通过虚引用(Phantom Reference)来实现堆外内存的释放的。

PhantomReference 是所有“弱引用”中最弱的引用类型。不同于软引用和弱引用,虚引用无法通过 get() 方法来取得目标对象的强引用从而使用目标对象,观察源码可以发现 get() 被重写为永远返回 null。
那虚引用到底有什么作用?其实虚引用主要被用来 跟踪对象被垃圾回收的状态,通过查看引用队列中是否包含对象所对应的虚引用来判断它是否 即将被垃圾回收,从而采取行动。它并不被期待用来取得目标对象的引用,而目标对象被回收前,它的引用会被放入一个 ReferenceQueue 对象中,从而达到跟踪对象垃圾回收的作用。
关于java引用类型的实现和原理可以阅读之前的文章Reference 、ReferenceQueue 详解 和Java 引用类型简述

关于linux的内核态和用户态

 
  • 内核态:控制计算机的硬件资源,并提供上层应用程序运行的环境。比如socket I/0操作或者文件的读写操作等
  • 用户态:上层应用程序的活动空间,应用程序的执行必须依托于内核提供的资源。
  • 系统调用:为了使上层应用能够访问到这些资源,内核为上层应用提供访问的接口。
 

 

因此我们可以得知当我们通过JNI调用的native方法实际上就是从用户态切换到了内核态的一种方式。并且通过该系统调用使用操作系统所提供的功能。

Q:为什么需要用户进程(位于用户态中)要通过系统调用(Java中即使JNI)来调用内核态中的资源,或者说调用操作系统的服务了?
A:intel cpu提供Ring0-Ring3四种级别的运行模式,Ring0级别最高,Ring3最低。Linux使用了Ring3级别运行用户态,Ring0作为内核态。Ring3状态不能访问Ring0的地址空间,包括代码和数据。因此用户态是没有权限去操作内核态的资源的,它只能通过系统调用外完成用户态到内核态的切换,然后在完成相关操作后再有内核态切换回用户态。

DirectByteBuffer ———— 直接缓冲

DirectByteBuffer是Java用于实现堆外内存的一个重要类,我们可以通过该类实现堆外内存的创建、使用和销毁。

 

 

 

DirectByteBuffer该类本身还是位于Java内存模型的堆中。堆内内存是JVM可以直接管控、操纵。
而DirectByteBuffer中的unsafe.allocateMemory(size);是个一个native方法,这个方法分配的是堆外内存,通过C的malloc来进行分配的。分配的内存是系统本地的内存,并不在Java的内存中,也不属于JVM管控范围,所以在DirectByteBuffer一定会存在某种方式来操纵堆外内存。
在DirectByteBuffer的父类Buffer中有个address属性:

    // Used only by direct buffers
    // NOTE: hoisted here for speed in JNI GetDirectBufferAddress
    long address;

address只会被直接缓存给使用到。之所以将address属性升级放在Buffer中,是为了在JNI调用GetDirectBufferAddress时提升它调用的速率。
address表示分配的堆外内存的地址。

 

 

 

unsafe.allocateMemory(size);分配完堆外内存后就会返回分配的堆外内存基地址,并将这个地址赋值给了address属性。这样我们后面通过JNI对这个堆外内存操作时都是通过这个address来实现的了。

在前面我们说过,在linux中内核态的权限是最高的,那么在内核态的场景下,操作系统是可以访问任何一个内存区域的,所以操作系统是可以访问到Java堆的这个内存区域的。
Q:那为什么操作系统不直接访问Java堆内的内存区域了?
A:这是因为JNI方法访问的内存区域是一个已经确定了的内存区域地质,那么该内存地址指向的是Java堆内内存的话,那么如果在操作系统正在访问这个内存地址的时候,Java在这个时候进行了GC操作,而GC操作会涉及到数据的移动操作[GC经常会进行先标志在压缩的操作。即,将可回收的空间做标志,然后清空标志位置的内存,然后会进行一个压缩,压缩就会涉及到对象的移动,移动的目的是为了腾出一块更加完整、连续的内存空间,以容纳更大的新对象],数据的移动会使JNI调用的数据错乱。所以JNI调用的内存是不能进行GC操作的。

Q:如上面所说,JNI调用的内存是不能进行GC操作的,那该如何解决了?
A:①堆内内存与堆外内存之间数据拷贝的方式(并且在将堆内内存拷贝到堆外内存的过程JVM会保证不会进行GC操作):比如我们要完成一个从文件中读数据到堆内内存的操作,即FileChannelImpl.read(HeapByteBuffer)。这里实际上File I/O会将数据读到堆外内存中,然后堆外内存再讲数据拷贝到堆内内存,这样我们就读到了文件中的内存。

 

 
    static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
        if (var1.isReadOnly()) {
            throw new IllegalArgumentException("Read-only buffer");
        } else if (var1 instanceof DirectBuffer) {
            return readIntoNativeBuffer(var0, var1, var2, var4);
        } else {
            // 分配临时的堆外内存
            ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining());

            int var7;
            try {
                // File I/O 操作会将数据读入到堆外内存中
                int var6 = readIntoNativeBuffer(var0, var5, var2, var4);
                var5.flip();
                if (var6 > 0) {
                    // 将堆外内存的数据拷贝到堆外内存中
                    var1.put(var5);
                }

                var7 = var6;
            } finally {
                // 里面会调用DirectBuffer.cleaner().clean()来释放临时的堆外内存
                Util.offerFirstTemporaryDirectBuffer(var5);
            }

            return var7;
        }
    }

而写操作则反之,我们会将堆内内存的数据线写到对堆外内存中,然后操作系统会将堆外内存的数据写入到文件中。
② 直接使用堆外内存,如DirectByteBuffer:这种方式是直接在堆外分配一个内存(即,native memory)来存储数据,程序通过JNI直接将数据读/写到堆外内存中。因为数据直接写入到了堆外内存中,所以这种方式就不会再在JVM管控的堆内再分配内存来存储数据了,也就不存在堆内内存和堆外内存数据拷贝的操作了。这样在进行I/O操作时,只需要将这个堆外内存地址传给JNI的I/O的函数就好了。

DirectByteBuffer堆外内存的创建和回收的源码解读

堆外内存分配

    DirectByteBuffer(int cap) {                   // package-private
        super(-1, 0, cap, cap);
        boolean pa = VM.isDirectMemoryPageAligned();
        int ps = Bits.pageSize();
        long size = Math.max(1L, (long)cap + (pa ? ps : 0));
        // 保留总分配内存(按页分配)的大小和实际内存的大小
        Bits.reserveMemory(size, cap);

        long base = 0;
        try {
            // 通过unsafe.allocateMemory分配堆外内存,并返回堆外内存的基地址
            base = unsafe.allocateMemory(size);
        } catch (OutOfMemoryError x) {
            Bits.unreserveMemory(size, cap);
            throw x;
        }
        unsafe.setMemory(base, size, (byte) 0);
        if (pa && (base % ps != 0)) {
            // Round up to page boundary
            address = base + ps - (base & (ps - 1));
        } else {
            address = base;
        }
        // 构建Cleaner对象用于跟踪DirectByteBuffer对象的垃圾回收,以实现当DirectByteBuffer被垃圾回收时,堆外内存也会被释放
        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
        att = null;
    }

Bits.reserveMemory(size, cap) 方法

    static void reserveMemory(long size, int cap) {

        if (!memoryLimitSet && VM.isBooted()) {
            maxMemory = VM.maxDirectMemory();
            memoryLimitSet = true;
        }

        // optimist!
        if (tryReserveMemory(size, cap)) {
            return;
        }

        final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();

        // retry while helping enqueue pending Reference objects
        // which includes executing pending Cleaner(s) which includes
        // Cleaner(s) that free direct buffer memory
        while (jlra.tryHandlePendingReference()) {
            if (tryReserveMemory(size, cap)) {
                return;
            }
        }

        // trigger VM's Reference processing
        System.gc();

        // a retry loop with exponential back-off delays
        // (this gives VM some time to do it's job)
        boolean interrupted = false;
        try {
            long sleepTime = 1;
            int sleeps = 0;
            while (true) {
                if (tryReserveMemory(size, cap)) {
                    return;
                }
                if (sleeps >= MAX_SLEEPS) {
                    break;
                }
                if (!jlra.tryHandlePendingReference()) {
                    try {
                        Thread.sleep(sleepTime);
                        sleepTime <<= 1;
                        sleeps++;
                    } catch (InterruptedException e) {
                        interrupted = true;
                    }
                }
            }

            // no luck
            throw new OutOfMemoryError("Direct buffer memory");

        } finally {
            if (interrupted) {
                // don't swallow interrupts
                Thread.currentThread().interrupt();
            }
        }
    }

该方法用于在系统中保存总分配内存(按页分配)的大小和实际内存的大小。

其中,如果系统中内存( 即,堆外内存 )不够的话:

        final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();

        // retry while helping enqueue pending Reference objects
        // which includes executing pending Cleaner(s) which includes
        // Cleaner(s) that free direct buffer memory
        while (jlra.tryHandlePendingReference()) {
            if (tryReserveMemory(size, cap)) {
                return;
            }
        }

jlra.tryHandlePendingReference()会触发一次非堵塞的Reference#tryHandlePending(false)。该方法会将已经被JVM垃圾回收的DirectBuffer对象的堆外内存释放。
因为在Reference的静态代码块中定义了:

        SharedSecrets.setJavaLangRefAccess(new JavaLangRefAccess() {
            @Override
            public boolean tryHandlePendingReference() {
                return tryHandlePending(false);
            }
        });

如果在进行一次堆外内存资源回收后,还不够进行本次堆外内存分配的话,则

        // trigger VM's Reference processing
        System.gc();

System.gc()会触发一个full gc,当然前提是你没有显示的设置-XX:+DisableExplicitGC来禁用显式GC。并且你需要知道,调用System.gc()并不能够保证full gc马上就能被执行。
所以在后面打代码中,会进行最多9次尝试,看是否有足够的可用堆外内存来分配堆外内存。并且每次尝试之前,都对延迟等待时间,已给JVM足够的时间去完成full gc操作。如果9次尝试后依旧没有足够的可用堆外内存来分配本次堆外内存,则抛出OutOfMemoryError("Direct buffer memory”)异常。

 

 

 

注意,这里之所以用使用full gc的很重要的一个原因是:System.gc()会对新生代的老生代都会进行内存回收,这样会比较彻底地回收DirectByteBuffer对象以及他们关联的堆外内存.
DirectByteBuffer对象本身其实是很小的,但是它后面可能关联了一个非常大的堆外内存,因此我们通常称之为冰山对象.
我们做ygc的时候会将新生代里的不可达的DirectByteBuffer对象及其堆外内存回收了,但是无法对old里的DirectByteBuffer对象及其堆外内存进行回收,这也是我们通常碰到的最大的问题。( 并且堆外内存多用于生命期中等或较长的对象 )
如果有大量的DirectByteBuffer对象移到了old,但是又一直没有做cms gc或者full gc,而只进行ygc,那么我们的物理内存可能被慢慢耗光,但是我们还不知道发生了什么,因为heap明明剩余的内存还很多(前提是我们禁用了System.gc – JVM参数DisableExplicitGC)。

总的来说,Bits.reserveMemory(size, cap)方法在可用堆外内存不足以分配给当前要创建的堆外内存大小时,会实现以下的步骤来尝试完成本次堆外内存的创建:
① 触发一次非堵塞的Reference#tryHandlePending(false)。该方法会将已经被JVM垃圾回收的DirectBuffer对象的堆外内存释放。
② 如果进行一次堆外内存资源回收后,还不够进行本次堆外内存分配的话,则进行 System.gc()。System.gc()会触发一个full gc,但你需要知道,调用System.gc()并不能够保证full gc马上就能被执行。所以在后面打代码中,会进行最多9次尝试,看是否有足够的可用堆外内存来分配堆外内存。并且每次尝试之前,都对延迟等待时间,已给JVM足够的时间去完成full gc操作。
注意,如果你设置了-XX:+DisableExplicitGC,将会禁用显示GC,这会使System.gc()调用无效。
③ 如果9次尝试后依旧没有足够的可用堆外内存来分配本次堆外内存,则抛出OutOfMemoryError("Direct buffer memory”)异常。

那么可用堆外内存到底是多少了?,即默认堆外存内存有多大:
① 如果我们没有通过-XX:MaxDirectMemorySize来指定最大的堆外内存。则👇
② 如果我们没通过-Dsun.nio.MaxDirectMemorySize指定了这个属性,且它不等于-1。则👇
③ 那么最大堆外内存的值来自于directMemory = Runtime.getRuntime().maxMemory(),这是一个native方法

JNIEXPORT jlong JNICALL
Java_java_lang_Runtime_maxMemory(JNIEnv *env, jobject this)
{
    return JVM_MaxMemory();
}

JVM_ENTRY_NO_ENV(jlong, JVM_MaxMemory(void))
  JVMWrapper("JVM_MaxMemory");
  size_t n = Universe::heap()->max_capacity();
  return convert_size_t_to_jlong(n);
JVM_END

其中在我们使用CMS GC的情况下也就是我们设置的-Xmx的值里除去一个survivor的大小就是默认的堆外内存的大小了。

堆外内存回收

Cleaner是PhantomReference的子类,并通过自身的next和prev字段维护的一个双向链表。PhantomReference的作用在于跟踪垃圾回收过程,并不会对对象的垃圾回收过程造成任何的影响。
所以cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); 用于对当前构造的DirectByteBuffer对象的垃圾回收过程进行跟踪。
当DirectByteBuffer对象从pending状态 ——> enqueue状态时,会触发Cleaner的clean(),而Cleaner的clean()的方法会实现通过unsafe对堆外内存的释放。

 

 

 

 

 

👆虽然Cleaner不会调用到Reference.clear(),但Cleaner的clean()方法调用了remove(this),即将当前Cleaner从Cleaner链表中移除,这样当clean()执行完后,Cleaner就是一个无引用指向的对象了,也就是可被GC回收的对象。

thunk方法:

 

 

通过配置参数的方式来回收堆外内存

同时我们可以通过-XX:MaxDirectMemorySize来指定最大的堆外内存大小,当使用达到了阈值的时候将调用System.gc()来做一次full gc,以此来回收掉没有被使用的堆外内存。

堆外内存那些事

使用堆外内存的原因

  • 对垃圾回收停顿的改善
    因为full gc 意味着彻底回收,彻底回收时,垃圾收集器会对所有分配的堆内内存进行完整的扫描,这意味着一个重要的事实——这样一次垃圾收集对Java应用造成的影响,跟堆的大小是成正比的。过大的堆会影响Java应用的性能。如果使用堆外内存的话,堆外内存是直接受操作系统管理( 而不是虚拟机 )。这样做的结果就是能保持一个较小的堆内内存,以减少垃圾收集对应用的影响。
  • 在某些场景下可以提升程序I/O操纵的性能。少去了将数据从堆内内存拷贝到堆外内存的步骤。

什么情况下使用堆外内存

  • 堆外内存适用于生命周期中等或较长的对象。( 如果是生命周期较短的对象,在YGC的时候就被回收了,就不存在大内存且生命周期较长的对象在FGC对应用造成的性能影响 )。
  • 直接的文件拷贝操作,或者I/O操作。直接使用堆外内存就能少去内存从用户内存拷贝到系统内存的操作,因为I/O操作是系统内核内存和设备间的通信,而不是通过程序直接和外设通信的。
  • 同时,还可以使用 池+堆外内存 的组合方式,来对生命周期较短,但涉及到I/O操作的对象进行堆外内存的再使用。( Netty中就使用了该方式 )

堆外内存 VS 内存池

  • 内存池:主要用于两类对象:①生命周期较短,且结构简单的对象,在内存池中重复利用这些对象能增加CPU缓存的命中率,从而提高性能;②加载含有大量重复对象的大片数据,此时使用内存池能减少垃圾回收的时间。
  • 堆外内存:它和内存池一样,也能缩短垃圾回收时间,但是它适用的对象和内存池完全相反。内存池往往适用于生命期较短的可变对象,而生命期中等或较长的对象,正是堆外内存要解决的。

堆外内存的特点

  • 对于大内存有良好的伸缩性
  • 对垃圾回收停顿的改善可以明显感觉到
  • 在进程间可以共享,减少虚拟机间的复制

堆外内存的一些问题

  • 堆外内存回收问题,以及堆外内存的泄漏问题。这个在上面的源码解析已经提到了
  • 堆外内存的数据结构问题:堆外内存最大的问题就是你的数据结构变得不那么直观,如果数据结构比较复杂,就要对它进行串行化(serialization),而串行化本身也会影响性能。另一个问题是由于你可以使用更大的内存,你可能开始担心虚拟内存(即硬盘)的速度对你的影响了。

参考

http://lovestblog.cn/blog/2015/05/12/direct-buffer/
http://www.infoq.com/cn/news/2014/12/external-memory-heap-memory
http://www.jianshu.com/p/85e931636f27
圣思园《并发与Netty》课程

堆外内存 之 DirectByteBuffer 详解 - 简书

posted @ 2024-02-04 16:50  CharyGao  阅读(3)  评论(0编辑  收藏  举报