JEP 387: 弹性元空间
总结
更及时地将未使用的 HotSpot 类元数据(即,元空间)内存归还给操作系统,减少元空间占用,并简化元空间代码以降低维护成本。
非目标
-
本提案的目标不是改变压缩类指针编码的工作方式,也不是改变压缩类空间的存在事实。
-
本提案的目标不是将元空间分配器的使用扩展到 HotSpot 的其他领域,尽管这可能是一个潜在的未来增强。
动机
自 JEP 122 提出以来,元空间 (Metaspace) 因堆外内存使用过高而臭名昭著。大多数普通应用程序不会出现问题,但很容易以不当的方式触发元空间分配器,从而导致过多的内存浪费。不幸的是,这类病态案例并不少见。
元空间内存是按照每个类加载器 区域 进行管理的。一个区域包含一个或多个 块,类加载器通过廉价的指针递增从这些块中分配内存。元空间的块是粗粒度的,以保持分配操作的高效性。然而,这可能会导致使用大量小型类加载器的应用程序遭受不合理高的元空间使用率。
当一个类加载器被回收时,其元空间区域中的块会被放置到空闲列表中以供后续重用。然而,这种重用可能很长时间都不会发生,甚至可能永远不会发生。具有大量类加载和卸载活动的应用程序因此可能会在元空间的空闲列表中积累大量未使用的空间。如果这些空间没有碎片化,它们可以被归还给操作系统用于其他用途,但实际情况往往并非如此。
描述
我们建议用基于伙伴的分配方案(buddy-based allocation scheme)替换现有的元空间内存分配器。这是一种古老且经过验证的算法,例如在 Linux 内核中已成功使用。该方案将使以较小的块分配元空间内存变得可行,从而减少类加载器的开销。它还将减少碎片化,使我们能够通过将未使用的元空间内存返回给操作系统来提高弹性。
我们还将根据需要延迟从操作系统提交内存到内存区域。这将减少那些初始时拥有较大内存区域但不立即使用它们,或者可能永远不会完全使用它们的加载器的内存占用,例如,引导类加载器。
最后,为了充分利用伙伴分配(buddy allocation)所提供的弹性,我们会将元空间内存组织为统一大小的粒度单元(granules),这些单元可以彼此独立地进行提交和取消提交。这些粒度单元的大小可以通过一个新的命令行选项来控制,这提供了一种控制虚拟内存碎片化的简单方法。
详细介绍新算法的文档可以在这里找到:here。一个工作原型作为 JDK 沙盒仓库中的分支 存在。
替代方案
与其对元空间进行现代化改造,不如将其移除,并直接从 C 堆中分配类元数据。这样做的好处是可以降低代码复杂性。然而,使用 C 堆分配器会有以下缺点:
-
作为基于区域的分配器,元空间利用了类元数据对象是批量释放这一事实。C 堆分配器没有这种便利,因此我们不得不逐个跟踪和释放每个对象。这会增加运行时开销,并且根据对象的跟踪方式,还会增加代码复杂性和/或内存使用量。
-
元空间使用指针碰撞分配,这实现了非常紧凑的内存布局。C 堆分配器通常每次分配都会产生更多的开销。
-
如果我们使用 C 堆分配器,那么我们将无法像现在这样实现压缩类空间,并且必须为压缩类指针提出不同的解决方案。
-
过度依赖 C 分配器会带来其自身的风险。C 堆分配器可能伴随着自己的一系列问题,例如高碎片化和弹性不足。由于这些问题不受我们控制,解决它们需要与操作系统供应商合作,这可能会耗费大量时间,并且很容易抵消减少代码复杂性带来的优势。
尽管如此,我们测试了一个将元数据分配重写到 C 堆的原型。我们将这个基于 malloc
的原型与上述基于伙伴系统的原型进行了比较,运行了一个涉及大量类加载和卸载的微基准测试。我们为此测试关闭了压缩类空间,因为它不适用于 C 堆分配。
在 glibc 2.23 的 Debian 系统上,我们观察到基于 malloc
的原型存在以下问题:
- 根据加载类的数量和大小,性能下降了 8%-12%。
- 在类卸载之前的类加载峰值期间,内存使用量(进程 RSS)增加了 15%-18%。
- 内存使用量完全没有从使用高峰中恢复,即元空间完全缺乏弹性。这导致了内存使用量的差异高达 153%。
这些观察结果掩盖了关闭压缩类空间所导致的内存损失;如果考虑到这一点,基于 malloc
的变体的对比将更加不利。
风险与假设
虚拟内存碎片化
每个操作系统都会以某种方式管理其虚拟内存范围;例如,Linux 内核使用红黑树。取消提交内存可能会使这些范围碎片化并增加其数量。这可能会影响某些内存操作的性能。根据操作系统的不同,这也可能导致 VM 进程遇到对内存映射最大数量的系统限制。
在实践中,伙伴分配器的碎片整理能力相当不错,因此我们观察到内存映射数量只有非常适度的增长。如果映射数量的增加成为一个问题,那么我们将增大粒度大小,这会导致更粗略的未提交操作。这样做会减少虚拟内存映射的数量,但会损失一些未提交的机会。
提交速度
取消提交大范围的内存可能会很慢,具体取决于操作系统如何实现页表以及之前该范围的填充密度。元空间回收可能发生在垃圾收集暂停期间,因此这可能是一个问题。
到目前为止,我们还没有观察到这个问题,但如果未提交次数成为一个问题,那么我们可以将未提交的工作转移到一个单独的线程中,这样它就可以独立于 GC 暂停进行处理。
回收策略
为了处理涉及虚拟内存碎片化或未提交速度的潜在问题,我们将添加一个新的生产命令行选项来控制元空间回收行为:
`-XX:MetaspaceReclaimPolicy=(balanced|aggressive|none)`
balanced
(平衡模式):大多数应用程序应该会看到元空间内存占用量有所改善,而内存回收的负面影响应该很小。此模式是默认模式,旨在实现向后兼容。aggressive
(激进模式):以增加虚拟内存碎片为代价,提供更高的内存回收率。none
(无):完全禁用内存回收。
元数据的最大大小
单个元空间对象不能大于 根区块大小,这是伙伴分配器管理的最大区块大小。根区块大小目前设置为 4 MB,这比我们想要在元空间中分配的任何内容都要大得多。