Java堆外内存管理


发布于 2024-10-13 / 40 阅读 / 0 评论 /
解释什么是堆外内存,以及堆外内存的管理机制,特别是回收机制。

1.堆外内存定义

JVM管理堆之外的内存,称为非堆内存,即堆外内存。

换句话说:堆外内存就是把内存对象分配在Java虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是JVM),这样做的结果就是能够在一定程度上减少gc对应用程序造成的影响。

1.1.使用堆外内存的缺点

堆外内存的缺点就是内存难以控制,使用了堆外内存就间接失去了JVM管理内存的可行性,改由自己来管理,当发生内存溢出时排查起来非常困难。

1.2.使用堆外内存的优点

堆外内存有以下优点:

(1)可以扩展至更大的内存空间。比如超过1TB甚至比主存还大的空间;

(2)减少了垃圾回收(因为垃圾回收会暂停其他的工作);

(3)可以在进程间共享,减少JVM间的对象复制,使得JVM的分割部署更容易实现(堆内在flush到远程时,会先复制到直接内存(非堆内存),然后在发送;而堆外内存相当于省略掉了这个工作);

(4)它的持久化存储可以支持快速重启,同时还能够在测试环境中重现生产数据;

站在系统设计的角度来看,使用堆外内存可以为你的设计提供更多可能。最重要的提升并不在于性能,而是决定性的。

2.堆外内存中的对象

Java 虚拟机具有一个由所有线程共享的方法区。方法区属于非堆内存。它存储每个类结构,如运行时常数池、字段和方法数据,以及方法和构造方法的代码。它是在 Java 虚拟机启动时创建的。

方法区在逻辑上属于堆,但JVM实现可以选择不对其进行回收或压缩。与堆类似,方法区的内存不需要是连续空间,因此方法区的大小可以固定,也可以扩大和缩小。

除了方法区外,JVM实现可能需要用于内部处理或优化的内存,这种内存也是非堆内存。例如,JIT 编译器需要内存来存储从 Java 虚拟机代码转换而来的本机代码,从而获得高性能。

3.堆外内存的申请

JDK的ByteBuffer类提供了一个接口allocateDirect(int capacity)进行堆外内存的申请。

    /**
     * Allocates a new direct byte buffer.
     *
     * <p> The new buffer's position will be zero, its limit will be its
     * capacity, its mark will be undefined, each of its elements will be
     * initialized to zero, and its byte order will be
     * {@link ByteOrder#BIG_ENDIAN BIG_ENDIAN}.  Whether or not it has a
     * {@link #hasArray backing array} is unspecified.
     *
     * @param  capacity
     *         The new buffer's capacity, in bytes
     *
     * @return  The new byte buffer
     *
     * @throws  IllegalArgumentException
     *          If the {@code capacity} is a negative integer
     */
    public static ByteBuffer allocateDirect(int capacity) {
        return new DirectByteBuffer(capacity);
    }

底层通过unsafe .allocateMemory(size) 实现,Netty、Mina等框架提供的接口也是基于ByteBuffer封装的。

    DirectByteBuffer(int cap) {                   // package-private

        super(-1, 0, cap, cap, null);
        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;

    }

4.堆外内存的释放

JDK中使用DirectByteBuffer对象来表示堆外内存,每个DirectByteBuffer对象在初始化时,都会创建一个对应的Cleaner对象,这个Cleaner对象会在合适的时候执行unsafe.freeMemory(address),从而回收这块堆外内存。

    public void run() {
        if (address == 0) {
            // Paranoia
            return;
        }
        UNSAFE.freeMemory(address);
        address = 0;
        Bits.unreserveMemory(size, capacity);
    }

5.堆外内存溢出排查方法

一般来说,如果出现堆外内存溢出的情况,那么通过ps命令查看到的进程实际使用的物理内存是非常大的,且远大于XMX配置值,这部分多余的内存即是堆外内存的大小。

在项目中添加-XX:NativeMemoryTracking=detailJVM参数重启项目,使用命令jcmd pid VM.native_memory detail查看到的内存分布

发现命令显示的committed的内存小于物理内存,因为jcmd命令显示的内存包含堆内内存、Code区域、通过unsafe.allocateMemory和DirectByteBuffer申请的内存,但是不包含其他Native Code(C代码)申请的堆外内存。所以猜测是使用Native Code申请内存所导致的问题。

使用了pmap查看内存分布,发现大量的64M的地址;而这些地址空间不在jcmd命令所给出的地址空间里面,基本上就断定就是这些64M的内存所导致。

基本上可以确定是不是Native Code所引起,而Java层面的工具不便于排查此类问题,只能使用系统层面的工具去定位问题。

首先,使用了gperftools去定位问题

然后,使用strace去追踪系统调用

接着,使用GDB去dump可疑内存

再次,项目启动时使用strace去追踪系统调用

最后,使用jstack去查看对应的线程