Java堆外内存解析:为什么Java8用元空间替代永久代及其内存管理机制

一、前言

Java堆外内存解析:为什么Java8用元空间替代永久代及其内存管理机制

平时编程时,在 Java 里创建对象,会在堆上划分出一块区域,而这块区域被称作堆内内存。

剖开 JVM 内存模型,来看下其堆划分:

从图中能够看出,Java8 采用了元空间来替代永久代,并且元空间是放置在堆外内存上的。这其中的缘由是什么呢?

类的元数据信息经常被用到,在进行 GC 时回收效率较低。类的元数据信息不太容易确定其大小,若指定得太小,就容易出现永久代溢出的情况;若指定得太大,就容易造成老年代溢出。

那什么是堆外内存?

堆外内存和堆内内存是相对应的。在整个机器内存当中,除去堆内内存的那部分就是堆外内存。

Java 程序通常会利用 -XX:MaxDirectMemorySize 这个参数去对最大堆外内存进行限制。

还有个问题:堆外内存属于用户空间还是内核空间? 用户空间。

(1)为什么需要堆外内存?

使用堆外内存,有这些好处:

直接使用堆外内存能减少一次内存拷贝。在进行网络 I/O 操作以及文件读写时,堆内内存需要转换为堆外内存,之后才能与底层设备进行交互。这样可以降低 JVM GC 对应用程序的影响,因为堆外内存不受 JVM 管理。并且堆外内存能够实现进程之间以及 JVM 多实例之间的数据共享。

那我就有个问题:为什么使用堆外内存可以减少一次内存拷贝呢?

原因:在进行网络 I/O 操作或者文件读写的时候,如果运用堆内内存(HeapByteBuffer),那么 JDK 会首先创建一个堆外内存(DirectBuffer),接着去进行真正的读写操作。

具体原因在于:要调用底层系统函数,比如 write 函数和 read 函数等,就必须要求所使用的地址空间是连续的。

操作系统不会感知 JVM 的堆内存。JVM 的内存布局与操作系统所分配的不同。操作系统不会依照 JVM 的行为来读写数据。同一个对象的内存地址会因 JVM GC 的执行而随时改变,比如在 JVM GC 过程中,会通过压缩来减少内存碎片,这就涉及到对象移动的问题。

当然使用堆外内存,有这些弊端:

排查内存泄漏问题有一定难度。堆外内存需要手动释放,若不熟悉对应框架源码,稍不留意就可能导致应用程序出现内存泄漏情况。这对开发人员的基础技能要求较高。

由此可以看出,若要实现高效的 I/O 操作,堆外内存是个不错选择;若要缓存常用对象,堆外内存是个不错选择;若要降低 JVM GC 压力,堆外内存是个不错选择。

(2)如何分配堆外内存?

Java 中堆外内存的分配方式有两种:

NIO 类里的 ByteBuffer 有 allocateDirectUnsafe 方法,该方法用于 allocateMemory

首先来看 Java NIO 包中 ByteBuffer 类的分配方式,其使用方式如下:

// 分配 10M 堆外内存
使用 ByteBuffer.allocateDirect 方法分配一个容量为 10 乘以 1024 乘以 1024 的直接缓冲区,赋值给 byteBuffer 变量。
// 释放堆外内存
((DirectBuffer) byteBuffer).获取 cleaner() 方法的返回值并调用 clean() 方法;

跟进 ByteBuffer.allocateDirect 的源码,我们可以发现其中直接调用了 DirectByteBuffer 的构造函数。

DirectByteBuffer(int cap) { 
    super(-1, 0, cap, cap);
VM 的直接内存页是否对齐可以通过 boolean 类型的 pa 来表示。
    int ps = Bits.pageSize();
首先获取 1L 与 cap 加上根据 pa 条件判断的 ps 或 0 的和的较大值,将其赋值给 long size ,即 long size 等于 Math 类中的 max 方法的返回值,该返回值是 1L 与 cap 加上根据 pa 条件判断的 ps 或 0 的和的较大值。
Bits 会预留指定大小 size 和容量 cap 的内存,并且在此过程中会调用 System.gc() 方法。
    long base = 0;
    try {
        // 1\. 真正分配堆外内存
使用 unsafe 分配内存,其大小为 size,分配的内存存储在 base 中。
当捕获到 OutOfMemoryError 异常时,执行以下操作:
Bits 进行释放内存的操作,释放的内存大小为 size,同时容量为 cap 。
        throw x;
    }
unsafe 对内存进行设置操作,将指定的基地址 base 处的大小为 size 的内存区域设置为字节值 (byte) 0 。
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
地址等于基值加上部分值再减去基值与部分值减 1 的结果的与运算;
    } else {
        address = base;
    }
    // 2\. 用于回收堆外内存
Cleaner 是通过调用 Cleaner.create 方法创建的,该方法的参数为 this、一个新的 Deallocator 实例,其参数为 base、size 和 cap 。
    att = null;
}

DirectByteBuffer 对象存放在堆内存中,它仅包含堆外内存的相关属性,如地址、大小等。并且会创建对应的 Cleaner 对象。由 ByteBuffer 分配的堆外内存无需手动进行回收,它能够被 JVM 自动回收。

堆内的 DirectByteBuffer 对象被 GC 回收的时候,Cleaner 会被用来回收与之对应的堆外内存。

真正分配堆外内存的逻辑是通过 unsafe.allocateMemory 来实现的,并且分配的内存大小为 size 。

Unsafe 是一个安全性极差的类,它被用于进行内存访问、分配以及修改等较为敏感的操作,能够突破 JVM 所设置的限制。Unsafe 一开始并非是为开发者而设计的,在使用它的时候,虽然能够获得对底层资源的掌控权,然而与此同时也丧失了安全性方面的保障,因此,使用 Unsafe 必须要格外谨慎。

在 Java 里无法直接去使用 Unsafe 。不过能够借助反射来获取 Unsafe 的实例,其使用的方式呈现如下:

静态私有变量 unsafe 初始值为 null 。
static {
    try {
Unsafe 类中的一个字段被获取到,这个字段名为 getUnsafe,并且通过 Unsafe.class.getDeclaredField("theUnsafe") 来获取该字段。
调用 getUnsafe 的 setAccessible 方法并将其参数设置为 true 。
unsafe 等于 (Unsafe) getUnsafe.get(null) ;获取 Unsafe 的 getUnsafe 方法并传入 null 后,将其结果转换为 Unsafe 类型赋值给 unsafe 。
如果捕获到 NoSuchFieldException 或者 IllegalAccessException 异常,就执行以下操作。
        e.printStackTrace();
    }
}

获得 Unsafe 实例之后,能够利用 allocateMemory 方法来分配堆外内存,allocateMemory 方法所返回的是内存地址,其使用方法如下所示:

// 分配 10M 堆外内存
unsafe.allocateMemory 函数被用于为 long address 分配 10 * 1024 * 1024 大小的内存。
Unsafe#allocateMemory 分配的内存需要自己手动去释放,不然就会导致内存泄漏。
// 这也是 Unsafe 不安全的体现。
unsafe.freeMemory(address);

(3)如何回收堆外内存?

堆外内存回收,有两种方式:

Full GC 时以及调用 System.gc()的情况如下:通过 JVM 参数 -XX:MaxDirectMemorySize 来指定堆外内存的上限大小。当堆外内存的大小超过这个阈值时,就会触发一次 Full GC 以进行清理回收。如果在 Full GC 之后依然无法满足堆外内存的分配,那么程序就会抛出 OOM 异常。DirectByteBuffer 在初始化时会创建一个 Cleaner 对象,这个 Cleaner 内会创建 Deallocator。可以使用 unsafe.freeMemory(address); 来回收,即调用 Deallocator#run() 来进行回收。同时,System.gc() 会触发这一回收过程。

那就有个问题,什么时候会触发 System.gc() ?

在 ByteBuffer.allocateDirect 进行分配的过程里:倘若没有足够的空间来分配堆外内存,那么在 Bits.reserveMemory 方法中,也会主动去调用 System.gc(),如此就会引发 Full GC(并非立即执行)。

ByteBuffer 调用 allocateDirect 方法后会直接去调用 DirectByteBuffer 的构造函数。
DirectByteBuffer(int cap) { 
    ... 
    Bits.reserveMemory(size, cap);  // 注意这里会调用 System.gc();
    ...
}

如果环境中设置了某个特定的参数 -XX:+DisableExplicitGC,那么 System.gc() 将不会起作用。

所以依赖 System.gc() 并不是一个好办法。

2)Cleaner对象

前面介绍了堆外内存分配方式。我们知道,DirectByteBuffer 在初始化时会创建一个 Cleaner 对象,这个 Cleaner 对象负责堆外内存的回收工作。那么,Cleaner 是怎样与 GC 关联起来的呢?

先来看下 Cleaner 的源码:

公共类 Cleaner 继承自 java.lang.ref.PhantomReference 。 {
静态私有常量引用队列。dummyQueue 是一个新的引用队列。<>();
    // 双向链表
静态的 sun.misc.Cleaner 是第一个。
私有 sun.misc.Cleaner 指向下一个;
一个私有 sun.misc.Cleaner 被赋值给 prev;
这是一个私有最终的 Java 语言的 Runnable 类型的 thunk。 它是一个具有特定功能的可运行对象。 这个 thunk 被声明为私有且最终的,意味着它在类的内部被定义,并且其值不能被改变。 它通常用于在特定的上下文中执行特定的任务或代码块。
    public void clean() {
如果不能移除(这个),即不能把自己从链表上移除。  // 这里使用“即”来连接两个分句,使意思更清晰。
            return;
        try {
thunk 是 Deallocator ,然后执行 thunk.run() ;
        } catch (final Throwable x) {
           // ... ...
        }
    }
}

可以看到,Cleaner 是 PhantomReference 的子类。那么,Cleaner#clean()的执行是否与 JVM GC 有关呢?是否与 Reference 有关呢?

Java 对象存在着四种引用的方式,分别是强引用 StrongReference,还有软引用 SoftReference,以及弱引用 WeakReference,另外还有虚引用 PhantomReference。

这里先了解下 Reference 核心处理流程:

JVM 的垃圾收集器扫描到了对象 O 是可回收的。接着将对象 O 所对应的 Reference 实例 R 加入到了 PendingReference 链表里。然后通知了 ReferenceHandler 线程去进行处理。最后完成了清理的逻辑。

下面是其源码:

// Reference.java, 部分代码省略
Reference 与抽象类的紧密关系具体体现; {
    static {
创建了一个名为 Thread handler 的对象,该对象是一个 ReferenceHandler 类型的实例,它的参数 tg 和 "Reference Handler" 分别用于初始化该对象。
handler 将线程的优先级设置为 Thread.MAX_PRIORITY 。
        handler.setDaemon(true);
        handler.start();
    }
静态内部类 ReferenceHandler 继承自 Thread 类。
        public void run() {
            while (true) {
                tryHandlePending(true);
            }
        }
    }
静态的布尔值 tryHandlePending ,其参数为 waitForNotify 。判断是否能够尝试处理待处理的情况。如果 waitForNotify 为真,就等待通知;如果为假,就不等待通知。最后返回一个布尔值,表示是否成功处理了待处理的情况。
        Reference r;
        Cleaner c;
        try {
            synchronized (lock) {
                if (pending != null) {
                    r = pending;
                    // 判断是否为 Cleaner
如果 r 是 Cleaner 的实例,那么 c 就等于 (Cleaner) r,否则 c 等于 null。
从“pending”链中解除“r”的关联。
                    pending = r.discovered;
                    r.discovered = null;
                } else {
                    // ... ...
                }
            }
当捕获到 OutOfMemoryError 异常时,会执行以下操作:
            // 等待CG后的通知
            // ... ...
}在捕获到 InterruptedException 异常时,将其赋值给 x 变量。
            // ... ...
        }
如果是 Cleaner,就调用 Cleaner 的 clean() 方法。
        if (c != null) {
            c.clean();
            return true;
        }
        ReferenceQueue q = r.queue;
如果 q 不等于 ReferenceQueue.NULL,那么就将 r 加入到 q 中。
        return true;
    }
}

总结一下:当 DirectByteBuffer 进入回收流程时,会调用 Cleaner 的 clean() 方法,以此来释放堆外内存。

拓展:Netty的noCleaner策略

Netty 在分配堆外内存时,有不带 Cleaner 的相关方法。

UnpooledByteBufAllocator 会创建一个新的直接缓冲区。
会创建一种 InstrumentedUnpooledUnsafeNoCleanerDirectByteBuf,并且这种 ByteBuf 是不带 Cleaner 的。
创建直接字节缓冲区;使用 UnpooledUnsafeNoCleanerDirectByteBuf 类的 allocateDirect 方法;该方法用于分配直接内存;直接内存分配可提高 I/O 操作的性能;常用于需要高效内存访问的场景。
UnpooledUnsafeNoCleanerDirectByteBuf 释放直接内存;直接释放 UnpooledUnsafeNoCleanerDirectByteBuf 的内存;让 UnpooledUnsafeNoCleanerDirectByteBuf 释放其直接内存;UnpooledUnsafeNoCleanerDirectByteBuf 进行直接内存的释放操作;UnpooledUnsafeNoCleanerDirectByteBuf 负责释放直接内存。

Tips:-XX:MaxDirectMemorySize 不能够对 Netty 里采用 noCleaner 策略的 DirectByteBuffer(堆外内存)的大小进行限制。

需要使用:

-Dio.netty.maxDirectMemory:

二、案例 堆外内存泄漏(1)模拟堆外内存泄漏

模拟堆外内存泄漏,设置堆外内存大小 10MB,代码如下:

public class Test {
-Xmx10M,-XX:MaxDirectMemorySize 为 10M,同时 -Xloggc:gc.log
静态私有常量 _10MB 等于 10 乘以 1024 再乘以 1024 。
公共静态的 void 主函数(String 类型的数组 args)抛出异常。
        List list = new ArrayList<>();
        // 分配 20MB
语言风格:保持原文风格,去掉最前面的序号,
        list.add(ByteBuffer.allocateDirect(_10MB));
    }
}

在 IDEA 里需要设置下 JVM 参数:

运行果如下:

gc.log 日志如下:

OpenJDK 64 位服务器虚拟机(25.162 - b12),适用于 linux - amd64 JRE(1.8.0_162 - 8u162 - b12 - 1 - b12),于 2018 年 3 月 15 日 17 时 19 分 50 秒构建,由“buildd”使用 gcc 7.3.0 构建
CommandLine flags: 有 -XX:InitialHeapSize 被设置为 10485760;有 -XX:MaxDirectMemorySize 被设置为 10485760;有 -XX:MaxHeapSize 被设置为 10485760;有 -XX:+PrintGC;有 -XX:+PrintGCTimeStamps;有 -XX:+UseCompressedClassPointers;有 -XX:+UseCompressedOops;有 -XX:+UseParallelGC
0.093 表示:发生了垃圾回收(GC),原因是分配失败,回收前占用 2048K 内存,回收后占用 701K 内存,堆内存总大小为 9728K,此次回收耗时 0.0020135 秒
[GC (System.gc()) 进行了操作,内存从 2147K 减少到 857K,总内存为 9728K,花费了 0.0039815 秒]
Full GC(System.gc())时,内存从 857K 变为 663K(总内存为 9728K),耗时 0.0069431 秒。

可以看到,当分配堆外内存失败时,会调用 System.gc()。并且,在调用 System.gc()之后,会触发 Full GC。

运行上面代码同时,观察 Linux 中所占内存情况:

# 1\. 先找到应用程序对应的 PID
$ jps
# 2\. top 观察
$ top | grep 25131

发现应用程序所占内存(RES)大约为 40MB,这个数值远远超过了堆内内存的 10MB 以及堆外内存的 10MB。

为何不用 unsafe.allocateMemory()去模拟分配内存呢?

Unsafe.allocateMemory()是对系统调用的 os::malloc 的一个包装。它没有关注 VM 所要求的内存限制。因此,它会绕过 MaxDirectMemorySize 的限制。

可能会写这样的代码:

public class Test {
    private static final int _10MB = 10 * 1024 * 1024;
    public static void main(String[] args) throws Exception {
Unsafe 类的声明字段中,有一个字段被定义为 unsafeField ,它是通过 Unsafe.class.getDeclaredFields()[0] 获取到的。
设置 unsafeField 可访问,将其 accessible 属性设置为 true 。
Unsafe 被赋值给一个名为 unsafe 的变量,这个赋值操作是通过获取名为 unsafeField 的字段并传入 null 来实现的,即 unsafe = (Unsafe) unsafeField.get(null);
while 处于真状态时,会使机子直接陷入卡死状态,并且会一直持续到内存被耗尽。
            unsafe.allocateMemory(_10MB);
        }
    }
}
在主线程中出现了一个异常,即 Java 内存溢出错误。
在 sun.misc.Unsafe 中通过 Native Method 分配内存。
在 org.fenixsoft.oom.DMOOM 这个类的 main 方法中,位于 DMOOM.java 文件的第 20 行。

在主线程中抛出了这个异常。

Java.lang.OutOfMemoryError 这种情况才会 kill 进程,并且这个过程中机子会卡死,只有内存溢出时才会出现这种情况。

为什么使用 ByteBuffer.allocateDirect() 就不会有 unsafe 方面的问题呢?

每次分配内存时,都会对进程的内存占用情况进行检查,并且会抛出异常。对应的代码是 Bits.reserveMemory(size, cap)。

DirectByteBuffer(int cap) { 
    ...
    // 进行检测
    Bits.reserveMemory(size, cap);  // 注意这里会调用 System.gc();
    ... 
}

所以使用 ByteBuffer.allocateDirect() 这种方式相对来说更为安全。

(2)美团堆外内存泄漏

WebSocket 断开连接后存在无法正常释放内存的情况,而添加 Packet packet = new Packet(PacketType.MSSAGE) 之后,框架就能够正常识别并释放内存了。

他的排查问题步骤,总结如下:

收到监控告警后,前往监控平台 CAT 查看整个集群的各项指标。猜测可能出现问题的地方,然后去 Review 代码。硬着头皮查看日志文件,查看对应堆栈信息。采取手段,在代码中打点日志来进一步监控(注意:这里直接修改生产代码,查看生产日志)。进行线下模拟,复现场景,线下验证。最后进行线上验证。

你可能想看:
« 上一篇
下一篇 »

相关推荐

java中什么是冒泡,冒泡算法

2026年09月06日

27阅读

java中冒号,java双冒号

2026年09月06日

26阅读

java中的->,java中的是什么意思

2026年09月06日

24阅读

java中的super用法,Java中super的说明

2026年09月06日

28阅读

java中反射的应用,java中反射的应用场景

2026年09月06日

26阅读

java中开关的定义,Java关键字有哪些

2026年09月06日

26阅读