揭秘Java世界中metaspace之如何回收死掉的Classloader
本文深入分析了Java 8中ClassLoader死亡后的内存回收过程。当ClassLoader在堆中不可达时,其元空间内存会经历三个阶段回收: GC触发解绑:通过ClassLoaderDataGraph将死亡加载器从全局链表移除,放入卸载列表; Chunk级回收:SpaceManager析构时,将Metachunk归还到全局ChunkManager供复用; OS级释放:GC最后阶段检查完全空闲的
如何回收死掉的Classloader
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
如何回收死掉的Classloader
在 Java 8 中,类的生命周期与它对应的 ClassLoader 深度绑定。元空间(Metaspace)采用了一种面向类的加载器的内存管理机制。当一个非启动类加载器(User-Defined ClassLoader)在 Java 堆中不再可达、且其加载的所有类也都不可达时,它就会在下一次垃圾回收(GC)中被标记为死亡。
一旦 ClassLoader 死亡,其在 JVM Native(C++)层维护的 Metaspace 内存并不会立刻以类似于 free() 或 delete 的方式全部返还给操作系统,而是经历一个“逻辑解绑 → \rightarrow → Chunk级回收 → \rightarrow → OS级物理释放”的链式过程。
一、 整体演进阶段与核心流转架构
当一个 Java 层的 ClassLoader 死亡后,其 C++ 内存的释放分为三个关键阶段:
- 类卸载与解绑(GC 触发): GC 扫描到 ClassLoader 死亡,调用
ClassLoaderDataGraph::do_unloading。此时将ClassLoaderData(简称 CLD)从全局链表中摘除,并将其移动到专门的卸载链表中。 - Chunk 级别回收(SpaceManager 析构): 当调用 CLD 的析构函数时,其内部管理的
SpaceManager被销毁。SpaceManager将其占用的所有Metachunk(Metaspace 的分配块单位)统一归还给全局的ChunkManager(进入自由链表),以供其他活着的 ClassLoader 复用。 - OS 级物理卸载(Purge): 在 GC 的最后阶段,调用
Metaspace::purge()。JVM 会遍历底层的VirtualSpaceList,如果发现某个VirtualSpaceNode(申请的 OS 虚拟内存页)上的所有 Chunk 都处于空闲状态,则将整块虚拟内存解绑(通过munmap或VirtualFree)真正返还给操作系统。
二、 OpenJDK 8核心源码深度剖析与加注
以下结合 OpenJDK 8的HotSpot 虚拟机源码,详细还原这三步的 C++ 内存释放路径,并在关键位置附加中文注释。
阶段一:ClassLoaderDataGraph 的卸载触发
在 GC 的清理(Vacate/Purge)阶段,底层会调用 ClassLoaderDataGraph::do_unloading 来切断死掉的加载器与全局元空间的联系。
源码文件:share/vm/classfile/classLoaderData.cpp
bool ClassLoaderDataGraph::do_unloading(BoolObjectClosure* is_alive, bool clean_alive) {
assert(SafepointSynchronize::is_at_safepoint(), "must be at safepoint");
ClassLoaderData* data = _head;
ClassLoaderData* prev = NULL;
bool Stacey = false;
bool purged_class = false;
// 1. 遍历 HotSpot 中所有 ClassLoaderData 的全局链表
while (data != NULL) {
// 检查这个 ClassLoader 对应的 Java 堆中对象(holder)是否存活
if (data->is_alive(is_alive)) {
if (clean_alive) {
data->classes_do(InstanceKlass::clean_weak_instanceklass_links);
}
prev = data;
data = data->next();
continue;
}
// 2. 走到这里,说明该 ClassLoader 已经在 Java 堆中死掉
purged_class = true;
ClassLoaderData* dead = data;
data = data->next();
// 3. 将其从当前的全局活体 ClassLoaderData 链表中解除挂载(摘除节点)
if (prev == NULL) {
_head = data;
} else {
prev->set_next(data);
}
// 4. 将这个死掉的 C++ 结构体挂载到专用的“卸载链表(Unloading List)”中
dead->set_next(_unloading);
_unloading = dead;
}
// 5. 如果有卸载发生,则在随后的步骤中真正去销毁这些死掉的 C++ 结构
if (purged_class) {
// 彻底释放解绑的 CLD 包含的内存,内部会调用死掉 CLD 的析构函数
purge_unloading();
}
return purged_class;
}
void ClassLoaderDataGraph::purge_unloading() {
assert(SafepointSynchronize::is_at_safepoint(), "must be at safepoint");
ClassLoaderData* list = _unloading;
_unloading = NULL;
while (list != NULL) {
ClassLoaderData* next = list->next();
// 关键点:调用 C++ delete,从而触发 ~ClassLoaderData() 析构函数!
delete list;
list = next;
}
}
阶段二:SpaceManager 析构与 Chunk 归还
当执行 delete list 时,会执行 ~ClassLoaderData()。这个析构函数会销毁该加载器专属的 Metaspace 抽象,并间接销毁 SpaceManager。这一步完成了 C++ 内存从“死加载器独占”向“全局自由链表”的归还。
源码文件:share/vm/memory/metaspace.cpp
// 1. ClassLoaderData 析构时,会连带析构其持有的 Metaspace 容器
Metaspace::~Metaspace() {
delete _vsm; // 析构 专门负责非类元数据的 SpaceManager
if (_class_vsm != NULL) {
delete _class_vsm; // 析构 专门负责类元数据(Klass)的 SpaceManager
}
}
// 2. 进入 SpaceManager 的析构函数,这里是内存块(Chunk)归还的轴心
SpaceManager::~SpaceManager() {
// 加锁:因为归还到全局 ChunkManager 会引发多线程/多 GC 线程并发竞争
MutexLockerEx cl(lock(), Mutex::_no_safepoint_check_flag);
// 释放用户定义类加载器可能残留的局部自由块 BlockFreelist
if (_block_freelist != NULL) {
delete _block_freelist;
}
// 3. 核心机制:将该加载器死前霸占的所有 Metachunk 一并归还给全局
// 遍历当前 SpaceManager 持有的不同尺寸的 Chunk 链表(Small, Medium, Specialized)
for (ChunkIndex i = ZeroIndex; i < NumberOfInUseLists; i = next_chunk_index(i)) {
Metachunk* chunks = chunks_in_use(i);
while (chunks != NULL) {
Metachunk* next = chunks->next();
// 调试用日志,追踪物理 Chunk 的归还
if (TraceMetadataChunkAllocation && Verbose) {
gclog_or_tty->print_snprintf("SpaceManager::~SpaceManager(): 正在归还 Chunk %p 到全局", chunks);
}
// 4. 将此挂载在死加载器名下的 Metachunk 回收到全局 ChunkManager 的自由链表中
// 注意:此时并没有发生 OS 级别的物理内存释放,它只是变成了“闲置状态”,可供其他加载器使用
chunk_manager()->return_chunks(i, chunks);
chunks = next;
}
}
// 清空计数
_chunks_in_use_count = 0;
}
阶段三:OS 级物理内存解绑
虽然 Chunk 已经全部退回到了全局池中,但在 VirtualSpaceList(虚拟内存节点链表)中,依然占用了操作系统的虚拟内存和物理页。最后,通过调用 Metaspace::purge(),HotSpot 会做一次彻底的检查,如果发现某个大内存块(VirtualSpaceNode)里全都是闲置 Chunk,就将其从操作系统中真正卸载。
源码文件:share/vm/memory/metaspace.cpp
void Metaspace::purge() {
MutexLockerEx cl(SpaceManager::expand_lock(), Mutex::_no_safepoint_check_flag);
// 分别对非类区(Non-Class)和类区(Class Space)的全局虚拟内存链表执行清理
_space_list->purge();
if (using_class_space()) {
class_space_list()->purge();
}
}
void VirtualSpaceList::purge() {
// 此时处于 GC 最后的安全点,遍历底层的每一个成员节点(VirtualSpaceNode)
VirtualSpaceNode* prev_vsn = NULL;
VirtualSpaceNode* next_vsn = NULL;
for (VirtualSpaceNode* vsl = current_virtual_space(); vsl != NULL; vsl = next_vsn) {
next_vsn = vsl->next();
// 1. 核心判定:检查该内存节点是否已经是“彻底空闲”
// 判定的标准是:该 Node 内部已使用的内存空间大小(capacity)是否等于 0
if (vsl->is_empty()) {
// 2. 从全局虚拟空间链表中将这个空的 OS 内存节点切断摘除
if (prev_vsn == NULL) {
_virtual_space_list = next_vsn;
} else {
prev_vsn->set_next(next_vsn);
}
if (vsl == _current_virtual_space) {
_current_virtual_space = prev_vsn; // 重置当前分配指针
}
// 3. 释放封装的 C++ 内部结构,并完成向底层操作系统的解绑
vsl->retire(chunk_manager());
vsl->destruct(); // 内部解绑 OS 虚拟内存(OS Native Release)
delete vsl; // 释放 C++ 节点自身内存
_virtual_space_count--;
} else {
prev_vsn = vsl;
}
}
}
// 4. 操作系统层面的落地点
void VirtualSpaceNode::destruct() {
// 检查是否持有了底层的操作系统虚拟映射区
if (virtual_space().is_reserved()) {
// 打印元空间物理释放日志(对应 -XX:+TraceMetaspaceAllocation)
if (TraceMetaspaceLevel3) {
gclog_or_tty->print_cr("解绑 OS 虚拟内存: [" INTPTR_FORMAT ", " INTPTR_FORMAT ")",
p2i(virtual_space().low_boundary()), p2i(virtual_space().high_boundary()));
}
// 最终调用 os::release_memory()。
// 在 Linux 平台底层执行 munmap(),在 Windows 平台执行 VirtualFree()
virtual_space().release();
}
}
三、 避坑指南:为什么 Java 8的Metaspace 经常“不释放内存”?
在实际生产中,我们经常遇到 Java 堆里的 ClassLoader 已经回收了,但进程占用的系统物理内存(RSS)却没有降下来的现象。结合上述源码可以归纳出两个核心原因:
1. 高碎片的“木桶短板效应”
由于 Metaspace 的 OS 级释放是以 VirtualSpaceNode(通常为 2MB 甚至更大)为最小粒度进行的。只要这 2MB 的虚拟内存页中,有哪怕一个 Metachunk 仍然隶属于某个还活着的 ClassLoader,那么整块 2MB 的高位内存就无法通过 purge() 归还给 OS。这种现象称为元空间高碎片化。
2. 操作系统内存管理(GLIBC Malloc Arena)
即便 JVM 层面调用了 munmap 或将内存退回给了 glibc(比如通过 free() 释放一些内部 C++ 辅助结构),glibc 默认也不会立即把物理内存页还给内核(Kernel),而是囤积在自己的 Arena 自由池中以备后续分配,导致在宿主机看来 JVM 进程的 RSS 依旧处于高位。
调优建议
- **
-XX:MetaspaceSize和-XX:MaxMetaspaceSize**:一定要显式设置最大值,防止失控。 -XX:MinMetaspaceFreeRatio:控制 GC 后元空间最小的空闲比例,调大此值会激进地促使 JVM 在 GC 后尝试进行purge释放。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)