Metaspace Architecture - tenji/ks GitHub Wiki

Metaspace Architecture

我们深入研究 Metaspace 的架构。我们描述了各个层和组件以及它们如何协同工作。

对于想要破解 Hotspot 和 Metaspace 或至少真正了解内存去向以及为什么我们不能只使用 malloc 的人来说,这将主要是有趣的。

像大多数其他非平凡分配器一样,Metaspace 是分层实现的。

在底层,内存在操作系统的大区域中分配。在中间层,我们将这些区域划分为不太大的 chunks,并将它们交给类加载器。

在顶层,类加载器将这些 chunks 切碎为调用者代码提供服务。

The bottom layer : The space list

在最底层 - 最粗粒度 - Metaspace 的内存被 reserved,并通过虚拟内存调用(如 mmap(3))从操作系统按需 committed。这发生在 2MB 大小的区域(在 64 位平台上)。

这些映射区域作为节点保存在名为 VirtualSpaceList 的全局链表中。

每个节点管理一个高水位标记,将 committed 的空间与 uncommitted 的空间分开。当分配达到高水位线时,会按需提交新页面(New Page)。保留一点余量以避免过于频繁地调用操作系统。

这种情况一直持续到节点完全用完为止。然后,分配一个新节点并将其添加到列表中。旧节点正在“退休”。

内存是从称为 MetaChunk 的块中的节点分配的。它们分为三种尺寸,分别命名为 specialized, small 和 medium - 命名具有历史意义 - 通常为 1K/4K/64K。

VirtualSpaceList 及其节点是全局结构,而 Metachunk 由一个类加载器拥有。因此 VirtualSpaceList 中的单个节点可能并且经常包含来自不同类加载器的块:

当类加载器及其所有关联的类被卸载时,用于保存其类元数据的 Metaspace 将被释放。现在所有的空闲 chunks 都被添加到一个全局空闲列表中(the ChunkManager):

这些 chunks 被重用:如果另一个类加载器开始加载类并分配 Metaspace,它可能会被分配一个空闲块而不是分配一个新块:

The middle layer: Metachunk

类加载器从 Metaspace 请求内存以获取一段元数据(通常是少量 - 数十或数百字节),比如说 200 字节。它将获得一个 Metachunk - 一块通常比它请求的内存大得多的内存。

为什么?因为直接从全局 VirtualSpaceList 分配内存是很昂贵的。 VirtualSpaceList 是一个全局结构,需要加锁。我们不想经常这样做,因此将更大的内存分配给类加载器 - 即 Metachunk - 类加载器将使用它来更快地满足未来的分配,跟其他加载器并行,而无需加锁。

只有当该 chunk 用完时,类加载程序才会再次从全局 VirtualSpaceList 分配内存。

Metaspace 分配器如何决定将多大的块交给加载器呢?好吧,这都是视情况而定的:

  • 新启动的标准加载器将获得小的 4K 块,直到达到任意阈值 (4),Metaspace 分配器明显失去耐心并开始为加载器提供更大的 64K 块。
  • 众所周知,引导类加载器(Bootstrap Class Loader)倾向于加载很多类。所以分配器从一开始就给它一个巨大的块(4M)。这可以通过 InitialBootClassLoaderMetaspaceSize 进行调整。
  • 已知反射类加载器 (jdk.internal.reflect.DelegatingClassLoader) 和匿名类的类加载器仅加载一个类。所以他们从一开始就得到非常小的(1K)块,因为假设他们很快就会停止需要元空间,再给他们任何东西都会浪费。

请注意,整个优化 - 在假设加载器很快需要它的情况下,为加载器提供比当前需要更多的空间 - 是对该加载器未来分配行为的赌注,可能正确也可能不正确。他们可能会在分配器分配给他们一个大的 chunk 的那一刻停止加载。

这基本上就像喂猫或小孩子一样。小的你给盘子里的少量食物,大的你把它堆在上面,猫和孩子都可能随时给你惊喜,放下勺子(孩子,而不是猫)然后走开,留下吃掉一半的记忆盘子。猜错的惩罚是浪费内存。有关如何发现这一点的提示,请参阅 Part 5。

The upper layer: Metablock

在 Metachunk 中,我们有第二个类加载器本地分配器。它将 Metachunk 分割成小的分配单元。这些单元被称为 Metablock,是分发给调用者的实际单元(例如,一个 Metablock 包含一个 InstanceKlass)。

这个类加载器本地分配器可以是原始的,因此速度很快:

类元数据的生命周期与类加载器绑定,当类加载器死亡时会批量释放。所以 JVM 不需要关心释放随机 Metablocks。不像通用 malloc 分配器必须这样做。

让我们检查一个 Metachunk:

当它诞生时,它只包含 Header。随后的分配只是在顶部分配。同样,分配器不必很聪明,因为它可以依赖于批量释放的整个元数据。

请注意当前块的“未使用”部分:由于 chunk 由一个类加载器拥有,因此该部分只能由同一个加载器使用。如果加载器停止加载类,则该空间实际上被浪费了。

类加载器将其本机表示保留在名为 ClassLoaderData 的本机结构中。

该结构引用了一个 ClassLoaderMetaspace 结构,该结构保存了该加载器正在使用的所有 Metachunk 的列表。

当加载器被卸载时,关联的 ClassLoaderData 及其 ClassLoaderMetaspace 被删除。这会将此类加载器使用的所有 chunks 释放到 Metaspace 空闲列表中。如果条件合适,它可能会或可能不会导致内存释放给操作系统,请参见下文。

Anonymous classes

ClassloaderData != ClassLoaderMetaspace

请注意,我们一直在说“Metaspace 内存归其类加载器所有”——但在这里我们有点撒谎,这是一种简化。添加匿名类后,图片变得更加复杂:

这些是为动态语言支持而生成的结构。当加载器加载匿名类时,该类会获得自己单独的 ClassLoaderData,其生命周期与匿名类的生命周期耦合,而不是外壳类加载器的生命周期(因此可以在收集外壳加载器之前收集它及其关联的元数据) .这意味着类加载器具有用于所有正常加载类的主要 ClassLoaderData 和用于每个匿名类的次要 ClassLoaderData 结构。

这种分离的目的之一是为了避免不必要地延长诸如 Lambda 和方法句柄之类的 Metaspace 分配的生命周期。

So, again: when is memory returned to the OS?

让我们再看看内存何时返回给操作系统。我们现在可以比 Part 1 末尾更详细地回答这个问题。

当一个 VirtualSpaceListNode 中的所有块碰巧空闲时,该节点本身将被删除。该节点从 VirtualSpaceList 中删除。它的空闲块从 Metaspace 空闲列表中删除。节点被取消映射,其内存返回给操作系统。该节点被“清除”。

要使节点中的所有 chunks 空闲,拥有这些 chunks 的所有类加载器都必须已死。

这是否可能发生在很大程度上取决于碎片:

一个 node 大小为 2MB;chunks 的大小从 1K-64K 不等;通常的负载是每个 node 约 150 - 200 个块。如果这些 chunks 都已由单个类加载器分配,则收集该加载器将释放 node 并将其内存释放给操作系统。

但是,如果这些 chunks 由具有不同生命周期的不同类加载器拥有,则不会释放任何内容。当我们处理许多小型类加载器(例如匿名类的加载器或反射委托器)时,可能就是这种情况。

另请注意,Metaspace(Compressed Class Space)的一部分永远不会释放回操作系统。

TL;DR

  • 内存从操作系统保留在 2MB 大小的区域中,并保存在全局链表中。这些区域是按需 committed 的。
  • 这些区域被切割成 chunks,交给类加载器。一个 chunk 属于一个类加载器。
  • 该 chunk 被进一步划分为更小的分配单元,称为 block。这些是分发给调用者的分配单元。
  • 当加载器死亡时,它拥有的 chunks 会被添加到全局空闲列表中并重新使用。部分内存可能会释放给操作系统,但这在很大程度上取决于碎片和运气。