目录

JDK元空间的内存分配体系

这是去年写在语雀上的文章,搬运过来。

前段时间偶然在Twitter看到了SAP工程师的一个分享,这位大佬给OpenJDK提了个JEP,几乎算是重构了JDK元空间的实现,使得JDK的元空间更有弹性,对内存占用更友好,于是花了点时间看了下他的系列文章。简单整理如下:

版本说明

从JDK8以来,元空间的实现方式发生了很大变化,这里以OpenJDK 11为准。

1. 什么是元空间

详见此文,简单来讲,Metaspace是存储类元数据的地方,我们知道在JDK 8之前,类的元数据都是存在堆中的永久代。JDK8后,永久代被废除,元数据都移到了Metaspace里。类的元数据大体可以看做class文件的运行时形式,包括但不限于:

  • Klass结构:JVM内部对Java Class的表示,其中含有vtable和itable
  • 方法的元数据:method_info的运行时形式,其中有字节码异常表常量
  • 常量池
  • 注解
  • 方法计数器:为了给JIT编译器提供数据
  • 其他数据

元空间何时分配

当一个类被加载完成且它的JVM内部表示已经准备就绪了,它的类加载器就会给它分配对应的元空间:

https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204030939550.png

元空间何时释放

对一个类来说,已分配的元空间被它的类加载器所持有,当且仅当类加载器自身被卸载了,对应的元空间才会被回收掉。也就是说:当且仅当这个类加载器没有被引用被它加载的类的所有实例也没有被引用时,对应的元空间才会被回收 https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204030940508.png

但是要注意的是,这里说的释放,不是指将内存还给操作系统,被释放的内存,一部分甚至是全部,会以free-list的形式保留,以供后续的类加载时候用。至于到底有多少内存会被保留,取决于元空间的碎片化程度,OpenJDK 11元空间的碎片化问题做了很多修复。

元空间的参数

对于元空间大小的控制,JDK提供了两个参数:

-XX:MaxMetaspaceSize

MaxMetaspaceSize参数控制了元空间的最大可用大小,注意这指的是committed大小。

-XX:CompressedClassSpaceSize 

CompressedClassSpaceSize参数控制了元空间中_Compressed Class Space_的虚拟空间大小。

元空间与GC

正如上文所说,只有当类加载器被卸载时,GC才会回收,一般来讲,对于元空间的GC在以下两种情况都会被触发:

  • JVM内部维护一个阈值,分配元空间时,元空间大小超过这个阈值,GC就会被触发,收集被卸载的类加载器及其加载的类,以重用空间。
  • 遇到元空间OOM,一般是由于元空间committed大小达到了MaxMetaspaceSize,或者Compressed Class Space耗尽了。此时GC被触发,来尽可能补救。

2. 元空间的内存分配器

和glibc malloc等一般通用的分配器一样,元空间的内存分配器也分为了许多层:

第一层:VirtualSpaceList

这一层是直接和操作系统打交道的,也是粒度最粗的一层,就像一个经销商,一次性从操作系统批发大块内存。通过mmap等系统调用向OS按需申请内存,在64位系统上,一般是以2MB作为一个region向OS申请。这些申请下来的region在JVM内部被包装成VirtualSpaceNode形成一个VirtualSpaceList

https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204031016081.svg

每个node会有一个水位线(HWM, High Water Mark),将committed的内存和uncommitted内存分隔开,这个水位线的作用在于留好提前量,当内存分配达到水位线,就会调用OS接口请求新页,从而避免总是node耗尽了再去请求OS。随着内存分配的进行,当前node内存被使用完,此时,就会创建一个新node,添加到list末尾,旧节点就变为退休(retired)状态。注意,旧节点很有可能会有剩余空间,因为可能剩余的空间不足以满足元空间新的分配请求,比如要求200K,但目前只剩下100K,那这个剩下的100K就会被添加到freelist中,以供后续的分配使用,后面会详细介绍这部分。从node中分配出来的内存称之为MetaChunk,MetaChunk的大小分为specialized,small,medium。在64位机器上,其大小分别为1K、4K、64K。与Metachunk 不一样,VirtualSpaceList 和它保存的node都是全局性的,整个JVM进程只会存在一个(未启用指针压缩的情况下),而一个MetaChunk是被一个classloader持有,如下:

https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204031451802.svg

所以:一个node里的chunks,很可能被多个classloder持有。当一个classloader和其相关的类都被卸载了,则其关联的Metaspace就会被释放,被释放的chunk会被添加到一个全局的free list:ChunkManager,如下:

https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204031453108.svg

这些空出来的空间就有可能被后续的classloader使用:

https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204031455265.svg

中间层:MetaChunk

下面来详细说一下MetaChunk,如前所述,VirtualSpaceList 是全局的,那就必然存在锁的竞争,所以直接向VirtualSpaceList 申请内存,代价是很昂贵的,所以使用了MetaChunk这个中间层来避免锁竞争。而且,MetaChunk一般要比classloader实际所需的内存还要大一点,这也是为了满足classloader后续可能的类加载需求,避免频繁请求VirtualSpaceList 。 那么,到底给多大的MetaChunk给classloader合适呢,实际上,这全靠猜:

  1. 一般而言,正常的classloader,给的是4K大小的chunk,如果classloader请求分配器次数超过4次,分配器就会给到64K大小。
  2. bootstrap classloader比较特殊,我们知道,它加载的类是很多的,所以,所以分配器很大方地给4M给它,这个大小可以通过InitialBootClassLoaderMetaspaceSize参数来控制。
  3. 对于反射用到的classloader(jdk.internal.reflect.DelegatingClassLoader)和匿名类的classloader,一般会给1K,因为我们知道,这些classloader都只会加载一次类,给太多也是浪费。

给classloader比实际所需更多的内存,是基于这样的假设:classloader后续很可能还会需要内存来进行类加载。但是完全存在这样的可能:刚给完内存,classloader就再也不加载类了。

第三层:Metablock

MetaChunk还可以继续划分为Metablock

https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204031456336.svg

Metablock才是真正交给classloader的存储单元,一般一个Metablock 存放一个InstanceKlass实例,注意到当前这个Chunk的Unused部分,如果持有这个Chunk的classloader不再加载类了,那Unused部分就是被浪费掉了。上图还有个Deallocated block部分,这指的是在极少数的情况下,元空间会先于类卸载而被释放,比如:

  1. 类重定义了,旧的元数据就没用了
  2. 或者类加载中出现错误,为该类分配好的元数据就放在那无人问津

这样的情况下,对应的Metablock的状态就是_deallocated,并且_会被添加到 free-block-dictionary 中,

3. ClassloaderData 和 ClassLoaderMetaspace

classloader在JVM内部表示为ClassloaderData,ClassloaderData内部持有对ClassLoaderMetaspace的引用,而ClassLoaderMetaspace就保存了这个classloader在使用中的所有MetaChunk。当这个classloader被卸载,对应的ClassloaderData 和 ClassLoaderMetaspace也会一并被删除,于是,这个classloader在使用中的所有MetaChunk会被放入到元空间的free list中去。而是否会被进一步还给操作系统,取决于一定的条件,见下文。

4. 匿名类

前面我们说,元空间的内存被相应的classloader持有,其实是有一点不准确的,对于匿名类来说,情况要复杂一些: 当一个classloader加载一个匿名类时,会为匿名类生成一份独立的ClassloaderData,而并不是依附于宿主类,所以,匿名类及其元数据就有可能先于宿主类被回收掉。也就是说,对于一个正常的类而言,一个classloader只有一份ClassLoaderData ,而对于匿名类而言,会有一个二级的ClassLoaderData ,如下:

https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204031459302.svg

这样分离设计的目的之一,就是避免无谓地延长对lamda和Method Handle元空间分配的生命周期。 回到上面的问题,内存何时还给操作系统呢?答案就是:当VirtualSpaceListNode 里的所有chunk全都是空闲的,并且VirtualSpaceListNode 自身也被从VirtualSpaceList中移除时,这些空闲的chunk就会从free list中移除,node对应的内存就会还给操作系统,此时node的状态称为purged。而要达成上述的条件,就要求所有的chunk,它们对应的classloader都被卸载。这样的可能性到底有多高呢?我们可以计算一下:一个node的大小是2MB,chunk的大小从1K到64K大小不等,正常来讲,一个node会有150至200个chunk,如果这些chunk都被一个classloader持有,那只要卸载这个classloader就能回收内存给操作系统。但如果这些chunk各自被不同生命周期的classloader持有,那这个node就无法被释放,而这种情况在我们加载内部类或者使用反射时是很常见的。还需要注意一点,压缩类空间(Compressed Class Space)是永远不会还给操作系统的。

https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204031500874.png

5. 压缩类空间(Compressed Class Space)

压缩类空间介绍

压缩类空间其实和压缩指针有关系,压缩指针指的是在64位机器上,使用32位的指针来引用对象。这样做的好处有:

  1. 节省空间
  2. 能与某些平台上的寄存器更好地协作

关于压缩指针的详细介绍可以看 JVM的压缩指针。 压缩类空间的启用由UseCompressedClassPointers参数控制,默认是开启的。其原理就是32的压缩指针结合基地址,就能得到64位地址。每个java对象,在其头部都会有一个指针指向其对应的Klass实例: https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204031501144.svg

当启用压缩指针时,那这个指针就是32位的,为了找到64位地址的Klass实例,就需要一个公共的基地址:

https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204031502659.svg

正是因为这个基地址,对Klass实例的存放提出了内存地址限制:Klass实例的地址必须在4G(未偏移模式)或者32G(偏移模式)内,这样才能用一个32位的偏移量结合一个基地址来取址。这样就要求元空间中存储Klass实例的部分必须是一个连续空间。当我们使用mmap或者malloc等系统调用来申请内存,是无法保证每次申请的地址都在上述要求范围内的。比如,一次mmap调用返回0x0000000700000000,另一次mmap调用返回0x0000000f00000000。于是,我们将元空间分为两部分:

  • 一是储存Klass实例的部分,这就是压缩类空间,称为class part,这部分的内存都是连续的,并且需要提前申请好,且不可扩展。
  • 二就是除了Klass实例之外的其他元数据,称为non-class part,这部分因为没有采用32位偏移+基地址寻址的模式,而是直接采用64位地址寻址,所以就不要求内存连续。

所以,如果不开启压缩类空间,那么元空间内存布局是这样的:

https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204031503929.png

如果开启了,则是这样的:

https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204031504245.png

这样来讲,其实压缩类空间有点名不副实,压缩的并不是Klass实例,而是指向这些实例的指针。压缩类空间的大小由XX:CompressedClassSpaceSize参数控制,默认大小为1G。除此之外,HotSpot还人为规定了压缩类空间的最大大小为3G。 要注意,这里说的都是虚拟地址空间,并不是实际占用内存大小。因为现代操作系统分配内存是按需分配的,让进程以为它拥有了所有内存。 一般来讲,Klass实例大小平均为1K,所有默认1G的压缩类空间,大概能存放100W个Klass实例,这就是JVM能加载的类的个数限制。CompressedClassPointers 只有在CompressedOops 参数启用才会生效,而CompressedOops在Java堆大小大于32G时是自动失效的。

压缩类空间实现

现在,我们回到内存分配器,它是如何支持压缩类空间的呢?如果启用了压缩类空间,那么VirtualSpaceList和ChunkManager这样的全局数据结构就会有两份了,一份管理class part,另一份管理non-class part:

https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204031505634.png

这样,正如我们之前所述,non-class part的内存是连续的,提前申请好的,所以它对应的VirtualSpaceList就退化成了只有一个巨大node的list。而储存其他元数据的class part就是有多个node的VirtualSpaceList。

6. 元空间大小

元空间大小控制参数

控制元空间大小的关键参数有两个:MaxMetaspaceSize和CompressedClassSpaceSize。MaxMetaspaceSize控制的是元空间内存占用最大大小(这里是实实在在占用的物理内存),默认是不限制的,意味着如果可能,元空间会用尽所有能用的内存。CompressedClassSpaceSize控制的是压缩类空间的大小(这里是虚拟内存大小),必须在JVM启动前指定,启动后不能扩展,默认为1G。这两个参数的关系如下图所示:

https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204031506827.svg

红色的部分代表元空间占用内存大小,包括了class part和non-class part。如果超过MaxMetaspaceSize,就会导致OutOfMemoryError(“Metaspace”),如果class part部分的内存占用达到了CompressedClassSpaceSize,就会导致OutOfMemoryError(“Compressed Class Space”)。如果没有启用压缩类空间,那CompressedClassSpaceSize自然就无用了,上面的class part自然不复存在,事情就变得更简单了:元空间大小只由MaxMetaspaceSize限制。

一个类加载到底需要多少元空间?

一般而言,一个类被加载后,其元数据分布如下:

https://raw.githubusercontent.com/boatrainlsz/my-image-hosting/main/202204031508059.svg

class space: 主要存放Klass实例,紧随其后的是vtable和itable,前者大小取决于类中的方法个数,后者大小取决于实现的接口方法个数,再往后就是oopMap,它一般很小。vtable和itable的大小可以很大,对于一个有3W个方法或者实现了3W个方法的类,其vtable或者itable能达到240K,一般实际编码中,不会有人写出有3W个方法的类,但是在代码自动生成中,倒是有可能出现。 non-class space: 这里存的元数据就比较杂了,其中大头主要有以下几项:

  • 常量池,可变大小
  • 方法的元数据,包括:ConstMethod,ConstMethod中有字节码、局部变量表、异常表、参数信息、签名等
  • 运行时数据,用来给JIT优化使用
  • 注解

对于一个常见的WildFly服务程序,其class space和non-class space占用比如下:

类加载器类个数non-class space (avg per class)class space (avg per class)non-class/class
all1150360381k (5.25k)9957k (.86k)6.0 : 1
bootstrap281916720k (5.93k)1768k (0.62k)9.5 : 1
app1851320k (7.13k)136k (0.74k)9.7 : 1
anonymous8691013k (1.16k)475k (0.55k)2.1 : 1

从上表,可以得出以下结论:

  1. 对于从App Classloader和Bootstrap Classloader加载的类,一般每个类平均消耗5-7K的的non-class space和600-900bytes的class space
  2. 匿名类占用大小要小得多,但是class space和non-class space的比例显然与其他类也不一样,达到了将近1:2,说明,匿名类虽然小,但是其Klass实例大小也小不到哪儿去,因为肯定不会小于sizeof(Klass)。
  3. 从结论1,如果默认CompressedClassSpaceSize为1G,在理想情况下(没有碎片、没有浪费),压缩类空间最多能放100W-150W个Klass实例。

7. JDK 16对元空间的重大改进

https://www.yuque.com/boatrainlsz/qlwywg/lc2npw

参考资料

https://zhanjindong.com/2016/03/02/jvm-memory-tunning-notes http://cr.openjdk.java.net/~stuefe/jep387/review/2020-09-03/guide/review-guide.html#11-high-level-overview https://stuefe.de/posts/metaspace/metaspace-architecture/#fnref:1 http://xmlandmore.blogspot.com/2014/08/jdk-8-usecompressedclasspointers-vs.html https://www.oracle.com/webfolder/technetwork/tutorials/mooc/JVM_Troubleshooting/week1/lesson1.pdf https://openjdk.java.net/jeps/387 https://blogs.sap.com/2021/07/16/jep-387-elastic-metaspace-a-new-classroom-for-the-java-virtual-machine/