前言

本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。

metaspace机制之解绑与回收

深入理解 OpenJDK 8 的 Metaspace(元空间)内存管理及其解绑回收机制,是解决生产环境中非堆内存泄漏(Native Memory Leak)以及进行 JVM 高级调优的必备技能。

在 OpenJDK 8 中,Metaspace 的内存分配与回收采用了一种基于 Chunk(块)的网格化管理架构。它并没有采用像 Java 11+(以及 Java 16+ 的 Elastic Metaspace,即 JEP 387)那样高度弹性的细粒度内存页归还策略,其解绑与回收带有明显的“粗粒度”和“类加载器生命周期绑定”特征。

下面我们将结合 OpenJDK 8 源码(核心位于 hotspot/src/share/vm/memory/metaspace.cpp.hpp),深度拆解 Metaspace 内存解绑回收的三个层级,并给出关键源码的详尽注释。


1. Metaspace 内存组织架构核心模型

在理解回收之前,必须先明确底层内存是如何组织的。它们自上而下分为四个主要层次:

组件名称 作用与生命周期管理机制
VirtualSpaceList 全局唯一的虚拟内存空间链表,负责向操作系统申请大块的、连续的虚拟内存页(VirtualSpaceNode)。
VirtualSpaceNode 单个映射的内存节点(默认大小通常为 2MB 的物理/虚拟对齐块)。它是向 OS 执行 mmap/commit 的最小物理边界。
Metachunk VirtualSpaceNode 中切分出来的微型或中型块(分为 Specialized、Small、Medium、Humongous)。这是内存分配给各个类加载器的基本单元。
SpaceManager 关联类加载器。每个 ClassLoaderData 拥有一个 SpaceManager。它负责向全局的 ChunkManager 申请 Metachunk,并在内部给类提供细粒度的 MetaWord 指针分配。

2. Metaspace 内存解绑与回收的三个层级

OpenJDK 8 的元空间垃圾回收并不发生在类被卸载的瞬间,而是发生在整个 ClassLoader 被 GC 回收的时候。内存的回收分为三个完全不同的物理表现层级:

层级一:局部块复用(BlockFreelist)—— 针对 Live ClassLoader

当一个类加载器还活着,但发生类重定义(Enchance/Redefine)或部分元数据失效(如旧的 Method/ConstantPool 被废弃)时,Metaspace 绝对不会将这部分物理内存退还给操作系统,也不会退还给全局 free 列表。

  • 机制:它会将这些碎片化的空闲高水位指针块,收集到当前 SpaceManager 内部的 BlockFreelist 中。
  • 结果:这部分内存只能被当前同一个类加载器后续加载其他类时复用。

层级二:Chunk 级归还(ChunkManager)—— 针对 Dead ClassLoader

当 GC 判定某个 ClassLoader 死亡并清理其对应的 ClassLoaderData 时,会触发 SpaceManager 的析构函数。

  • 机制SpaceManager 将它名下的所有 Metachunk 全部解绑,打包批量归还给全局的 ChunkManager 自由链表。
  • 结果此时依然没有发生 OS 层的内存解绑(Uncommit/Unmap)。这些 Chunk 只是变成了“闲置状态”,可以被其他任意类加载器(如新创建的自定义类加载器)重新申请使用。

层级三:OS 级物理卸载(VirtualSpaceList::purge)—— 真正的物理回收

只有在发生 GC 后的清理阶段,JVM 才会调用 Metaspace::purge()

  • 机制:遍历全局 VirtualSpaceList 中的每一个 VirtualSpaceNode。如果发现某个 Node 内部的所有 Metachunk 全部变为空闲(即没有任何一个 Chunk 正在被任何活着的类加载器使用),该 Node 才会触发物理层面的 os::release_memory
  • 结果:解除虚拟内存映射(Unmap),内存真正返还给操作系统。

3. OpenJDK 8 核心源码深度剖析与加注

以下源码摘自 OpenJDK 8 中 hotspot/src/share/vm/memory/metaspace.cpp 的核心回收逻辑,对其关键链路进行了逐行中文深度剖析注释。

3.1 层级二源码:SpaceManager 析构与 Chunk 归还

当类加载器死亡,SpaceManager 被销毁,负责将当前专属的物理 Chunk 链表归还给全局空闲管理器 ChunkManager

// 文件位置: hotspot/src/share/vm/memory/metaspace.cpp

SpaceManager::~SpaceManager() {
  // 1. 这是一个极其关键的安全锁:扩展锁 (expand_lock)
  // 在对全局 ChunkManager 或 VirtualSpaceList 进行任何增删变更时,必须持有此锁。
  MutexLockerEx cl(SpaceManager::expand_lock(), Mutex::_no_safepoint_check_flag);

  // 2. 统计当前正在被销毁的 SpaceManager 所占用的各种计数器
  if (TraceMetadataChunkAllocation && count_chunks_by_type() > 0) {
    gclog_or_tty->print_cr("~SpaceManager(): " PTR_FORMAT, p2i(this));
    if (WizardMode) {
      this->locked_print_chunks_in_use_on(gclog_or_tty);
    }
  }

  // 3. 核心回收循环:遍历当前 SpaceManager 正在使用的所有 Chunk 类型
  // OpenJDK 8 将 Chunk 分为 SpecializedIndex, SmallIndex, MediumIndex, HumongousIndex
  for (ChunkIndex i = ZeroIndex; i < NumberOfInUseLists; i = next_chunk_index(i)) {
    Metachunk* chunks = chunks_in_use(i);
    
    // 逐个遍历该类型下的所有 Metachunk 节点
    while (chunks != NULL) {
      Metachunk* next = chunks->next();
      
      // 调试及跟踪日志
      if (TraceMetadataChunkAllocation && Verbose) {
        gclog_or_tty->print_cr("~SpaceManager(): returning chunk " PTR_FORMAT, p2i(chunks));
      }
      
      // 4. 【核心动作】将当前的 Metachunk 归还给全局单例的 ChunkManager。
      // 此时,内存块并没有真正交还给操作系统(OS),而是进入了 JVM 内部的全局空闲池,
      // 处于可被其他 ClassLoader 强行复用的状态(减少频繁向操作系统引发 mmap 的系统开销)。
      chunk_manager()->return_chunks(i, chunks);
      
      chunks = next;
    }
    
    // 归还完毕后,清空当前的空间追踪链表头指针
    set_chunks_in_use(i, NULL);
  }

  // 5. 释放内部的块空闲列表(BlockFreelist),即属于该 ClassLoader 内部无法对齐的碎片小块
  if (_block_freelist != NULL) {
    delete _block_freelist;
  }
}


3.2 层级三源码:VirtualSpaceList::purge 物理内存解绑(OS层面)

这是整个 Metaspace 唯一能将内存退还给操作系统的入口。该函数通常在全局垃圾回收(如 Full GC 后的 ClassLoaderDataGraph::do_unloading 阶段)被调用。

// 文件位置: hotspot/src/share/vm/memory/metaspace.cpp

void VirtualSpaceList::purge(ChunkManager* chunk_manager) {
  // 必须确保在安全点或持有空间扩展锁的状态下执行,防止多线程分配导致内存状态不一致
  assert_lock_strong(SpaceManager::expand_lock());

  // 创建一个临时链表头,用于收集所有“完全空闲、准备彻底释放给OS”的 VirtualSpaceNode
  VirtualSpaceNode* purged_nodes = NULL;

  // 1. 从当前链表的头部(_virtual_spaces)开始向后遍历所有已向操作系统申请的 2MB 节点
  VirtualSpaceNode* vsl = current_virtual_space();
  VirtualSpaceNode* prev_vsl = NULL;
  
  while (vsl != NULL) {
    VirtualSpaceNode* next_vsl = vsl->next();
    
    // 2. 【核心判定点】vsl->is_empty()
    // 判定条件极其严苛:该 VirtualSpaceNode 内部切分出的所有 Metachunk 必须无一例外
    // 全数被归还到了 ChunkManager 中,并且完全没有被任何活跃的类加载器引用。
    if (vsl->is_empty()) {
      
      // 3. 满足空闲条件,将其从全局活跃的 VirtualSpaceList 链表中剥离
      if (vsl == current_virtual_space()) {
        // 如果恰好是当前正在作为分配源头的头部节点
        _virtual_spaces = next_vsl;
      } else {
        // 如果是链表中间的节点,切断指针关联
        assert(prev_vsl != NULL, "Sanity check");
        prev_vsl->set_next(next_vsl);
      }
      
      // 4. 将脱离链表的死亡 Node 串联到临时清洗链表 'purged_nodes' 中
      vsl->set_next(purged_nodes);
      purged_nodes = vsl;
    } else {
      // 只要该 Node 内部还剩下一个正在被使用的 Chunk(哪怕只占用了几个字节),该 Node 就无法被释放给OS!
      // 此时保持 prev_vsl 的连续递增指针
      prev_vsl = vsl;
    }
    vsl = next_vsl;
  }

  // 5. 【物理回收阶段】开始真正向操作系统交还内存空间
  while (purged_nodes != NULL) {
    VirtualSpaceNode* purged_node = purged_nodes;
    purged_nodes = purged_node->next();
    
    if (TraceMetadataChunkAllocation && Verbose) {
      gclog_or_tty->print_cr("VirtualSpaceList::purge: removing node " PTR_FORMAT, p2i(purged_node));
    }
    
    // 5a. 必须从全局 ChunkManager 的自由链表中,剔除所有属于当前被解绑 VirtualSpaceNode 范围内的空闲 Chunk。
    // 如果不剔除,全局空闲池里就会存在指向已被释放内存的野指针(Dangling Pointer),会导致严重的 JVM Crash。
    chunk_manager->remove_chunks_in_space(purged_node);
    
    // 5b. 执行物理释放。这里会隐式调用 VirtualSpaceNode 的析构函数
    // 其底层会进一步调用 `os::release_memory(base, size)`。
    // 在 Linux 平台下,这会最终转换为系统调用 `munmap` 或者是带有 `MADV_DONTNEED` 标志的 `madvise`,
    // 将物理内存页真正解绑归还给 Linux 内核。
    delete purged_node;
    
    // 递减全局已保留(Reserved)的元空间物理节点计数器
    _reserved_words -= purged_node->reserved_words();
    _committed_words -= purged_node->committed_words();
  }
}


3.3 操作系统层面底层落地点:VirtualSpaceNode 物理释放

上面 delete purged_node 最终是如何打通到 OS 的?我们顺藤摸瓜来看 VirtualSpaceNode 销毁时的底层依赖:

// 文件位置: hotspot/src/share/vm/memory/metaspace.cpp

VirtualSpaceNode::~VirtualSpaceNode() {
  // 隐式触发其内部封装的底层 _virtual_space 对象的释放
  _virtual_space.release();
  
  // 释放分配在 C-Heap(系统本地堆)上的内存块记录指针
  if (_top != NULL) {
    // 彻底销毁这个高水位追踪记录
  }
}

其内部的 _virtual_space.release() 位于 share/vm/runtime/virtualspace.cpp

void VirtualSpace::release() {
  if (low_boundary() != NULL) {
    // 最终调用系统层面的 OS 内存释放抽象层
    os::release_memory(low_boundary(), high_boundary() - low_boundary());
    _low_boundary  = NULL;
    _high_boundary = NULL;
    // ...
  }
}

系统视角补充: 对于 Linux 系统,os::release_memory 的底层就是标准的 munmap(address, length)。如果配置了 CompressedClassSpaceSize(压缩类空间),则会采用大块的 ReservedSpace 进行预留,对其进行 uncommit 时则常常借由 mprotect(..., PROT_NONE)madvise 来实现页层面的物理置空。


4. 避坑指南

基于上述 OpenJDK 8 源码的剖析,我们可以总结出几个工业级生产环境中高频爆出的 Metaspace 内存故障本质原因:

严重缺陷:高碎片的“木桶短板效应”

VirtualSpaceList::purge 的源码可以清晰看到:只有当一个 2MB 的 VirtualSpaceNode 内部所有的 Chunk 全部为空时,才能将这 2MB 归还给 OS。

致命场景: 如果系统频繁动态创建、销毁自定义 ClassLoader(如大量的 RPC 动态代理、JSON 解析器、Groovy 脚本反射),每个 ClassLoader 死后,它们的 Chunk 都回到了 ChunkManager。但只要某个 2MB 空间内有一小块 Chunk 被一个长生命周期的 ClassLoader(例如 Spring 容器、Tomcat 的 WebappClassLoader)死死占住,整块 2MB 的物理内存就永远无法向操作系统完成解绑(Purge)

这就是为什么在 Java 8 中,即使频繁发生 Full GC,由于类加载器之间的交叉分配,Metaspace 的 RSS(常驻内存)曲线往往只升不降,极易引发 Linux 的 OOM Killer

生产调优核心参数建议

针对 OpenJDK 8 的这一物理特性,进行以下配置可以大幅缓解因碎片化导致的无法解绑回收:

  1. -XX:MetaspaceSize-XX:MaxMetaspaceSize 设置为相同值:防止由于元空间水位频繁触及高点而引发不必要的 Full GC,降低内存分配网格的扰动。
  2. 谨慎对待动态类生成技术:对 Groovy、动态反射等,尽可能使用统一、可复用的类加载器,或者及时强制触发 GC 协助卸载。
  3. 升级 JVM 核心:如果生产环境面临极为苛刻的动态元数据吞吐,建议升级到 Java 11/17+。其采用的 Elastic Metaspace 将分配粒度由原来的大块 Chunk 细化为 64KB 的小页面(Granule),并支持极其高效的任意位置主动 Uncommit 物理内存,彻底解决了 Java 8 这一因为“一个 Chunk 存活导致整块 Node 无法解绑”的架构级顽疾。
Logo

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

更多推荐