SAP为Java 16贡献JEP 387 “弹性元空间”


Java 虚拟机需要内存来呼吸——有时比我们喜欢的还要多。Metaspace 是它最需要的子系统之一,它是 JVM 中保存类元数据的部分。通过 JEP 387,SAP 为 OpenJDK 贡献了一个更加节俭和弹性的实现。
尽管“弹性元空间”相对默默无闻,但它是此版本最大的外部贡献之一,补丁本身的数量高达 25kloc。
 
什么是 JEP?
Java(Java 虚拟机和 JDK)是在OpenJDK 的保护伞下开发的,OpenJDK 是一个由 Oracle 和其他公司管理的大型开源项目。SAP 一直是该项目的长期贡献者,我们第一次参与可以追溯到 2012 年。
OpenJDK 开发由流程管理,正常的增强通过一个称为增强请求 (RFE) 的过程。RFE 需要补丁审查,但通常很少,除非补丁影响兼容性。
但是对 JVM、Java 语言或 JDK 的 API 表面的重大更改扩展了 RFE 的范围。因此,它们受制于更重量级的Java Enhancement Proposal 过程(在漂亮的递归中,它由其自己的 JEP定义)。JEP 需要更广泛的设计和代码审查。因此,它通常比简单的 RFE 需要更长的时间。尽管如此,JEP 对于确保 JDK 的长期质量和兼容性至关重要。
大多数 JEP 都是 Oracle 自己完成的,尽管该过程对所有人开放。这可能归结为纯粹的人才库规模。但是在 JEP 之外是可能的并且已经完成:例如,在 2019 年,Red Hat 提供了他们著名的 Shenandoah-GC 作为 JEP 189。
 
堆外内存和元空间
JVM 可能是一个资源匮乏的野兽。最大的内存消耗者通常是 Java 堆,可以说这很好,也是预期的,因为它包含实际的程序数据。其他一切都只是必要的过剩——使机器运转所需的油脂。
因此,用户有时会惊讶地发现 Java 堆消耗只是 JVM 进程总占用空间的一部分。但是我们需要容纳很多内部数据,例如:

  • 线程栈
  • GC 控制结构
  • 实习字符串
  • CDS 档案和文本片段
  • JIT 编译的代码(代码缓存)
  • 还有很多很多其他的东西

所有这些数据都位于 Java 堆之外,无论是在 C 堆中还是在手动管理的映射中。通俗地称为堆外(或者更不正确地称为本机)内存,这些区域的组合大小可以超过堆本身的大小。
在 JVM 中,本机内存的最大消费者之一可能是元空间。因此优化元空间占用空间是值得的。特别是因为如果条件恰到好处,它可能会失控:在 Java 16 之前,Metaspace 不能很好地处理某些 - 完全有效 - 类加载模式。
这就是 JEP 387 的主要目的。元空间随 Java 8 一起引入,自其诞生以来大部分时间保持不变。是时候进行大修了。
 
类元数据
元空间保存类元数据。这些是什么?
Java 类不仅仅包含java.lang.Class堆中的对象。当 JVM 加载一个类时,它会构建一个主要由类文件的预消化部分组成的结构树。这棵树的根是一个大小可变的结构,名为“Klass”(是的,大写的“K”),除其他外,它还包含类 itable 和 vtable。此外,树包含常量池、方法元数据、注释、字节码等等。它还包含不是从类文件加载但纯粹是运行时生成的数据,例如特定于 JIT 的计数器。
Java 类从被类加载器加载开始它的生命。在类加载期间,加载器java.lang.Class在堆中为此类创建对象,并在 Metaspace 中解析和存储此类的元数据。加载器在其整个生命周期中加载的类越多,它在 Metaspace 中积累的元数据就越多。类加载器拥有所有这些元数据。
一个 java 类被删除——卸载——只有当它的加载类加载器死了。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 中,它在其生命周期中加载和卸载了大量的类。
 
现在,“谈话是廉价的,给我看代码”。代码就在那里。与旧的元空间相比,新的元空间如何形成?
优点:
  • 更好的弹性
  • 每个类加载器的开销更少
  • 较小的元空间用于小型 Java 程序

Java 16 中的新元空间节省了内存——多少取决于场景。弹性和减少的碎片使正常运行时间长的大型应用程序受益。每个类加载器的开销减少有助于细粒度加载器方案的情况。新的 Metaspace 代码库也更简洁、更简单,这降低了我们维护人员的成本,并使未来的增强更容易。