SAP 为 Java 16 贡献 JEP 387 “弹性元空间”
类元数据
元空间保存类元数据。这些是什么?
Java 类不仅仅包含 java.lang.Class 堆中的对象。当 JVM 加载一个类时,它会构建一个主要由类文件的预消化部分组成的结构树。这棵树的根是一个大小可变的结构,名为“Klass”(是的,大写的“K”),除其他外,它还包含类 itable 和 vtable。此外,树包含常量池、方法元数据、注释、字节码等等。它还包含不是从类文件加载但纯粹是运行时生成的数据,例如特定于 JIT 的计数器。
Java 类从被类加载器加载开始它的生命。在类加载期间,加载器 java.lang.Class 在堆中为此类创建对象,并在 Metaspace 中解析和存储此类的元数据。加载器在其整个生命周期中加载的类越多,它在 Metaspace 中积累的元数据就越多。类加载器拥有所有这些元数据。
一个 j
ava 类被删除——卸载——只有当它的加载类加载器死了。Java 规范定义了这一点:
“当且仅当垃圾收集器可以回收其定义的类加载器时,才可以卸载类或接口”。
这条规则有一些有趣的后果。一个 java.lang.Class 对象持有对其加载的引用 java.lang.ClassLoader。所有实例都持有对其 java.lang.Class 对象的引用。因此,不考虑外部引用,类加载器只有在其所有类及其所有实例都可收集时才能被收集。一旦类加载器对象不可访问,GC 将删除它并卸载它的所有类。那时,它还释放加载器在其生命周期中积累的所有类元数据。
因此,我们有一个“bulk-free”场景:类元数据绑定到类加载器,并在该加载器死掉时批量释放(为了简单起见,我们在这里忽略了该规则的例外情况)。
Java 8 之前:永久代 PermGen 管理类元数据
今天,类元数据存在于本机内存中。情况并非总是如此:在 Java 8 之前,它们生活在所谓的永久代(?PermGen?)的堆中。GC 像管理普通的 Java 对象一样管理它们,但这有几个缺点。
作为 Java 堆的一部分,永久代的大小是有限的。该大小必须在 VM 启动时预先指定。过紧的限制通常会导致不可恢复的 OOM,因此用户倾向于将 PermGen 过大。那浪费了内存和地址空间。位于堆中还意味着永久代必须是一个连续的区域,这可能会在地址空间受限的 32 位平台上出现问题。
PermGen 的另一个问题是释放元数据所需的努力。GC 将它们视为普通的 Java 对象:可以在任意时间点消亡并可以收集的实体。但是类元数据绑定到它们的加载器,因此它们的生命周期是可以预测的。因此,一般垃圾收集的灵活性是不必要的,并且浪费了相关的成本 [6]。
PermGen 也让 JVM 开发人员的生活变得更加困难。由于元数据位于 Java 堆中,因此它们不是地址稳定的;GC 可以移动它们。在 JVM 中处理这些数据很麻烦,因为在访问时需要将引用解析为物理指针。此外,它还使调试 JVM 和分析核心文件变得不那么有趣。
BEA 和 JRockit JVM
1998 年,斯德哥尔摩的学生构建了一个替代 Java VM,即 JRockit VM,并创立了 Appeal Virtual Machines。2002 年 BEA Systems 接管了 Appeal,2008 年甲骨文又收购了 BEA。
2010 年,甲骨文收购了 Sun Microsystems。在第二次收购之后,Oracle 拥有两个独立的 JVM 实现,即 JRockit VM 和原始的 Sun JVM。JRockit JVM 被取消,重点转向 Sun JVM。
幸运的是,Sun 在收购之前就开源了其 JVM。2007 年,OpenJDK 项目成立,大部分代码库已在 GPLv2 下发布。被 Sun 收购后,幸运的是 Oracle 没有撤回这个决定,而是继续支持 OpenJDK。
JRockit VM 没有将类元数据保存在堆中,而是保存在本机内存中。这与当时前 Sun-JVM 团队内部的当前想法不谋而合。因此决定废弃 PermGen
Java 8 到 Java 15:第一个元空间
Java 8 中的第一个 Metaspace 是对 PermGen 的巨大改进。但它也带来了新的问题,表现为偶尔出现非常高的内存占用和大幅降低的弹性。从高层次来看,这些新问题是由类元数据离开 Java 堆的舒适拥抱并转而滚动其自己的内存分配器引起的。事实证明,其中存在一些陷阱。
在 SAP,我们调查了客户问题,并在那时更多地参与了 Metaspace 开发。
固定块大小
首先,元空间块管理过于僵化。块有各种大小,永远无法调整大小。这限制了它们在原始装载机死亡后的再利用潜力。空闲列表可能会填满大量锁定到错误大小的块,Metaspace 无法重用这些块。
缺乏弹性
第一个 Metaspace 也缺乏弹性,无法从使用高峰中恢复。
当类被卸载时,它们的元数据就不再需要了。理论上,JVM 可以将这些页面交还给操作系统。如果系统面临内存压力,内核可以将这些空闲页面提供给最需要它的人,这可能包括 JVM 本身的其他区域。为了将来某些可能的类加载而保留该内存是没有用的。
但是 Metaspace 通过在空闲列表中保留已释放的块来保留大部分内存。公平地说,存在一种通过取消映射空虚拟空间节点将内存返回给操作系统的机制。但是这种机制是非常粗粒度的,即使是中等的元空间碎片也很容易被打败。此外,它根本不适用于类空间。
每个类加载器的高开销
在旧的元空间中,小类加载器受到高内存开销的不成比例的影响。如果您的装载机尺寸达到这些“最佳位置”尺寸范围,您支付的费用将远远超过装载机所需的费用。例如,分配约 20K 元数据的加载程序将在内部消耗约 80K,浪费 75% 以上的分配空间。
这些数量很小,但在处理成群的小型装载机时会迅速加起来。这个问题主要困扰着自动生成的类加载器的场景,例如在 Java 上实现的动态语言。
Java 16:元空间,重新发明
Metaspace 代码库变得笨拙且难以维护,因此我们决定完全从头开始并进行干净的重新实现。这项工作需要 JEP,因为由于其规模和所涉及的风险,它超出了正常 RFE 的范围。它需要来自 Oracle 的运行时和 GC 人员的更仔细的审查、测试和合作。
随着 Java 16,JEP 387 发布——新的元空间诞生了。它保留了旧 Metaspace?架构的基本原则,其核心是一个位于其自己的虚拟内存层之上的竞技场分配器。但存在关键差异。
旧 Metaspace 中的块几何体是僵化且不灵活的。块主要以三种相当任意间隔的大小存在,并且很难合并和拆分。当类卸载开始时,这种低效的几何结构很快导致碎片化,这也是每个类加载器开销高的原因。
新的元空间使用新的分配方案来管理内存中的块,基于伙伴分配算法 [13]。该算法快速高效,实现了紧密的内存打包,并且非常擅长防止碎片化。它以非常低的运行时成本管理所有这些。
伙伴分配器算法很古老,起源于 1960 年代。它广泛用于 C-Heap 实现或操作系统中的虚拟内存管理。例如,Linux 内核使用此算法的变体来管理物理页面。
典型的伙伴分配器管理大小为 2 的幂的块。正因为如此,它不是实现像 malloc() 这样的“最终用户”分配方案的最佳选择,因为这会浪费内存,每次分配不是完美的二次幂。但是 Metaspace 使用伙伴分配的方式,这个限制并不重要:伙伴分配器管理的块不是元数据分配的最终产品,而是用于实现 Metaspace arenas 的更粗粒度的构建块。
Metaspace 中非常简化的伙伴分配是这样工作的:
类加载器为元数据请求空间;它的 arena 需要并向块管理器请求一个新块。
块管理器在空闲列表中搜索等于或大于请求大小的块。
如果它发现一个大于请求的大小,它会将该块重复地分成两半,直到片段具有请求的大小。
它现在将碎片块之一交给请求加载器,并将剩余的碎片添加回空闲列表。
块的释放以相反的顺序工作:
类加载器死了;它的 arena 也死了,并将所有的块返回给块管理器
块管理器将每个块标记为空闲并检查其相邻块(“伙伴”)。如果它也是空闲的,它将两个块融合成一个更大的块。
它递归地重复该过程,直到遇到仍在使用的伙伴,或者直到达到最大块大小(以及最大碎片整理)。
然后将大块取消分配以将内存返回给操作系统。
就像一个自我修复的冰盖,块在分配时分裂并在释放时结晶回更大的单元。即使这个过程无休止地重复,它也是一种防止碎片化的极好方法,例如,在一个 JVM 中,它在其生命周期中加载和卸载了大量的类。
评论