本节目标:把「堆没满但进程在涨」拆成元空间和直接内存两条线,并用上限参数逼出真实 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 线程容器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。