美文网首页一些收藏
探讨堆外内存的监控与回收

探讨堆外内存的监控与回收

作者: tracy_668 | 来源:发表于2020-12-07 08:37 被阅读0次

    一个诡异的线上问题:线上程序使用了 NIO FileChannel 的 堆内内存(HeapByteBuffer)作为缓冲区,读写文件,逻辑可以说相当简单,但根据监控,却发现堆外内存(DirectByteBuffer)飙升,导致了 OutOfMemeory 的异常。

    由这个线上问题,引出了这篇文章的主题,主要包括:FileChannel 源码分析,堆外内存监控,堆外内存回收。

    问题分析 & 源码分析

    根据异常日志的定位,发现的确使用的是 HeapByteBuffer 来进行读写,但却导致堆外内存飙升,随即翻了 FileChannel 的源码,来一探究竟。

    FileChannel 使用的是 IOUtil 进行读写操作(本文只分析读的逻辑,写和读的代码逻辑一致,不做重复分析)

    //sun.nio.ch.IOUtil#read
    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 {
                int var6 = readIntoNativeBuffer(var0, var5, var2, var4);
                var5.flip();
                if (var6 > 0) {
                    var1.put(var5);
                }
                var7 = var6;
            } finally {
                Util.offerFirstTemporaryDirectBuffer(var5);
            }
            return var7;
        }
    }
    

    可以发现当使用 HeapByteBuffer 时,会走到下面这行看似有点疑问的代码分支:

    Util.getTemporaryDirectBuffer(var1.remaining());
    
    

    这个 Util 封装了更为底层的一些 IO 逻辑

    package sun.nio.ch;
    public class Util {
        private static ThreadLocal<Util.BufferCache> bufferCache;
        
        public static ByteBuffer getTemporaryDirectBuffer(int var0) {
            if (isBufferTooLarge(var0)) {
                return ByteBuffer.allocateDirect(var0);
            } else {
                // FOUCS ON THIS LINE
                Util.BufferCache var1 = (Util.BufferCache)bufferCache.get();
                ByteBuffer var2 = var1.get(var0);
                if (var2 != null) {
                    return var2;
                } else {
                    if (!var1.isEmpty()) {
                        var2 = var1.removeFirst();
                        free(var2);
                    }
    
                    return ByteBuffer.allocateDirect(var0);
                }
            }
        }
    }
    

    isBufferTooLarge 这个方法会根据传入 Buffer 的大小决定如何分配堆外内存,如果过大,直接分配大缓冲区;如果不是太大,会使用 bufferCache 这个 ThreadLocal 变量来进行缓存,从而复用(实际上这个数值非常大,几乎不会走进直接分配堆外内存这个分支)。这么看来似乎发现了两个不得了的结论:

    1. 使用 HeapByteBuffer 读写都会经过 DirectByteBuffer,写入数据的流转方式其实是:HeapByteBuffer -> DirectByteBuffer -> PageCache -> Disk,读取数据的流转方式正好相反。
    2. 使用 HeapByteBuffer 读写会申请一块跟线程绑定的 DirectByteBuffer。这意味着,线程越多,临时 DirectByteBuffer 就越会占用越多的空间。

    看到这儿,线上的问题似乎有了一点眉目:很有可能是多线程使用 HeapByteBuffer 写入文件,而额外分配的这块 DirectByteBuffer 导致了内存溢出。

    复现问题

    为了复现线上的问题,我们使用一个程序,不断开启线程使用堆内内存作为缓冲区进行文件的读取操作,并监控该进程的堆外内存使用情况。

    public class ReadByHeapByteBufferTest {
        public static void main(String[] args) throws IOException, InterruptedException {
            File data = new File("/tmp/data.txt");
            FileChannel fileChannel = new RandomAccessFile(data, "rw").getChannel();
            ByteBuffer buffer = ByteBuffer.allocate(4 * 1024 * 1024);
            for (int i = 0; i < 1000; i++) {
                Thread.sleep(1000);
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            fileChannel.read(buffer);
                            buffer.clear();
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }).start();
            }
        }
    }
    
    

    堆外内存的确开始疯涨了,的确符合我们的预期,堆外缓存和线程绑定,当线程非常多时,即使只使用了 4M 的堆内内存,也可能会造成极大的堆外内存膨胀,在中间发生了一次断崖,推测是线程执行完毕 or GC,导致了内存的释放。

    知晓了这一点,相信大家今后使用堆内内存时可能就会更加注意了,我总结了两个注意点:

    1. 使用 HeapByteBuffer 还需要经过一次 DirectByteBuffer 的拷贝,在追求极致性能的场景下是可以通过直接复用堆外内存来避免的。
    2. 多线程下使用 HeapByteBuffer 进行文件读写,要注意 ThreadLocal<Util.BufferCache> bufferCache 导致的堆外内存膨胀的问题。

    问题深究

    那大家有没有想过,为什么 JDK 要如此设计?为什么不直接使用堆内内存写入 PageCache 进而落盘呢?为什么一定要经过 DirectByteBuffer 的拷贝呢?

    这里其实是在迁就 OpenJDK 里的 HotSpot VM 的一点实现细节。

    HotSpot VM 里的 GC 除了 CMS 之外都是要移动对象的,是所谓“compacting GC”。

    如果要把一个 Java 里的 byte[] 对象的引用传给 native 代码,让 native 代码直接访问数组的内容的话,就必须要保证 native 代码在访问的时候这个 byte[] 对象不能被移动,也就是要被“pin”(钉)住。

    可惜 HotSpot VM 出于一些取舍而决定不实现单个对象层面的 object pinning,要 pin 的话就得暂时禁用 GC——也就等于把整个 Java 堆都给 pin 住。

    所以 Oracle/Sun JDK / OpenJDK 的这个地方就用了点绕弯的做法。它假设把 HeapByteBuffer 背后的 byte[] 里的内容拷贝一次是一个时间开销可以接受的操作,同时假设真正的 I/O 可能是一个很慢的操作。

    于是它就先把 HeapByteBuffer 背后的 byte[] 的内容拷贝到一个 DirectByteBuffer 背后的 native memory 去,这个拷贝会涉及 sun.misc.Unsafe.copyMemory() 的调用,背后是类似 memcpy() 的实现。这个操作本质上是会在整个拷贝过程中暂时不允许发生 GC 的。

    然后数据被拷贝到 native memory 之后就好办了,就去做真正的 I/O,把 DirectByteBuffer 背后的 native memory 地址传给真正做 I/O 的函数。这边就不需要再去访问 Java 对象去读写要做 I/O 的数据了。

    总结一下就是:

    1.为了方便 GC 的实现,DirectByteBuffer 指向的 native memory 是不受 GC 管辖的

    1. HeapByteBuffer 背后使用的是 byte 数组,其占用的内存不一定是连续的,不太方便 JNI 方法的调用
    2. 数组实现在不同 JVM 中可能会不同

    Java NIO中,关于DirectBuffer,HeapBuffer的疑问?

    1. DirectBuffer 属于堆外存,那应该还是属于用户内存,而不是内核内存?

    2. FileChannel 的read(ByteBuffer dst)函数,write(ByteBuffer src)函数中,如果传入的参数是HeapBuffer类型,则会临时申请一块DirectBuffer,进行数据拷贝,而不是直接进行数据传输,这是出于什么原因?

    Java NIO中的direct buffer(主要是DirectByteBuffer)其实是分两部分的:

    Java        |      native
                       |
     DirectByteBuffer  |     malloc'd
     [    address   ] -+-> [   data    ]
                       |
    
    

    其中 DirectByteBuffer 自身是一个Java对象,在Java堆中;而这个对象中有个long类型字段address,记录着一块调用 malloc() 申请到的native memory。

    1. DirectBuffer 属于堆外存,那应该还是属于用户内存,而不是内核内存?

    DirectByteBuffer 自身是(Java)堆内的,它背后真正承载数据的buffer是在(Java)堆外——native memory中的。这是 malloc() 分配出来的内存,是用户态的。

    1. FileChannel 的read(ByteBuffer dst)函数,write(ByteBuffer src)函数中,如果传入的参数是HeapBuffer类型,则会临时申请一块DirectBuffer,进行数据拷贝,而不是直接进行数据传输,这是出于什么原因?

    题主看的是OpenJDK的 sun.nio.ch.IOUtil.write(FileDescriptor fd, ByteBuffer src, long position, NativeDispatcher nd) 的实现对不对:

    
    
    static int write(FileDescriptor fd, ByteBuffer src, long position,
                         NativeDispatcher nd)
            throws IOException
        {
            if (src instanceof DirectBuffer)
                return writeFromNativeBuffer(fd, src, position, nd);
    
            // Substitute a native buffer
            int pos = src.position();
            int lim = src.limit();
            assert (pos <= lim);
            int rem = (pos <= lim ? lim - pos : 0);
            ByteBuffer 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, nd);
                if (n > 0) {
                    // now update src
                    src.position(pos + n);
                }
                return n;
            } finally {
                Util.offerFirstTemporaryDirectBuffer(bb);
            }
        }
    

    这里其实是在迁就OpenJDK里的HotSpot VM的一点实现细节。

    解释在上面...

    堆外内存的回收

    DirectByteBuffer?既然可以监控堆外内存,那验证堆外内存的回收就变得很容易实现了。

    CASE 1:分配 1G 的 DirectByteBuffer,等待用户输入后,复制为 null,之后阻塞持续观察堆外内存变化

    public class WriteByDirectByteBufferTest {
        public static void main(String[] args) throws IOException, InterruptedException {
            ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
            System.in.read();
            buffer = null;
            new CountDownLatch(1).await();
        }
    }
    

    结论:变量虽然置为了 null,但内存依旧持续占用。

    CASE 2:分配 1G DirectByteBuffer,等待用户输入后,复制为 null,手动触发 GC,之后阻塞持续观察堆外内存变化

    public class WriteByDirectByteBufferTest {
        public static void main(String[] args) throws IOException, InterruptedException {
            ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
            System.in.read();
            buffer = null;
            System.gc();
            new CountDownLatch(1).await();
        }
    }
    

    结论:GC 时会触发堆外空闲内存的回收。

    CASE 3:分配 1G DirectByteBuffer,等待用户输入后,手动回收堆外内存,之后阻塞持续观察堆外内存变化

    public class WriteByDirectByteBufferTest {
        public static void main(String[] args) throws IOException, InterruptedException {
            ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
            System.in.read();
            ((DirectBuffer) buffer).cleaner().clean();
            new CountDownLatch(1).await();
        }
    }
    

    结论:手动回收可以立刻释放堆外内存,不需要等待到 GC 的发生。

    相关文章

      网友评论

        本文标题:探讨堆外内存的监控与回收

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