NIO详解(六):Java堆外内存

1. 前言

最近研究ByteBuffer和DirectByteBuffer。堆外内存是相对于堆内内存的一个概念。堆内内存是由JVM所管控的Java进程内存,我们平时在Java中创建的对象都处于堆内内存中,并且它们遵循JVM的内存管理机制,JVM会采用垃圾回收机制统一管理它们的内存。那么堆外内存就是存在于JVM管控之外的一块内存区域,因此它是不受JVM的管控。下面本博客就来详细介绍以下Java NIO 中的DirectByteBuffer。

2. Linux 内核态和用户态

在这里插入图片描述

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

内核态由操作系统控制计算机的硬件资源,用户态的程序可以通过一些系统调用,通过上下文切换,由用户态切换到内核态,然后进行相应的操作系统底层系统调用。因此我们可以得知当我们通过JNI调用的native方法实际上就是从用户态切换到了内核态的一种方式。并且通过该系统调用使用操作系统所提供的功能。

2.DirectByteBuffer ———— 直接缓冲

堆内内存存在的问题:由于HeapByteBuffer是存储在JVM 堆中的,所以我们在使用ByteBufffer的时候,如果创建了一个很大的ByteBuffer的时候,那么过大的内存Buffer对于垃圾回收来说造成的影响会很大,JVM新生带会频繁进行Minor GC,进而对程序的性能造成影响。在某些I/O操作下,FilChannelImpl需要通过堆外内存进行数据传输,如果使用HeapByteBuffer的话,FilChannelImpl需要通过将HeapByteBuffer复制到堆外内存,然后进行数据传输。

DirectByteBuffer解决了上述产生的问题:

  • 垃圾回收停顿改善:由于DirectByteBuffer是堆外内存,不受垃圾回收机制控制,它直接受操作系统管理。所以减少了大内存Buffer在新生代中,造成JVM新生代进行频繁的垃圾回收。
  • 在某些场景下可以提升程序I/O操纵的性能。少去了将数据从堆内内存拷贝到堆外内存的步骤。

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

在这里插入图片描述
DirectByteBuffer该类本身还是位于Java内存模型的堆中。堆内内存是JVM可以直接管控、操纵。 而DirectByteBuffer中的unsafe.allocateMemory(size);是个一个native方法,这个方法分配的是堆外内存,通过C的malloc来进行分配的。分配的内存是系统本地的内存,并不在Java的内存中,也不属于JVM管控范围,所以在DirectByteBuffer一定会存在某种方式来操纵堆外内存。 下面我们就来分析DirectByteBuffer的创建过程和回收过程。

2. 源码分析 DirectByteBuffer内存分配和回收

2.1 DirectByteBuffer内存分配

在DirectByteBuffer的父类Buffer中有个address属性,address只会被Directbuffer给使用到。之所以将address属性升级放在Buffer中,是为了在JNI调用GetDirectBufferAddress时提升它调用的速率。

 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 {
            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 = Cleaner.create(this, new Deallocator(base, size, cap));
        att = null;



    }

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

2.2 DirectByteBuffer回收

前面我们说到了DirectByteBuffer是堆外内存,它不由JVM 垃圾回收机制控制。所以JVM 垃圾回收不了DirectByteBuffer分配的内存,那么DirectByteBuffer是如何进行回收的呢?
DirectBuffer内存回收主要有两种方式,一种是通过System.gc来回收,另一种是通过构造函数里创建的Cleaner对象来回收。

System.gc回收

在DirectBuffer的构造函数中,用到了Bit.reserveMemory这个方法,该方法如下

static void reserveMemory(long size, int cap) {
        ······
        if (tryReserveMemory(size, cap)) {
            return;
        }
        ······
        while (jlra.tryHandlePendingReference()) {
            if (tryReserveMemory(size, cap)) {
                return;
            }
        }
        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();
            }
        }
    }

reserveMemory方法首先尝试分配内存,如果分配成功的话,那么就直接退出。如果分配失败那么就通过调用tryHandlePendingReference来尝试清理堆外内存(最终调用的是Cleaner的clean方法,其实就是unsafe.freeMemory然后释放内存),清理完内存之后再尝试分配内存。如果还是失败,调用System.gc()来触发一次FullGC进行回收(前提是没有加-XX:-+DisableExplicitGC参数)。

Cleaner对象回收

另个触发堆外内存回收的时机是通过Cleaner对象的clean方法进行回收。在每次新建一个DirectBuffer对象的时候,会同时创建一个Cleaner对象,同一个进程创建的所有的DirectBuffer对象跟Cleaner对象的个数是一样的,并且所有的Cleaner对象会组成一个链表,前后相连。

3. 源码分析 FilChannelImpl的read调用

下面我们来看一下为什么在某些I/O操作下,使用DirectBuffer对比HeapByteBuffer的性能会更好。在FileChannelImpl的read方法中进行read操作的时候,会调用IOUtil.read(this.fd, var1, -1L, this.nd) 的read 方法。

public class FileChannelImpl{
    public int read(ByteBuffer var1) throws IOException {
        this.ensureOpen();
        if (!this.readable) {
            throw new NonReadableChannelException();
        } else {
            Object var2 = this.positionLock;
            synchronized(this.positionLock) {
                int var3 = 0;
                int var4 = -1;

                byte var5;
                try {
                    this.begin();
                    var4 = this.threads.add();
                    if (this.isOpen()) {
                        do {
                            var3 = IOUtil.read(this.fd, var1, -1L, this.nd);
                        } while(var3 == -3 && this.isOpen());

                        int var12 = IOStatus.normalize(var3);
                        return var12;
                    }

                    var5 = 0;
                } finally {
                    this.threads.remove(var4);
                    this.end(var3 > 0);

                    assert IOStatus.check(var3);

                }

                return var5;
            }
        }
    }
}

接下来我们继续跟踪源码,找到为什么I/O操作下,使用DirectBuffer对比HeapByteBuffer的性能会更好。

public class IOUtil{

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

从这段代码中,我们可以看出来如果FileDescriptor操作的ByteBuffer是堆外内存的话,我们直接调用readIntoNativeBuffer将文件从内核缓冲区直接读取到DirectBuffer中。但是如果我们操作的是HeapBytebuffer的话,我们首先构造一个新的DirectBuffer,然后调用readIntoNativeBuffer将文件从内核缓冲区直接读取到DirectBuffer中,最后将这个DirectBuffer复制到HeapByteBuffer之中。所以说,如果我们直接使用DirectBuffer,在进行数据读取的话,就不用将构造出来新的DirectBuffer复制到HeapByteBuffer之中。所以对于大文件来说,我们使用DirectBuffer性能更好。

Q:小伙伴可能回文,那为什么操作系统不直接访问Java堆内的内存区域了?

A:这是因为JNI方法访问的内存区域是一个已经确定了的内存区域地址,那么该内存地址指向的是Java堆内内存的话,那么如果在操作系统正在访问这个内存地址的时候,Java在这个时候进行了GC操作,而GC操作会涉及到数据的移动操作[GC经常会进行先标志-整理算法的操作。即,将可回收的空间做标志,然后清空标志位置的内存,然后会进行一个整理,整理就会涉及到对象的移动,移动的目的是为了腾出一块更加完整、连续的内存空间,以容纳更大的新对象],数据的移动会使JNI调用的数据错乱。所以JNI调用的内存是不能进行GC操作的。

Q:如上面所说,JNI调用的内存是不能进行GC操作的,那该如何解决了?

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

  • 5
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 9
    评论
评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值