我们深入研究元空间的架构。我们描述了各个层和组件,以及它们是如何协同工作的。
这对那些想要破解hotspot和Metaspace或者至少真正理解内存的去向以及为什么我们不能仅仅使用malloc的人来说是很有趣的。
与大多数其他非平凡的分配器一样,元空间是在层中实现的。
在底部,内存是在操作系统的大区域中分配的。在中间,我们将这些区域分割成不太大的块,然后交给类装入器。
在顶部,类装入器将这些块分割为调用程序代码。
元空间的底层:虚拟空间列表VirtualSpaceList
VirtualSpaceList:
在最底层(在最粗的粒度上),Metaspace的内存是保留的,并通过类似mmap(3)的虚拟内存调用从操作系统按需提交内存。这种情况发生在2MB大小的区域(在64位平台上)。
这些映射区域作为节点保存在名为VirtualSpaceList的全局链接列表中。
每个节点管理一个高水位线,将已提交的空间与仍然未提交的空间分开。当分配达到最高水位线时,将按需提交新页面。为了避免过于频繁地调用操作系统,保留了一点空间。
直到节点完全用完为止。然后,分配一个新节点并将其添加到列表中。旧节点正在“失效”。
内存是从名为MetaChunk的块节点分配的。它们有三种尺寸,分别命名为specialized、small和medium—命名具有历史意义—通常为1K/4K/64K
VirtualSpaceList及其节点是全局结构,而Metachunk由一个类装入器拥有。因此,VirtualSpaceList中的单个节点可能包含来自不同类装入器的块:
当一个类装入器及其所有相关的类被卸载时,用于保存其类元数据的元空间将被释放。所有现在可用的块都添加到全局可用列表(ChunkManager):
这些块被重用:如果另一个类装入器开始加载类并分配元空间,则可能会给它一个空闲块,而不是分配一个新的块:
metaspace中间层:Metachunk
类装入器从Metaspace请求内存以获取一段元数据(通常是少量的,大约几十或几百个字节),比如200个字节。它将得到一个Metachunk——一块通常比请求的内存大得多的内存。
为什么?因为直接从全局VirtualSpaceList分配内存非常昂贵。VirtualSpaceList是一个全局结构,需要锁定。我们不想经常这样做,所以会给加载器一块更大的内存——这个Metachunk——加载程序将使用它更快地满足将来的分配,同时不锁定其他加载程序。只有当块用完时,加载程序才会再次困扰全局VirtualSpaceList。
元空间分配器如何决定要交给加载器的块有多大?好吧,都是猜测:
- 新启动的标准加载程序将获得小的4K块,直到达到任意阈值(4),在该阈值时,元空间分配器明显地失去了耐心,并开始给加载程序提供更大的64K块。
- 引导类加载器被称为加载程序,它倾向于加载许多类。所以分配器从一开始就给它一个巨大的块(4M)。这可以通过InitialBootClassLoaderMetaspaceSize进行调整。
- 反射类加载器(jdk.internal.reflect.DelegatingClassLoader)和匿名类的类装入器3已知只能加载一个类。因此,他们从一开始就得到非常小的(1K)块,因为假设他们很快就不再需要元空间,再给他们任何东西都是浪费。
请注意,整个优化——在假定加载程序很快就会需要它的情况下,为它提供比当前需要更多的空间——是对该加载程序未来分配行为的赌注,可能是正确的,也可能是不正确的。一旦分配器给它们一大块,它们就可能停止加载。
This is basically like feeding cats, or small children. The small ones you give a small amount of food on the plate, for the large ones you pile it on, and both cats and children may surprise you at any moment by dropping the spoon (the children, not the cats) and walking away, leaving half-eaten plates of memory behind. The penalty for guessing wrong is wasted memory.
metaspace上层:元块Metablock
在Metachunk中,我们有第二个类装入器本地分配器。它将元块分割成小的分配单元。这些单元称为元块,是传递给调用者的实际单元(例如,元块包含一个InstanceKlass)。
此类装入器本地分配器可以是原始的,因此速度很快:
类元数据的生存期被绑定到类加载器上,当类装入器死亡时,它将被批量释放。因此,JVM不需要关心释放随机元块4。与一般用途的malloc(3)分配器不同。
让我们来检查一下Metachunk:
当它出生时,它只包含头。随后的分配只是在顶部分配。由于整块元数据都可以被释放,所以不能再依赖于整块的分配。
注意当前块的“未使用”部分:由于块属于一个类装入器,所以该部分只能由同一个装入器使用。如果加载程序停止加载类,那么这个空间实际上是浪费了。
ClassloaderData和ClassLoaderMetaspace
类装入器将其本机表示形式保存在名为ClassLoaderData的本机结构中。
该结构引用了一个ClassLoaderMetaspace结构,该结构保存了该加载程序使用的所有元块的列表。
当加载程序被卸载时,关联的ClassLoaderData及其ClassLoaderMetaspace将被删除。这会将类装入器使用的所有块释放到元空间空闲列表中。如果条件正确,可能会或不会导致内存释放到操作系统,请参阅:https://javakk.com/160.html
匿名类
类加载器数据!=ClassLoaderMetaspace
注意我们一直在说“元空间内存由它的类加载器拥有”——但这里我们有点撒谎,这是一种简化。随着匿名类的增加,情况变得更加复杂:
这些是为动态语言支持而生成的构造。当装入器加载匿名类时,该类将获得自己的独立ClassLoaderData,其生存期与匿名类的生存期耦合,而不是宿主类装入器(因此,可以在收集housing loader之前收集它及其关联的元数据)。这意味着类装入器对所有正常加载的类都有一个主类装入器数据,而每个匿名类都有一个辅助类装入器数据结构。
这种分离的目的是为了不必要地延长Lambdas和方法句柄之类的元空间分配的寿命。
那么,再说一次:内存何时返回操作系统?
让我们再看看内存何时返回操作系统。我们现在可以比第1部分末尾更详细地回答这个问题:
当一个VirtualSpaceListNode中的所有块碰巧是空闲的时,该节点本身将被移除。该节点将从VirtualSpaceList中删除。它的空闲块从Metaspace空闲列表中移除。节点被取消映射,其内存返回给操作系统。节点被“清除”。
为了使一个节点中的所有块都是空闲的,拥有这些块的所有类装入器都必须已经死亡。
这是否可能在很大程度上取决于碎片化:
一个节点的大小是2MB;块的大小从1K-64K不等;通常每个节点的负载是150-200块。如果这些块都是由一个类装入器分配的,那么收集该装入器将释放节点并将其内存释放给操作系统。
但是,如果这些块由具有不同生命周期的不同类装入器拥有,则不会释放任何内容。当我们处理许多小类装入器(例如匿名类的装入器或反射委托器)时,可能会出现这种情况。
另外,请注意,部分Metaspace(压缩类空间)将永远不会释放回操作系统。
- 内存由操作系统在2MB大小的区域中保留,并保存在全局链接列表中。这些地区承诺按需提供服务。
- 这些区域被分割成块,然后交给类装入器。块属于一个类装入器。
- 块被进一步分割成微小的分配,称为块。这些是分发给呼叫者的分配单元。
- 当一个全局块被重新使用时,它拥有一个全局块。部分内存可能会被释放到操作系统中,但这在很大程度上取决于碎片化和运气。
除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/395.html
暂无评论