《Java 虚拟机原理与性能调优》7.3 元空间与直接内存:类生成与堆外泄漏

堆没满、进程却一直涨,问题常在 -Xmx 之外。本节用 Byte Buddy 1.18.16 生成类,在 64 MB 元空间上限下复现真实 Metaspace OOM;再用 32 MB 直接内存上限复现 Cannot reserve 报错,并给出 VM.metaspace、NMT 与插件热重载的排查路径。

本节目标:把「堆没满但进程在涨」拆成元空间和直接内存两条线,并用上限参数逼出真实 OOM。
适用版本:JDK 25(Temurin 25.0.5+8);Byte Buddy 1.18.16;-XX:MaxMetaspaceSize、-XX:MaxDirectMemorySize。

承接的问题

7.1 和 7.2 都在堆里找答案。但线上还有一类内存问题:heap dump 看起来很正常,老年代稳定,GC.heap_info 的 used 也不高,进程 RSS 却从 1 GB 涨到 3 GB,最后被容器 OOM Kill。这类问题通常落在两个账本上:

  • 元空间(Metaspace):类的元数据、方法字节码、常量池、注解等。类加载得越多、类加载器越多,它越大。
  • 直接内存(Direct Memory):ByteBuffer.allocateDirect、NIO、部分 JDBC 驱动和序列化库使用的堆外缓冲区。它不受 -Xmx 限制,受 -XX:MaxDirectMemorySize 和物理内存限制。

libraryd 两处都可能踩到:插件热重载每次换一个新 ClassLoader,旧加载器如果没释放,类就留在元空间;报表导出如果每个请求都 allocateDirect,直接内存会随并发线性增长。

这一节先用两个可控实验把两种 OOM 逼出来,再看 jcmd 和 NMT 怎么读,最后落到 PluginRegistry 的修法。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,默认 G1。

元空间:类元数据、压缩类空间与卸载

类从加载到卸载要经过一条完整的链:加载 → 链接 → 初始化 → 使用 → 卸载。卸载的条件很苛刻:这个类对应的 ClassLoader 必须不可达,同时它的 Class 对象也不可达。只要还有一处强引用,类就留在元空间。

本机默认开启了压缩类指针:

java -XX:+PrintFlagsFinal -version 2>&1 | grep -E 'UseCompressedClassPointers|MaxMetaspaceSize'

本机输出:

   size_t MaxMetaspaceSize      = 18446744073709551615  {product} {default}
    bool UseCompressedClassPointers = true              {product lp64_product} {default}

MaxMetaspaceSize 的默认值是一个无符号 64 位最大值,等于「不设上限」。元空间不够时会一直向操作系统要内存,直到物理内存或容器限制把它杀掉。这就是为什么「堆没满但进程被杀」经常和元空间有关。

卸载可以用统一日志直接观察。下面这个实验用 Byte Buddy 生成 200 个类,每个类放在独立的 ClassLoader 里,然后清空引用并 GC:

import java.util.ArrayList;
import java.util.List;
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.dynamic.loading.ClassLoadingStrategy;

public class UnloadDemo {
    public static void main(String[] args) throws Exception {
        ByteBuddy buddy = new ByteBuddy();
        List<Class<?>> classes = new ArrayList<>();
        for (int i = 0; i < 200; i++) {
            classes.add(buddy.subclass(Object.class)
                    .name("lab.plugin.Temp" + i)
                    .make()
                    .load(UnloadDemo.class.getClassLoader(), ClassLoadingStrategy.Default.WRAPPER)
                    .getLoaded());
        }
        System.out.println("loaded=" + classes.size());
        classes.clear();
        for (int i = 0; i < 4; i++) {
            System.gc();
            Thread.sleep(100);
        }
        System.out.println("cleared");
    }
}

WRAPPER 策略会为每个类创建一个新的 ByteArrayClassLoader。清空 classes 后,这些加载器没有其他强引用,类就可以卸载。用统一日志观察:

java "-Xlog:class+unload=info:file=/tmp/vol4-d/meta/unload.log" \
     -cp "target/classes:$HOME/.m2/repository/net/bytebuddy/byte-buddy/1.18.16/byte-buddy-1.18.16.jar" \
     lab.UnloadDemo

本机输出(日志节选):

[0.286s][info][class,unload] unloading class lab.plugin.Temp199 0x00001f8001139800
[0.287s][info][class,unload] unloading class lab.plugin.Temp0 0x00001f80010d1c00

日志里一共 200 行 unloading class,与生成的 200 个类一一对应。这说明「类卸载」不是抽象概念,只要加载器不可达,GC 就会把类元数据还回去。

再看一个仍然活着的进程。用同一个 Byte Buddy 工程生成 6000 个类并保持引用,然后查元空间:

jcmd <pid> VM.metaspace

本机输出(节选):

Metaspace        used 10718K, committed 41408K, reserved 131072K
 class space     used 3795K, committed 12736K, reserved 65536K

Total Usage - 6039 loaders, 7952 classes (990 shared):
  Non-Class: 6165 chunks, 30.41 MB capacity, 27.97 MB (92%) committed, 6.76 MB (22%) used
      Class: 6075 chunks, 12.56 MB capacity, 12.44 MB (>99%) committed, 3.71 MB (30%) used

几个读法:

  • used 是真正被类元数据占用的部分,committed 是已经向操作系统要来的内存,reserved 是虚拟地址空间。三者依次变大,reserved 大不代表真的占了物理内存。
  • class space 是压缩类指针使用的区域,和 UseCompressedClassPointers 对应。它单独统计,排查时不要只看上面的 Metaspace 行。
  • 6039 loaders, 7952 classes:6000 个生成类各自带一个加载器,再加上 JVM 自身的加载器。加载器数量本身就是一条线索,热重载泄漏时它会持续上涨。

用 Byte Buddy 把 Metaspace 打满

把上限设成 64 MB,生成类直到失败:

java -Xmx256m -XX:MaxMetaspaceSize=64m \
     -cp "target/classes:$HOME/.m2/repository/net/bytebuddy/byte-buddy/1.18.16/byte-buddy-1.18.16.jar" \
     lab.MetaLeak 20000

生成循环每 1000 个打印一次,本机输出:

generated=10001
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
	at java.base/java.lang.ClassLoader.defineClass1(Native Method)
	at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:962)
	at net.bytebuddy.dynamic.loading.ByteArrayClassLoader.access$300(ByteArrayClassLoader.java:66)
	at net.bytebuddy.dynamic.loading.ByteArrayClassLoader$ClassDefinitionAction.run(ByteArrayClassLoader.java:732)
	at net.bytebuddy.dynamic.loading.ByteArrayClassLoader.findClass(ByteArrayClassLoader.java:451)
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:557)
	at net.bytebuddy.dynamic.loading.ClassLoadingStrategy$Default$WrappingDispatcher.load(ClassLoadingStrategy.java:390)
	at lab.MetaLeak.main(MetaLeak.java:18)

大约 1 万个类时元空间耗尽。每个类的元数据大小和类结构有关,这个数字不是通用阈值;有意义的是错误类型和栈顶:java.lang.OutOfMemoryError: Metaspace,栈顶在 ClassLoader.defineClass1,说明类加载器在定义类时拿不到元空间。

注意 -XX:MaxMetaspaceSize=64m 限制的是 committed,不是 reserved。同一个进程在 6000 个类时,VM.metaspace 显示 reserved 131072K,但 committed 只有 41408K。上限到了以后,JVM 不会再扩大 committed,而是在下一次定义类时抛 OOM。

这也解释了为什么「元空间 OOM」常常来得突然:堆还有空间,GC 日志也正常,但类加载路径已经到顶。监控要看 Metaspace 的 committed 和加载器数量,不能只看堆。

直接内存:不受 -Xmx 管的另一本账

直接内存的入口通常是 ByteBuffer.allocateDirect。它分配的缓冲区在堆外,堆内的 DirectByteBuffer 对象只是一个壳。壳被 GC 回收时,Cleaner 会释放堆外内存;但释放时机由 GC 决定,不是 close()。

用 32 MB 上限跑一个循环:

import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

public class DirectLeak {
    public static void main(String[] args) {
        List<ByteBuffer> buffers = new ArrayList<>();
        try {
            for (int i = 0; i < 20; i++) {
                buffers.add(ByteBuffer.allocateDirect(4 * 1024 * 1024));
                System.out.println("allocated=" + (i + 1) + " " + directPool());
            }
        } catch (OutOfMemoryError e) {
            System.out.println("caught: " + e.getMessage());
            System.out.println("kept=" + buffers.size() + " " + directPool());
        }
    }

    static String directPool() {
        for (BufferPoolMXBean pool : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
            if (pool.getName().equals("direct")) {
                return "pool{count=" + pool.getCount() + " used=" + pool.getMemoryUsed() + "}";
            }
        }
        return "no direct pool";
    }
}
javac --release 25 -d out DirectLeak.java
java -XX:MaxDirectMemorySize=32m -cp out DirectLeak

本机输出:

allocated=1 pool{count=1 used=4194304}
allocated=2 pool{count=2 used=8388608}
allocated=3 pool{count=3 used=12582912}
allocated=4 pool{count=4 used=16777216}
allocated=5 pool{count=5 used=20971520}
allocated=6 pool{count=6 used=25165824}
allocated=7 pool{count=7 used=29360128}
allocated=8 pool{count=8 used=33554432}
caught: Cannot reserve 4194304 bytes of direct buffer memory (allocated: 33554432, limit: 33554432)
kept=8 pool{count=8 used=33554432}

报错原文是 Cannot reserve 4194304 bytes of direct buffer memory (allocated: 33554432, limit: 33554432)。allocated 和 limit 都等于 32 MB,说明第 9 次 4 MB 分配被上限挡住,前 8 个缓冲区仍然被 buffers 强引用,不会被 Cleaner 释放。

默认值需要单独确认:

java -XX:+PrintFlagsFinal -version 2>&1 | grep MaxDirectMemorySize

本机输出:

 uint64_t MaxDirectMemorySize = 0  {product} {default}

0 不是「禁止直接内存」,而是「等于最大堆」。本机 MaxHeapSize 是 8 GB,所以默认直接内存上限也是 8 GB 量级。容器里如果只设了 -Xmx 而没有设 -XX:MaxDirectMemorySize,直接内存可能远大于堆,最终由容器内存限制兜底。

NMT 可以把堆外的账拆开,但必须在启动时开启:

java -Xmx256m -XX:MaxMetaspaceSize=64m -XX:NativeMemoryTracking=summary \
     -cp ... lab.MetaLeak 6000
jcmd <pid> VM.native_memory summary

本机输出(节选):

Total: reserved=801978KB, committed=193770KB
-                 Java Heap (reserved=262144KB, committed=46080KB)
-                     Class (reserved=76203KB, committed=23403KB)
-                 Metaspace (reserved=66404KB, committed=29540KB)
-                    Thread (reserved=55372KB, committed=668KB)
-                      Code (reserved=251907KB, committed=10739KB)
-                        GC (reserved=56924KB, committed=52780KB)

Total 是进程级保留/提交,Java Heap、Class、Metaspace、Thread、Code、GC 各占一块。直接内存在这个视图里通常归到 Other 或 Internal,不会单独列成 Direct;所以排查直接内存时,BufferPoolMXBean 的 used 比 NMT 更直接。NMT 的价值是告诉你「除了堆,进程还把内存花在哪」。

忘记加 -XX:NativeMemoryTracking=summary 时,jcmd VM.native_memory summary 只会提示未开启,不会给出数字。NMT 本身有开销,生产环境一般用 summary,不要用 detail。

在 libraryd 里落地

插件热重载是元空间泄漏的高发区。PluginRegistry 的关键不是「怎么加载新版本」,而是「旧版本怎么退场」:

package com.plume.library.web;

import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public final class PluginRegistry implements AutoCloseable {

    private final Map<String, URLClassLoader> loaders = new ConcurrentHashMap<>();

    public Class<?> reload(String plugin, Path jar) throws Exception {
        URLClassLoader next = new URLClassLoader(
                new URL[]{jar.toUri().toURL()},
                PluginRegistry.class.getClassLoader());
        URLClassLoader old = loaders.put(plugin, next);
        if (old != null) {
            old.close();          // 少了这一行,旧 ClassLoader 和它的类一直留在 Metaspace
        }
        return Class.forName("com.plume.plugin." + plugin + ".EntryPoint", true, next);
    }

    @Override
    public void close() throws IOException {
        for (URLClassLoader loader : loaders.values()) {
            loader.close();
        }
        loaders.clear();
    }
}

close() 只是关闭 jar 文件句柄,不会让加载器立刻不可达。真正让旧类卸载的是:loaders 里不再有旧加载器的引用,且插件类没有把自身泄漏到父加载器、线程、ThreadLocal、JDBC DriverManager 或静态注册表里。常见泄漏路径有四条:

  • 插件把 Class 或实例注册到父加载器的静态 Map,父加载器活多久,插件类就活多久。
  • 插件启动的线程没有停止,线程的 contextClassLoader 指向插件加载器。
  • 插件注册了 JDBC 驱动或 JNI 回调,注册表持有类引用。
  • 旧加载器没有 close(),jar 文件句柄和类元数据都留着。

启动参数要同时给两条线上限:

java -Xmx2g \
     -XX:MaxMetaspaceSize=256m \
     -XX:MaxDirectMemorySize=256m \
     -XX:NativeMemoryTracking=summary \
     -jar libraryd-app.jar

MaxMetaspaceSize=256m 让元空间泄漏在可控范围内失败,而不是拖垮整台机器;MaxDirectMemorySize=256m 让直接内存有明确上限。两个值都要小于容器内存减去堆、线程栈、代码缓存后的余量,不能各拍一个数。

报表导出这类场景,不要每个请求 allocateDirect。按工作线程复用固定大小的缓冲区,或者用 Arena 管理生命周期:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment buffer = arena.allocate(4 * 1024 * 1024);
    exporter.write(buffer);
}   // 离开作用域即释放,不依赖 GC

Arena 是 FFM 的确定性释放方式,适合「一次导出、用完即还」的缓冲区。它和 ByteBuffer.allocateDirect 的差别是:前者在 close() 时释放,后者要等 Cleaner。高并发下这个差别就是「内存曲线平滑」和「锯齿状冲高」的差别。

常见错误与边界

错误一:只调 -Xmx。 元空间和直接内存都不在堆里。堆加到 8 GB,元空间仍然可能因为加载器泄漏涨到几个 GB,直接内存仍然可能被 allocateDirect 打满。

错误二:热重载只换 Map 里的加载器,不 close() 旧的。 jar 文件句柄、类元数据和加载器本身都会留下。每次重载都新增一份,VM.metaspace 的 loaders 数量会持续上涨。

错误三:以为 System.gc() 能及时释放直接内存。 默认只是建议,-XX:+DisableExplicitGC 会忽略;即使执行了,也要等 DirectByteBuffer 对象被回收、Cleaner 运行。不要用 GC 做资源管理。

错误四:把 MaxMetaspaceSize 设得过小。 64 MB 在本机大约能装 1 万个简单生成的类;真实应用有框架、代理、JSP/EL、字节码增强,类元数据更大。设得太小会在启动或首次请求时直接 OutOfMemoryError: Metaspace。

边界:NMT 不是默认开启的。 VM.native_memory summary 需要启动时加 -XX:NativeMemoryTracking=summary,运行中再加是无效的。

边界:-Xlog 在 zsh 下要加引号。 -Xlog:class+unload=info:file=... 里的 *、+ 可能被 shell 解释,统一写成 "-Xlog:..." 最稳。

小结

  • 元空间存类元数据,卸载要求 ClassLoader 不可达;本机清空 200 个加载器后,日志出现 200 行 unloading class。
  • MaxMetaspaceSize 默认无上限;设成 64 MB 后,本机约 1 万个生成类触发 java.lang.OutOfMemoryError: Metaspace,栈顶在 defineClass1。
  • VM.metaspace 要同时看 used、committed、reserved 和加载器数量;本机 6000 个类对应 6039 个加载器。
  • 直接内存受 MaxDirectMemorySize 限制;本机 32 MB 上限下第 9 个 4 MB 缓冲区触发 Cannot reserve 4194304 bytes of direct buffer memory。
  • MaxDirectMemorySize=0 表示等于最大堆,不是禁用;容器里要显式设置并与 -Xmx 一起核算。
  • libraryd 的插件热重载必须 close() 旧加载器,并切断父加载器、线程、驱动注册表对插件类的引用。

堆内和堆外的账都对齐了,下一章换到线程:先看线程栈和转储,再拆锁的运行时,最后看虚拟线程如何挂在载体上。

阅读导航:上一节:7.2 内存泄漏定位:增长曲线、直方图与引用链 · 下一节:8.1 线程栈与转储:从 jstack 到 JSON 线程容器 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发