前言

本文旨在记录近期研读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++ 内存的释放分为三个关键阶段:

  1. 类卸载与解绑(GC 触发): GC 扫描到 ClassLoader 死亡,调用 ClassLoaderDataGraph::do_unloading。此时将 ClassLoaderData(简称 CLD)从全局链表中摘除,并将其移动到专门的卸载链表中。
  2. Chunk 级别回收(SpaceManager 析构): 当调用 CLD 的析构函数时,其内部管理的 SpaceManager 被销毁。SpaceManager 将其占用的所有 Metachunk(Metaspace 的分配块单位)统一归还给全局的 ChunkManager(进入自由链表),以供其他活着的 ClassLoader 复用。
  3. OS 级物理卸载(Purge): 在 GC 的最后阶段,调用 Metaspace::purge()。JVM 会遍历底层的 VirtualSpaceList,如果发现某个 VirtualSpaceNode(申请的 OS 虚拟内存页)上的所有 Chunk 都处于空闲状态,则将整块虚拟内存解绑(通过 munmapVirtualFree)真正返还给操作系统。

二、 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 释放。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐