全网最硬核 JVM 内存详解(下)原创
今天又是干货满满的一天,这是全网最硬核 JVM 解析系列第四篇,往期精彩:
全网最硬核 TLAB 解析
全网最硬核 Java 随机数解析
全网最硬核 Java 新内存模型解析
本篇是关于 JVM 内存的详细分析。网上有很多关于 JVM 内存结构的分析以及图片,但是由于不是一手的资料亦或是人云亦云导致有很错误,造成了很多误解;并且,这里可能最容易混淆的是一边是 JVM Specification 的定义,一边是 Hotspot JVM 的实际实现,有时候人们一些部分说的是 JVM Specification,一部分说的是 Hotspot 实现,给人一种割裂感与误解。本篇主要从 Hotspot 实现出发,以 Linux x86 环境为主,紧密贴合 JVM 源码并且辅以各种 JVM 工具验证帮助大家理解 JVM 内存的结构。但是,本篇仅限于对于这些内存的用途,使用限制,相关参数的分析,有些地方可能比较深入,有些地方可能需要结合本身用这块内存涉及的 JVM 模块去说,会放在另一系列文章详细描述。最后,洗稿抄袭狗不得 house
本篇全篇目录(以及涉及的 JVM 参数):
-
从 Native Memory Tracking 说起(全网最硬核 JVM 内存解析 - 1.从 Native Memory Tracking 说起开始)
-
Native Memory Tracking 的开启
-
Native Memory Tracking 的使用(涉及 JVM 参数:
NativeMemoryTracking
) -
Native Memory Tracking 的 summary 信息每部分含义
-
Native Memory Tracking 的 summary 信息的持续监控
-
为何 Native Memory Tracking 中申请的内存分为 reserved 和 committed
-
JVM 内存申请与使用流程(全网最硬核 JVM 内存解析 - 2.JVM 内存申请与使用流程开始)
-
Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
-
Linux 大页分配方式 - Transparent Huge Pages (THP)
-
JVM 大页分配相关参数与机制(涉及 JVM 参数:
UseLargePages
,UseHugeTLBFS
,UseSHM
,UseTransparentHugePages
,LargePageSizeInBytes
) -
JVM commit 的内存与实际占用内存的差异
-
Linux 下内存管理模型简述
-
JVM commit 的内存与实际占用内存的差异
-
大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
-
Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
-
验证
32-bit
压缩指针模式 -
验证
Zero based
压缩指针模式 -
验证
Non-zero disjoint
压缩指针模式 -
验证
Non-zero based
压缩指针模式 -
压缩对象指针存在的意义(涉及 JVM 参数:
ObjectAlignmentInBytes
) -
压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:
UseCompressedOops
,UseCompressedClassPointers
) -
压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:
ObjectAlignmentInBytes
,HeapBaseMinAddress
) -
通用初始化与扩展流程
-
直接指定三个指标的方式(涉及 JVM 参数:
MaxHeapSize
,MinHeapSize
,InitialHeapSize
,Xmx
,Xms
) -
不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
-
压缩对象指针相关机制(涉及 JVM 参数:
UseCompressedOops
)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始) -
为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:
HeapBaseMinAddress
) -
结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:
HeapBaseMinAddress
,ObjectAlignmentInBytes
,MinHeapSize
,MaxHeapSize
,InitialHeapSize
) -
使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
-
堆大小的动态伸缩(涉及 JVM 参数:
MinHeapFreeRatio
,MaxHeapFreeRatio
,MinHeapDeltaBytes
)(全网最硬核 JVM 内存解析 - 6.其他 Java 堆内存相关的特殊机制开始) -
适用于长期运行并且尽量将所有可用内存被堆使用的 JVM 参数 AggressiveHeap
-
JVM 参数 AlwaysPreTouch 的作用
-
JVM 参数 UseContainerSupport - JVM 如何感知到容器内存限制
-
JVM 参数 SoftMaxHeapSize - 用于平滑迁移更耗内存的 GC 使用
-
JVM 元空间设计(全网最硬核 JVM 内存解析 - 7.元空间存储的元数据开始)
-
jcmd <pid> VM.metaspace
元空间说明 -
元空间相关 JVM 日志
-
元空间 JFR 事件详解
-
jdk.MetaspaceSummary
元空间定时统计事件 -
jdk.MetaspaceAllocationFailure
元空间分配失败事件 -
jdk.MetaspaceOOM
元空间 OOM 事件 -
jdk.MetaspaceGCThreshold
元空间 GC 阈值变化事件 -
jdk.MetaspaceChunkFreeListSummary
元空间 Chunk FreeList 统计事件 -
CommitLimiter
的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC -
每次 GC 之后,也会尝试重新计算
_capacity_until_GC
-
首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
-
然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
-
然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
-
然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
-
然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
-
新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
-
然后类加载器 1 被 GC 回收掉
-
然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
-
元空间的整体配置以及相关参数(涉及 JVM 参数:
MetaspaceSize
,MaxMetaspaceSize
,MinMetaspaceExpansion
,MaxMetaspaceExpansion
,MaxMetaspaceFreeRatio
,MinMetaspaceFreeRatio
,UseCompressedClassPointers
,CompressedClassSpaceSize
,CompressedClassSpaceBaseAddress
,MetaspaceReclaimPolicy
) -
元空间上下文
MetaspaceContext
-
虚拟内存空间节点列表
VirtualSpaceList
-
虚拟内存空间节点
VirtualSpaceNode
与CompressedClassSpaceSize
-
MetaChunk
-
类加载的入口
SystemDictionary
与保留所有ClassLoaderData
的ClassLoaderDataGraph
-
每个类加载器私有的
ClassLoaderData
以及ClassLoaderMetaspace
-
管理正在使用的
MetaChunk
的MetaspaceArena
-
元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
-
ClassLoaderData
回收 -
ChunkHeaderPool
池化MetaChunk
对象 -
ChunkManager
管理空闲的MetaChunk
-
类加载器到
MetaSpaceArena
的流程 -
从
MetaChunkArena
普通分配 - 整体流程 -
从
MetaChunkArena
普通分配 -FreeBlocks
回收老的current chunk
与用于后续分配的流程 -
从
MetaChunkArena
普通分配 - 尝试从FreeBlocks
分配 -
从
MetaChunkArena
普通分配 - 尝试扩容current chunk
-
从
MetaChunkArena
普通分配 - 从ChunkManager
分配新的MetaChunk
-
从
MetaChunkArena
普通分配 - 从ChunkManager
分配新的MetaChunk
- 从VirtualSpaceList
申请新的RootMetaChunk
-
从
MetaChunkArena
普通分配 - 从ChunkManager
分配新的MetaChunk
- 将RootMetaChunk
切割成为需要的MetaChunk
-
MetaChunk
回收 - 不同情况下,MetaChunk
如何放入FreeChunkListVector
-
什么时候用到元空间,以及释放时机
-
元空间保存什么
-
什么是元数据,为什么需要元数据
-
什么时候用到元空间,元空间保存什么
-
元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
-
元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
-
元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
-
jcmd VM.metaspace
元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始) -
JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
-
解释执行与编译执行时候的判断(x86为例)
-
一个 Java 线程 Xss 最小能指定多大
-
JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:
ThreadStackSize
,VMThreadStackSize
,CompilerThreadStackSize
,StackYellowPages
,StackRedPages
,StackShadowPages
,StackReservedPages
,RestrictReservedStack
) -
Java 线程栈内存的结构
-
Java 线程如何抛出的 StackOverflowError
4. JVM 元空间设计
4.1. 什么是元数据,为什么需要元数据
JVM 在执行 Java 应用程序时,将加载的 Java 类的许多细节记录在内存中,这些信息称为类元数据(Class MetaData)。这些元数据对于 Java 的很多灵活的语言以及虚拟机特性都是很重要的,比如动态类加载、JIT 实时编译、反射以及动态代理等等。不同的 JVM 加载类保存的内存信息是不一样的,它们通常在更低的内存占用与更快的执行速度之间进行权衡(类似于空间还是时间的权衡)。对于 OpenJDK Hotspot 使用的则是相对丰富的元数据模型来获得尽可能快的性能(时间优先,不影响速度的情况下尽量优化空间占用)。相比于 C,C++,Go 这些离线编译为可执行二进制文件的程序相比,像 JVM 这样的托管运行时动态解释执行或者编译执行的,则需要保留更多关于正在执行的代码的运行时信息。原因如下:
-
依赖类库并不是一个确定的有限集:Java 可以动态加载类,并且还有 ASM 以及 Javassist 这些工具在运行时动态定义类并加载,还有 JVMTI agent 这样的机制来动态修改类。所以,JVM 通过类元数据保存:运行时中存在哪些类,它们包含哪些方法和字段,并能够在链接加载期间动态地解析从一个类到另一个类的引用。类的链接也需要考虑类的可见性和可访问性。类元数据与类加载器相关联,同时类元数据也包括类权限和包路径以及模块信息(Java 9之后引入的模块化),以确定可访问性
-
JVM 解释执行或者通过 JIT 实时编译执行 Java 代码的时候需要基于类元数据的很多信息才能执行:需要知道例如类与类之间的关系,类属性以及字段还有方法结构等等等等。例如在做强制转换的时候,需要检查类型的父子类关系确定是否可以强制转换等等。
-
JVM 需要一些统计数据决定哪些代码解释执行那些代码是热点代码需要 JIT 即时编译执行。
-
Java 有反射 API 供用户使用,这就需要运行时知道所有类的各种信息。洗稿也是一种侵权行为
4.2. 什么时候用到元空间,元空间保存什么
4.2.1. 什么时候用到元空间,以及释放时机
只要发生类加载,就会用到元空间。例如我们创建一个类对象时:这个类首先会被类加载器加载,在发生类加载的时候,对应类的元数据被存入元空间。元数据分为两部分存入元空间,一部分存入了元空间的类空间另一部分存入了元空间的非类空间。堆中新建的对象的对象头中的 Klass
指针部分,指向元空间中 Klass,同时,Klass 中各种字段都是指针,实际对象的地址,可能在非类空间,例如实现方法多态以及 virtual call 的 vtable 与 itable 保存着方法代码地址的引用指针。非类空间中存储着比较大的元数据,例如常量池,字节码,JIT 编译后的代码等等。由于编译后的代码可能非常大,以及 JVM 对于多语言支持的扩展可能动态加载很多类,所以将 MetaSpace 的类空间与非类空间区分开。如图所示:
JVM 启动参数 -XX:CompressedClassSpaceSize
指定的是压缩类空间大小,默认是 1G。-XX:MaxMetaspaceSize
控制的是 MetaSpace 的总大小。这两个参数,以及 MetaSpace 更多参数,我们会在后面的章节详细解释。
当类加载器加载的所有类都没有任何实例,并且没有任何指向这些类对象(java.lang.Class
)的引用,也没有指向这个类加载器的引用的时候,如果发生了 GC,这个类加载器使用的元空间就会被释放。但是这个释放并不一定是释放回操作系统,而是被标记为可以被其他类加载器使用了。
4.2.2. 元空间保存什么
元空间保存的数据,目前分为两大类:
-
Java 类数据:即加载的 Java 类对应 JVM 中的 Klass 对象(Klass 是 JVM 源码中的一个 c++ 类,你可以理解为类在 JVM 中的内存形式),但是这个 Klass 对象中存储的很多数据都是指针,具体的数据存储属于非 Java 类数据,一般非 Java 类数据远比 Java 类数据占用空间大。
-
非 Java 类数据:即被 Klass 对象引用的一些数据,例如:类中的各种方法,注解,执行采集与统计信息等等。不要偷取他人的劳动成果,也不要浪费自己的时间和精力,让我们一起做一个有良知的写作者。
如果是 64 位的 JVM 虚拟机(从 Java 9+ 开始只有 64 位的虚拟机了)并且开启了压缩类指针(-XX:+UseCompressedClassPointers,默认是开启的),那么元空间会被划分成两部分:
-
类元空间:存储上面说的Java 类数据的空间
-
数据元空间:存储上面说的非 Java 类数据的空间
基于是否开启了压缩类指针分为这两部分的原因是,(剽窃抄袭侵权
)在对象头需要保留指向 Klass 的指针,如果我们能尽量压缩这个指针的大小,那么每个对象的大小也能得到压缩,这将节省很多堆空间。在 64 位虚拟机上面,指针默认都是 64 位大小的,开启压缩类指针(-XX:+UseCompressedClassPointers
,默认是开启的)之后,类指针变为 32 位大小,最多能指向 2^32 也就是 4G 的空间,如果我们能保持 Klass 所处的空间占用不超过这个限制的话,就能使用压缩类指针了。所以我们把 Klass 单独提取到一个单独的区域进行分配。Klass 占用的空间并不会太大,虽然对于 Java 中的每一个类都会有一个 Klass,但是占用空间的方法内容以及动态编译信息等等,具体数据都在数据元空间中存储,Klass 中大部分都是指针。基本上很少会遇到 32 位指针不够用的情况。
注意,老版本中, UseCompressedClassPointers
取决于 UseCompressedOops
,即压缩对象指针如果没开启,那么压缩类指针也无法开启。但是从 Java 15 Build 23 开始, UseCompressedClassPointers
已经不再依赖 UseCompressedOops
了,两者在大部分情况下已经独立开来。除非在 x86 的 CPU 上面启用 JVM Compiler Interface(例如使用 GraalVM)。参考 JDK ISSUE:https://bugs.openjdk.java.net/browse/JDK-8241825 - Make compressed oops and compressed class pointers independent (x86_64, PPC, S390) 以及源码:
-
https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/cpu/x86/globalDefinitions_x86.hpp
:#define COMPRESSED_CLASS_POINTERS_DEPENDS_ON_COMPRESSED_OOPS EnableJVMCI
在 x86 CPU 上,UseCompressedClassPointers
是否依赖UseCompressedOops
取决于是否启用了 JVMCI,默认使用的 JVM 发布版,EnableJVMCI 都是 false -
https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/cpu/arm/globalDefinitions_arm.hpp
:#define COMPRESSED_CLASS_POINTERS_DEPENDS_ON_COMPRESSED_OOPS false
在 ARM CPU 上,UseCompressedClassPointers
不依赖UseCompressedOops
-
https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/cpu/ppc/globalDefinitions_ppc.hpp
:#define COMPRESSED_CLASS_POINTERS_DEPENDS_ON_COMPRESSED_OOPS false
在 PPC CPU 上,UseCompressedClassPointers
不依赖UseCompressedOops
-
https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/cpu/s390/globalDefinitions_s390.hpp
:#define COMPRESSED_CLASS_POINTERS_DEPENDS_ON_COMPRESSED_OOPS false
在 S390 CPU 上,UseCompressedClassPointers
不依赖UseCompressedOops
在元空间分配的对象,都是调用 Metaspace::allocate
从元空间分配空间。调用这个方法的是 MetaspaceObj
的构造函数,对应源码:https://github.com/openjdk/jdk/blob/jdk-21+3/src/hotspot/share/memory/allocation.cpp
void* MetaspaceObj::operator new(size_t size, ClassLoaderData* loader_data,
size_t word_size,
MetaspaceObj::Type type, TRAPS) throw() {
// Klass has its own operator new
return Metaspace::allocate(loader_data, word_size, type, THREAD);
}//你以为我想这样么?主要是抄袭狗太多
void* MetaspaceObj::operator new(size_t size, ClassLoaderData* loader_data,
size_t word_size,
MetaspaceObj::Type type) throw() {
assert(!Thread::current()->is_Java_thread(), "only allowed by non-Java thread");
return Metaspace::allocate(loader_data, word_size, type);
}
MetaspaceObj
的 Operator new 方法定义了从 MetaSpace 上分配内存,即所有 MetaspaceObj
的子类,只要没有明确覆盖从其他地方分配,就会从 MetaSpace 分配内存。MetaspaceObj
的子类包括:
位于类元空间的:
-
Klass
:其实就是 Java 类的实例(每个 Java 的 class 有一个对应的对象实例,用来反射访问,这个就是那个对象实例),即 Java 对象头的类型指针指向的实例: -
ObjArrayKlass
:普通对象数组对应的 Klass -
TypeArrayKlass
:原始类型数组对应的 Klass -
InstanceRefKlass
:java.lang.ref.Reference
类以及子类对应的 Klass -
InstanceClassLoaderKlass
:Java 类加载器对应的 Klass -
InstanceMirrorKlass
:java.lang.Class
对应的 Klass -
InstanceKlass
:普通对象类的 Klass: -
ArrayKlass
:Java 数组对应的 Klass
位于数据元空间的:
-
Symbol
:符号常量,即类中所有的符号字符串,例如类名称,方法名称,方法定义等等。 -
ConstantPool
:运行时常量池,数据来自于类文件中的常量池。 -
ConstanPoolCache
:运行时常量池缓存,用于加速常量池访问 -
ConstMethod
:类文件中的方法解析后,静态信息放入 ConstMethod,这部分信息可以理解为是不变的,例如字节码,行号,方法异常表,本地变量表,参数表等等。 -
MethodCounters
:方法的计数器相关数据。 -
MethodData
:方法数据采集,动态编译相关数据。例如某个方法需要采集一些指标,决定是否采用 C1 C2 动态编译优化性能。 -
Method
:Java 方法,包含以上ConstMethod
,MethodCounters
,MethodData
的指针以及一些额外数据。 -
RecordComponent
:对应 Java 14 新特性 Record,即从 Record 中解析出的关键信息。
以上这类型,我们在下一个系列全网最硬核 JVM 元空间解析中再详细说明。
4.3. 元空间的核心概念与设计
4.3.1. 元空间的整体配置以及相关参数
元空间配置相关的参数:
-
MetaspaceSize
:初始元空间大小,也是最小元空间大小。后面元空间大小伸缩的时候,不会小于这个大小。默认是 21M。抄袭剽窃侵权
滚 -
MaxMetaspaceSize
:最大元空间大小,默认是无符号 int 最大值。 -
MinMetaspaceExpansion
:每次元空间大小伸缩的时候,至少改变的大小。默认是 256K。后文讲到元空间内存大小限制的时候会详细分析。 -
MaxMetaspaceExpansion
:每次元空间大小伸缩的时候,最多改变的大小。默认是 4M。后文讲到元空间内存大小限制的时候会详细分析。 -
MaxMetaspaceFreeRatio
:最大元空间空闲比例,默认是 70,即 70%。后文讲到元空间内存大小限制的时候会详细分析。 -
MinMetaspaceFreeRatio
:最小元空间空闲比例,默认是 40,即 40%。后文讲到元空间内存大小限制的时候会详细分析。 -
UseCompressedClassPointers
:前文提到过,是否开启压缩类指针。默认是开启的。老版本中,UseCompressedClassPointers
取决于UseCompressedOops
,即压缩对象指针如果没开启,那么压缩类指针也无法开启。但是从 Java 15 Build 23 开始,UseCompressedClassPointers
已经不再依赖UseCompressedOops
了,两者在大部分情况下已经独立开来。除非在 x86 的 CPU 上面启用 JVM Compiler Interface(例如使用 GraalVM)。参考 JDK ISSUE:https://bugs.openjdk.java.net/browse/JDK-8241825 - Make compressed oops and compressed class pointers independent (x86_64, PPC, S390) -
CompressedClassSpaceSize
:如果启用了压缩类指针,则元空间会分为类元空间和数据元空间,否则只有数据元空间。这个参数限制类元空间的大小,范围是 1M ~ 3G。默认大小是 1G,如果指定了MaxMetaspaceSize
,那么为 1G 与MaxMetaspaceSize * 0.8
中比较小的那个值, -
CompressedClassSpaceBaseAddress
:类元空间起始虚拟内存地址,这个一般不指定。作用和前文分析堆内存的堆起始位置的作用差不多。 -
MetaspaceReclaimPolicy
:可以为:balanced
,aggressive
, 以及none
,需要注意一点的是none
要被移除了(https://bugs.openjdk.org/browse/JDK-8302385)。默认是balanced
。具体主要是影响元空间底层相关的配置,下面我们会详细分析。
元空间底层相关的配置包括:
-
commit 粒度 - commit_granule:通过第二章的分析我们知道,JVM 的空间一般是先 reserve, 之后 commit 之前 reserve 的空间的一部分,然后才能使用的。这个 commit 粒度代表元空间中 commit 内存的最小粒度,元空间在扩容缩容的时候最小的大小单位是 commit 粒度。
-
虚拟内存空间节点内存大小 - virtual_space_node_default_word_size:这是后文我们会详细分析的
VirtualSpaceNode
的虚拟内存大小。大小在 64 位环境下是 64 MB。 -
虚拟内存空间节点内存对齐 - virtual_space_node_reserve_alignment_words:这是后文我们会详细分析的
VirtualSpaceNode
的虚拟内存大小需要对齐的大小,即整体大小需要大于这个对齐大小并且是这个对齐大小整数倍。这个大小就是MetaChunk
的最大大小,即 4MB。 -
当前 MetaChunk 不足以分配的时候,是否尝试扩容当前 MetaChunk - enlarge_chunks_in_place:这个参数在正式 JVM 中是 true,并且不能修改。后文我们会详细分析什么是
MetaChunk
。这里简单理解就是,元空间整体使用了和 Linux 伙伴分配算法类似的设计与抽象,其中内存分配的单元就是 Chunk,元空间中对应的就是 MetaChunk。 -
分配新的 MetaChunk 的时候,是否一下子 commit MetaChunk 所有的内存 - new_chunks_are_fully_committed:后文我们会详细分析什么是
MetaChunk
。 -
在 MetaChunk 整个空间都没有使用的时候,是否将 MetaChunk 的内存全部释放回操作系统 - uncommit_free_chunks:后文我们会详细分析什么是
MetaChunk
。
从 Java 16 开始,引入了弹性元空间。老的元空间由于设计上分配粒度比较大,并且没有很好地释放空间的策略设计,所以占用可能比较大。Java 16 开始,JEP 387: Elastic Metaspace 引入了弹性元空间的设计,也是我们这里要讨论的设计。这个弹性元空间也引入了一个重要的参数 -XX:MetaspaceReclaimPolicy
。
MetaspaceReclaimPolicy
:可以为:balanced
, aggressive
, 以及 none
,需要注意一点的是 none
要被移除了(https://bugs.openjdk.org/browse/JDK-8302385),这三个配置具体影响是:
4.3.2. 元空间上下文 MetaspaceContext
MetaspaceContext
本身直接原生堆上面分配,Native Memory Tracking 中属于 Metaspace
那一类别,即元空间的抽象类占用的空间。
https://github.com/openjdk/jdk/blob/jdk-21%2B11/src/hotspot/share/memory/metaspace/metaspaceContext.hpp
class MetaspaceContext : public CHeapObj<mtMetaspace>
JVM 元空间,会在全局建立两个元空间上下文(MetaspaceContext
),一个用于类元空间(我们后面称为类元空间 MetaspaceContext
),一个用于数据元空间(我们后面称为数据元空间 MetaspaceContext
)。当然,在没有启用压缩类指针的时候,只会初始化一个数据元空间 MetaspaceContext
,不会初始化类元空间 MetaspaceContext
,之后使用分配的时候,也只会用数据元空间 MetaspaceContext
进行分配。但是我们在后面讨论的时候,只会讨论开启压缩类指针的情况,因为这是默认并且常用的情况。
每个 MetaspaceContext
都会对应一个独立的 VirtualSpaceList
,以及一个独立的 ChunkManager
。
这个 VirtualSpaceList
中的每一个元素都是一个 VirtualSpaceNode
。顾名思义,VirtualSpaceNode
是从操作系统申请内存,与元空间内存划分的抽象隔离的中间层抽象。VirtualSpaceList
负责与操作系统交互,申请或者释放内存。元空间与 VirtualSpaceList
交互,使用内存。
ChunkManager
顾名思义,是管理所有 Chunk 的内存管理器。Chunk 这个概念经常出现在各种伙伴内存管理算法框架(Buddy Allocator)中,一般指内存管理分配的最小单元,这里的 Chunk 抽象对应的就是 MetaChunk
。ChunkManager
从 VirtualSpaceList
上面获取一块连续比较大的内存的 MetaChunk
(其实是 RootMetaChunk
),然后将这个 RootMetaChunk
按照分配需求,连续对半分割成需要的大小,返回这个合适大小的 MetaChunk
,剩下的分割出来的 MetaChunk
进入 FreeChunkListVector
用于下次分配 MetaChunk
的时候,直接返回合适的,就不再从 VirtualSpaceList
获取了。
我们接下来仔细分析 VirtualSpaceList
与 ChunkManager
4.3.3. 虚拟内存空间节点列表 VirtualSpaceList
VirtualSpaceList
本身直接原生堆上面分配,Native Memory Tracking 中属于 Class
那一类别,即元空间的加载类占用的空间。其实本人感觉这么设计不太合理,应该和 MetaspaceContext
属于同一个类别才比较合理。真正分配加载的类的占用空间的是从 VirtualSpaceNode
上面标记的内存分配的,这是下一小节要分析的内容。
https://github.com/openjdk/jdk/blob/jdk-21%2B11/src/hotspot/share/memory/metaspace/virtualSpaceList.hpp
class VirtualSpaceList : public CHeapObj<mtClass>
首先提一点,类元空间 MetaspaceContext
与数据元空间 MetaspaceContext
略有不同:类元空间 MetaspaceContext
的 VirtualSpaceList
是不可以扩展申请新的内存的,但是数据元空间 MetaspaceContext
的 VirtualSpaceList
是可以的。也就是说:类元空间 MetaspaceContext
的 VirtualSpaceList
其实只有一个 VirtualSpaceNode
,但是数据元空间 MetaspaceContext
的 VirtualSpaceList
是一个包含多个 VirtualSpaceNode
的列表。
4.3.4. 虚拟内存空间节点 VirtualSpaceNode
与 CompressedClassSpaceSize
VirtualSpaceNode
本身直接原生堆上面分配,Native Memory Tracking 中属于 Class
那一类别,即元空间的加载类占用的空间。其实本人感觉这么设计不太合理,应该和 MetaspaceContext
属于同一个类别才比较合理。真正分配加载的类的占用空间的是从 VirtualSpaceNode
上面标记的内存地址分配的,VirtualSpaceNode
本身的空间占用只是起到描述记录作用,应该也属于元空间描述的那一类。
https://github.com/openjdk/jdk/blob/jdk-21%2B11/src/hotspot/share/memory/metaspace/virtualSpaceNode.hpp
class VirtualSpaceNode : public CHeapObj<mtClass>
VirtualSpaceNode
是一块连续的虚拟内存空间内存的抽象。类元空间的 VirtualSpaceList
只包含一个 VirtualSpaceNode
,大小是前文提到的 CompressedClassSpaceSize
。
数据元空间并不像类元空间或者堆内存那样,一下子 reserve 最大堆内存限制的内存,而是每次 reserve VirtualSpaceNode
大小。VirtualSpaceNode
大小在 64 位环境下是 64 MB:
https://github.com/openjdk/jdk/blob/jdk-21%2B11/src/hotspot/share/memory/metaspace/metaspaceSettings.hpp
static const size_t _virtual_space_node_default_word_size =
chunklevel::MAX_CHUNK_WORD_SIZE * NOT_LP64(2) LP64_ONLY(16); // 8MB (32-bit) / 64MB (64-bit)
VirtualSpaceNode
通过两个数据结构来管理它维护的虚拟内存空间:
-
CommitMask
:实际是一个位图,用于维护哪些内存被 commit 了,哪些没有,位图的标记的单位就是前文提到的 commit_granule(commit 粒度)。 -
RootChunkAreaLUT
:用于维护每个RootMetaChunk
的内存分布。至于什么是RootMetaChunk
在后续我们讲MetaChunk
的时候会详细讲解。
一个 VirtualSpaceNode
的主要结构如下图所示:
4.3.5. MetaChunk
MetaChunk
是元空间内存分配的核心抽象,其本质就是描述一块连续的虚拟内存空间。MetaChunk
本身只是一个描述对象,它也是直接原生堆上面分配,Native Memory Tracking 中属于 Metaspace
那一类别,即元空间的抽象类占用的空间。这个描述对象是池化的,参考后面会分析的 ChunkHeaderPool
。不要偷取他人的劳动成果!
元空间的任意分配,都是在某个 MetaChunk
上进行的(不要偷取他人的劳动成果!)。MetaChunk
有级别的概念,即 ChunkLevel
,每个 MetaChunk
都有自己的 ChunkLevel
,这个 ChunkLevel
主要代表了 MetaChunk
描述的内存空间的大小,每一个 level 都是下一个 level 大小的 2 倍:
从 VirtualSpaceNode
上直接划分的 MetaChunk
是 RootMetaChunk
,它的 ChunkLevel
为最高级别的 0,大小是 4MB,并且其中的内存只是 reserve 还没有 commit 的。
MetaChunk
有三个状态:
-
Dead
:即MetaChunk
只是对象被创建出来,但是没有关联描述实际的虚拟内存。后面我们会知道,MetaChunk
是池化可回收在利用的,MetaChunk
的池就是ChunkHeaderPool
。位于ChunkHeaderPool
都还没有关联描述实际的虚拟内存,状态为Dead
。 -
Free
:即MetaChunk
关联描述了实际的虚拟内存,但是没有被实际使用。此时,这个MetaChunk
位于ChunkManager
管理。 -
InUse
:即MetaChunk
关联描述了实际的虚拟内存,也被实际使用了,此时,MetaChunkArena
管理这个MetaChunk
上面的内存分配。
4.3.5.1. ChunkHeaderPool
池化 MetaChunk
对象
MetaChunk
实际上只是一块连续的虚拟内存空间的描述类(不要偷取他人的劳动成果!),即元数据类。由于类加载需要的大小不一,并且还经常会发生合并,切分等等,MetaChunk
可能有很多很多,元空间为了节省这个元数据类占用的空间,将其池化,回收再利用。这个池就是 ChunkHeaderPool
。例如,从 VirtualSpaceNode
上直接划分 RootMetaChunk
的内存空间,会从 ChunkHeaderPool
申请一个 MetaChunk
用于描述。当两个 MetaChunk
的空间需要合并成一个的时候,其中一个 MetaChunk
其实就没有用了,会放回 ChunkHeaderPool
,而不是直接 free 掉这个对象。
ChunkHeaderPool
本身直接原生堆上面分配,Native Memory Tracking 中属于 Metaspace
那一类别,即元空间的抽象类占用的空间。
https://github.com/openjdk/jdk/blob/jdk-21%2B11/src/hotspot/share/memory/metaspace/chunkHeaderPool.hpp
class ChunkHeaderPool : public CHeapObj<mtMetaspace>
其实从这里我们可以推测出,MetaChunk
本身也是直接原生堆上面分配,Native Memory Tracking 中也是属于 Metaspace
那一类别。
ChunkHeaderPool
的结构是:
其实 ChunkHeaderPool
的机制很简单:
-
申请
MetaChunk
用于描述内存: -
首先查看
_freelist
,是否有之前放回的MetaChunk
可以使用,如果有,就返回那个MetaChunk
,并从_freelist
移除这个MetaChunk
-
如果没有,读取
_current_slab
指向的Slab
,Slab
核心就是一个预分配好的MetaChunk
数组(大小是 128),_top
指的是当前使用到数组的哪一个。 -
如果
_top
没有到 128,返回_top
代表的MetaChunk
,并将_top
加 1。 -
如果
_top
到 128,创建新的Slab
,_current_slab
指向这个新的Slab
-
回收
MetaChunk
:放入_freelist
4.3.5.2. ChunkManager
管理空闲的 MetaChunk
ChunkManager
本身直接原生堆上面分配,Native Memory Tracking 中属于 Metaspace
那一类别,即元空间的抽象类占用的空间。不要偷取他人的劳动成果!
class ChunkManager : public CHeapObj<mtMetaspace>
https://github.com/openjdk/jdk/blob/jdk-21%2B11/src/hotspot/share/memory/metaspace/chunkManager.hpp
ChunkManager
管理已经关联内存但是还没使用(状态是 Free
)的 MetaChunk
。在第一次从 VirtualSpaceNode
上面分配 RootMetaChunk
的内存的时候,根据申请的内存大小,决定要将 RootMetaChunk
拆分到某个 ChunkLevel
大小之后用于当前分配,拆分出来的其他的 MetaChunk
还没有使用,先放入一个类似于之前 ChunkHeaderPool
里面的 _free_list
的结构,用于下次申请 MetaChunk
用于分配的时候,先从这个里面找,找不到之后再从 VirtualSpaceNode
上面尝试分配新的 RootMetaChunk
。不要惯着cao袭的人!
ChunkManager
的整体结构是:
ChunkManager
主要维护一个 FreeChunkListVector
,FreeChunkListVector
里面是一个 FreeChunkList
数组(还有xigao dog 的码)。FreeChunkList
是一个 MetaChunk
链表,链表中都是 Free
的 MetaChunk
,同样 ChunkLevel
的 MetaChunk
位于同一个 FreeChunkList
中。FreeChunkList
数组以 ChunkLevel
为下标,这样的数据结构可以快速找到一个所需 ChunkLevel
的 MetaChunk
。FreeChunkList
这个链表其实是一个双向链表,包含头尾两个指针,如果一个 MetaChunk
管理的内存被 commit 了,就会放在链表头部,没有 commit 的放在链表尾部。
MetaChunk
具体的分配,切分,合并流程,我们会在介绍完 MetaspaceArena
之后详细分析。但是,MetaspaceArena
和 ChunkManager
不一样,ChunkManager
是全局两个,一个属于类元空间,一个属于数据元空间,倘若没有开启压缩类指针,那么就只有一个数据元空间 ChunkManager
,而 MetaspaceArena
我们后面会看到是每个 ClassLoader
独立私有的。所以,在讲 MetaspaceArena
之前,我们先要从另一个角度即 ClassLoader
加载类的角度出发,向下一层一层剖析到 MetaspaceArena
。
4.3.6. 类加载的入口 SystemDictionary
与保留所有 ClassLoaderData
的 ClassLoaderDataGraph
类加载的入口在全局唯一的 SystemDictionary
中,这里我们只是为了看一下类加载需要哪些参数,来搞清楚对应关系,不用关心细节,入口代码是:
https://github.com/openjdk/jdk/blob/jdk-21%2B11/src/hotspot/share/classfile/systemDictionary.cpp
InstanceKlass* SystemDictionary::resolve_from_stream(ClassFileStream* st,
Symbol* class_name,
Handle class_loader,
const ClassLoadInfo& cl_info,
TRAPS) {
//隐藏类与普通类的加载方式不同,隐藏类是 JEP 371: Hidden Classes 引入的,Java 15 中发布的新特性
if (cl_info.is_hidden()) {
return resolve_hidden_class_from_stream(st, class_name, class_loader, cl_info, CHECK_NULL);
} else {
return resolve_class_from_stream(st, class_name, class_loader, cl_info, CHECK_NULL);
}
}
可以看到,加载类需要以下参数:
-
ClassFileStream* st
:类文件流 -
Symbol* class_name
:加载的类的名称 -
Handle class_loader
:是哪个类加载器 -
const ClassLoadInfo& cl_info
:类加载器信息
在加载类的时候,SystemDictionary
会获取类加载器的 ClassLoaderData
,ClassLoaderData
是每个类加载器私有的。
https://github.com/openjdk/jdk/blob/jdk-21%2B11/src/hotspot/share/classfile/systemDictionary.cpp
//通过类加载器获取对应的 `ClassLoaderData`
ClassLoaderData* SystemDictionary::register_loader(Handle class_loader, bool create_mirror_cld) {
if (create_mirror_cld) {
return ClassLoaderDataGraph::add(class_loader, true);
} else {
// 如果是 null,代表是 BootstrapClassLoader,使用全局的 BootstrapClassLoader 对应的 ClassLoaderData
return (class_loader() == NULL) ? ClassLoaderData::the_null_class_loader_data() :
//否则,从 ClassLoaderDataGraph 寻找或者创建 class_loader 对应的 ClassLoaderData
ClassLoaderDataGraph::find_or_create(class_loader);
}
}
ClassLoaderDataGraph
保存着所有的 ClassLoaderData
,这个主要用来遍历每个类加载器,以及获取每个类加载器加载的类的信息,还有遍历类加载器加载的类,例如 jcmd
命令中的 VM.classloaders
以及 VM.classloader_stats
就是这么实现的。但是,我们就不纠结于 ClassLoaderDataGraph
的细节了,这不是咱们的重点。
4.3.7. 每个类加载器私有的 ClassLoaderData
以及 ClassLoaderMetaspace
ClassLoaderData
本身直接原生堆上面分配,Native Memory Tracking 中属于 Class
那一类别,即元空间的加载类占用的空间。这就很合理了,不加载类就不会有 ClassLoaderData
。
https://github.com/openjdk/jdk/blob/jdk-21%2B11/src/hotspot/share/classfile/classLoaderData.hpp
class ClassLoaderData : public CHeapObj<mtClass>
如前所述,ClassLoaderData
是每个类加载器私有的。ClassLoaderData
包含的元素众多,我们这里只关心它其中与元空间内存分配相关的,即 ClassLoaderMetaspace
:
https://github.com/openjdk/jdk/blob/jdk-21%2B11/src/hotspot/share/classfile/classLoaderData.hpp
ClassLoaderMetaspace * volatile _metaspace;
ClassLoaderMetaspace
本身直接原生堆上面分配,Native Memory Tracking 中属于 Class
那一类别,即元空间的加载类占用的空间。
https://github.com/openjdk/jdk/blob/jdk-21%2B11/src/hotspot/share/memory/classLoaderMetaspace.hpp
class ClassLoaderMetaspace : public CHeapObj<mtClass>
ClassLoaderMetaspace
有不同的类型(MetaspaceType
):
-
MetaspaceType::StandardMetaspaceType
:平台类加载器(Platform ClassLoader,Java 9 之前叫做 ext ClassLoader)以及应用类加载器(Application ClassLoader)的ClassLoaderMetaspace
-
MetaspaceType::BootMetaspaceType
:即根类加载器(Boostrap ClassLoader)的ClassLoaderMetaspace
-
MetaspaceType::ClassMirrorHolderMetaspaceType
:加载匿名类的类加载器的ClassLoaderMetaspace
-
MetaspaceType::ReflectionMetaspaceType
:反射调用的前几次通过 jni native 调用,超过一定次数会优化成生成字节码类调用。加载这些字节码类的类加载器是jdk.internal.reflect.DelegatingClassLoader
,这个类加载器的ClassLoaderMetaspace
类型就是ReflectionMetaspaceType
。
ClassLoaderMetaspace
和 MetaspaceContext
类似,如果压缩类指针开启,那么 ClassLoaderMetaspace
包含一个类元空间的 MetaspaceArena
和一个数据元空间的 MetaspaceArena
,否则只有一个数据元空间的 MetaspaceArena
。
4.3.8. 管理正在使用的 MetaChunk
的 MetaspaceArena
MetaspaceArena
本身直接原生堆上面分配,Native Memory Tracking 中属于 Class
那一类别,即元空间的加载类占用的空间。这也是肯定的,因为跟着类加载器存在
class MetaspaceArena : public CHeapObj<mtClass>
MetaspaceArena
结构如下所示:
MetaspaceArena
包含:
-
一个
MetachunkList
:管理在该MetaspaceArena
分配的MetaChunk
的列表,列表的第一个是当前分配内存的MetaChunk
。 -
当前
MetaspaceArena
的ArenaGrowthPolicy
:在当前分配内存的MetaChunk
不够分配的时候,申请新的MetaChunk
的大小。 -
Freeblocks
: 在当前分配内存的MetaChunk
不够分配的时候,需要分配新的MetaChunk
。当前的MetaChunk
剩余空间放入Freeblocks
。
Freeblocks
包含一个 BinList32
和一个 BlockTree
。大小大于 33 字节的进入 BlockTree
,否则进入 BinList32
。
BinList32
类似于 FreeChunkListVector
,是一个链表的数组,同样大小的内存在同一数组下标的链表。
BlockTree
是一个在 Binary Search Tree(BST)的基础上,同样内存的节点在二叉树节点的后面形成链表的数据结构。
不同的类加载器类型的类元空间的 MetaspaceArena
与数据元空间的 MetaspaceArena
的 ArenaGrowthPolicy
不同:
1.根类加载器(Boostrap ClassLoader)的 ClassLoaderMetaspace
类元空间的 MetaspaceArena
的 ArenaGrowthPolicy
:MetachunkList
每次增长都是申请大小为 256K
的 MetaChunk
https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace/metaspaceArenaGrowthPolicy.cpp
static const chunklevel_t g_sequ_boot_class[] = {
chunklevel::CHUNK_LEVEL_256K
// .. repeat last
};
2.根类加载器(Boostrap ClassLoader)的 ClassLoaderMetaspace
数据元空间的 MetaspaceArena
的 ArenaGrowthPolicy
:MetachunkList
的第一个 MetaChunk
大小为 4M
,之后每个新 MetaChunk
都是 1M
:
https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace/metaspaceArenaGrowthPolicy.cpp
static const chunklevel_t g_sequ_boot_non_class[] = {
chunklevel::CHUNK_LEVEL_4M,
chunklevel::CHUNK_LEVEL_1M
// .. repeat last
};
3.平台类加载器(Platform ClassLoader,Java 9 之前叫做 ext ClassLoader)以及应用类加载器(Application ClassLoader)的 ClassLoaderMetaspace
类元空间的 MetaspaceArena
的 ArenaGrowthPolicy
:MetachunkList
的第一个 MetaChunk
大小为 2K
,第二个也是 2K
,第三个 4K
,第四个为 8K
,之后每个新 MetaChunk
都是 16K
(不要惯着cao袭的人!):
https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace/metaspaceArenaGrowthPolicy.cpp
static const chunklevel_t g_sequ_standard_class[] = {
chunklevel::CHUNK_LEVEL_2K,
chunklevel::CHUNK_LEVEL_2K,
chunklevel::CHUNK_LEVEL_4K,
chunklevel::CHUNK_LEVEL_8K,
chunklevel::CHUNK_LEVEL_16K
// .. repeat last
};
4.平台类加载器(Platform ClassLoader,Java 9 之前叫做 ext ClassLoader)以及应用类加载器(Application ClassLoader)的 ClassLoaderMetaspace
数据元空间的 MetaspaceArena
的 ArenaGrowthPolicy
:MetachunkList
的第一个 MetaChunk
大小为 4K
,第二个也是 4K
,第三个 4K
,第四个为 8K
,之后每个新 MetaChunk
都是 16K
:
https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace/metaspaceArenaGrowthPolicy.cpp
static const chunklevel_t g_sequ_standard_non_class[] = {
chunklevel::CHUNK_LEVEL_4K,
chunklevel::CHUNK_LEVEL_4K,
chunklevel::CHUNK_LEVEL_4K,
chunklevel::CHUNK_LEVEL_8K,
chunklevel::CHUNK_LEVEL_16K
// .. repeat last
};
5.加载匿名类的类加载器的 ClassLoaderMetaspace
类元空间的 MetaspaceArena
的 ArenaGrowthPolicy
:MetachunkList
每次增长都是申请大小为 1K
的 MetaChunk
:
https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace/metaspaceArenaGrowthPolicy.cpp
static const chunklevel_t g_sequ_anon_class[] = {
chunklevel::CHUNK_LEVEL_1K,
// .. repeat last
};
6.加载匿名类的类加载器的 ClassLoaderMetaspace
数据元空间的 MetaspaceArena
的 ArenaGrowthPolicy
:MetachunkList
每次增长都是申请大小为 1K
的 MetaChunk
:
https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace/metaspaceArenaGrowthPolicy.cpp
static const chunklevel_t g_sequ_anon_non_class[] = {
chunklevel::CHUNK_LEVEL_1K,
// .. repeat last
};
7.DelegatingClassLoader
的 ClassLoaderMetaspace
类元空间的 MetaspaceArena
的 ArenaGrowthPolicy
:MetachunkList
每次增长都是申请大小为 1K
的 MetaChunk
:
https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace/metaspaceArenaGrowthPolicy.cpp
static const chunklevel_t g_sequ_refl_class[] = {
chunklevel::CHUNK_LEVEL_1K,
// .. repeat last
};
8.DelegatingClassLoader
的 ClassLoaderMetaspace
数据元空间的 MetaspaceArena
的 ArenaGrowthPolicy
:MetachunkList
的第一个 MetaChunk
大小为 2K
,之后每个新 MetaChunk
都是 1K
:
https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace/metaspaceArenaGrowthPolicy.cpp
static const chunklevel_t g_sequ_refl_non_class[] = {
chunklevel::CHUNK_LEVEL_2K,
chunklevel::CHUNK_LEVEL_1K
// .. repeat last
};
4.3.9. 元空间内存分配流程
我们过一下元空间内存分配流程,我们会忽略一些 GC 相关的还有并发安全的细节,否则涉及的概念太多,一下说不过来,这些细节,会在以后的系列中详细提到。
4.3.9.1. 类加载器到 MetaSpaceArena
的流程
当类加载器加载类的时候,需要从对应的 ClassLoaderMetaspace
分配元空间进行存储。这个过程大概是:
图中有蓝色填充的方块是我们要重点分析的流程,我们先从从 MetaChunkArena 普通分配开始分析,尝试 GC 以及扩容元空间用于分配会涉及到元空间大小限制以及 GC 界限的概念,我们后面分析。这里对应的源码是:https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace.cpp#L899
整个流程如下:
-
首先,验证要分配的内存小于最大
MetaChunk
大小,即RootMetaChunk
大小,即ChunkLevel
= 0 的大小,即4MB
-
然后,走普通分配流程,判断当前分配的数据类型是类元空间的还是数据元空间的,分别到类元空间的
MetaSpaceArena
或者数据源空间MetaSpaceArena
进行分配。这是下一节我们要详细分析的。 -
如果普通分配失败,那么会触发
jdk.MetaspaceAllocationFailure
这个 JFR 事件,大家可以监控这个事件,去调整元空间大小减少由于元空间不足触发的 GC。触发之后,抄袭狗死全家 -
之后,尝试 GC,以及增大元空间的 GC 界限(元空间有最大大小限制,但是还有动态计算的 GC 界限,超过 GC 界限的话,第二步的普通分配也会失败)用于分配。这个流程我们后面会详细分析
-
最后,如果这样还是分配失败,那么就会抛出大名鼎鼎的
java.lang.OutOfMemoryError
, 触发jdk.MetaspaceOOM
这个 JFR 事件,这个我们也会详细分析。
我们先分析第二步的普通分配流程,其他的需要后续我们分析元空间大小限制的时候详细分析。
4.3.9.2. 从 MetaChunkArena
普通分配 - 整体流程
从 MetaChunkArena
普通分配的流程并不太复杂:
我们前面讲过 MetaspaceArena
的结构,如下所示:
对应的源码是 https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace/metaspaceArena.cpp#L222
,结合流程图,我们可以整理出一个简单的分配思路:
-
首先,尝试从
FreeBlocks
中分配(第一次分配,肯定FreeBlocks
里面没有可以分配的,直接进入下一分支),如果分配成功,直接返回 -
然后,尝试从
current chunk
分配(第一次分配肯定current chunk
为NULL
,直接进入下一分支): -
current chunk
如果有足够的空间,并且这些空间是 committed 的或者可以 commit 成功,那么从current chunk
分配。 -
current chunk
如果没有足够的空间,尝试current chunk
扩容,如果扩容成功,并且这些空间是 committed 的或者可以 commit 成功,那么从current chunk
分配。 -
如果扩容失败,或者 commit 失败,直接进入下一分支
-
前面分配失败,尝试从
ChunkManager
中申请一个新的MetaChunk
。 -
如果申请成功,则加入当前的
MetaChunkList
中,将current chunk
指向新的MetaChunk
,老的current chunk
的剩余空间回收到FreeBlocks
中,然后从新的current chunk
分配。
我们接下来分析 FreeBlocks
回收老的 current chunk
与用于后续分配的流程
4.3.9.3. 从 MetaChunkArena
普通分配 - FreeBlocks
回收老的 current chunk
与用于后续分配的流程
首先我们前面提到过:Freeblocks
包含一个 BinList32
和一个 BlockTree
。大小大于 33 字节的进入 BlockTree
,否则进入 BinList32
。
BinList32
类似于 FreeChunkListVector
,是一个链表的数组,同样大小的内存在同一数组下标的链表。
BlockTree
是一个在 Binary Search Tree(BST)的基础上,同样内存的节点在二叉树节点的后面形成链表的数据结构。
回收的流程十分简单,就是判断下 current chunk
的剩余空间,根据大小放到不同的数据结构:
对应的源码是 https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace/metaspaceArena.cpp#L60
:
-
计算
MetaChunk
剩余的committed
的空间(很简单,直接用committed
的位置减去已分配到的位置) -
判断剩余空间是否大于
FreeBlocks
最小内存块大小(即BinList32
最小大小即2KB
):对待抄袭狗不必手软 -
从
MetaChunk
分配剩余的committed
的空间,放入FreeBlocks
中 -
内存大于
BinList32
最大内存块大小即33KB
,放入BlockTree
,否则放入BinList32
4.3.9.4. 从 MetaChunkArena
普通分配 - 尝试从 FreeBlocks
分配
尝试从 FreeBlocks
分配即从其中的 BinList32
和 BlockTree
寻找是否有合适的内存,流程是:
对应的源码是 https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace/freeBlocks.cpp#L42
-
首先判断,要分配的内存大小是否大于
BinList32
最大内存块大小即33KB
:如果大于,就从BlockTree
查找不小于内存大小的最接近的内存块;如果不大于,就从BinList32
查找是否有对应大小的内存块。 -
如果找到了,计算
waste
,waste = 内存块大小 - 要分配的内存大小
。 -
判断
waste
大于FreeBlocks
最小内存块大小(即BinList32
最小大小即2KB
)。如果大于,则要回收,和前面回收MetaChunk
的流程一样将剩余的内存放回FreeBlocks
。
4.3.9.5. 从 MetaChunkArena
普通分配 - 尝试扩容 current chunk
对应的源码是 https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace/metaspaceArena.cpp#L171
-
enlarge_chunks_in_place
是否是true
,不是的话直接结束,不过前面我们说过,目前JVM
是代码里写死的true
-
判断是否
current chunk
已经是RootMetaChunk
(代表已经不能扩容了),如果是,直接结束 -
current chunk
已使用大小加上要分配的内存大小是否大于RootMetaChunk
的大小即4MB
(代表已经不能扩容了),如果是,直接结束 -
找到大于
current chunk
已使用大小,加上要分配的内存大小的最接近的ChunkLevel
(记为new_level
) -
判断
new_level
是否小于current chunk
的ChunkLevel
减 1,代表要扩容到的大小大于原始大小的 2 倍以上(不允许一下子扩容两倍以上),如果是,直接结束 -
current chunk
是否是leader
(这个概念后面分析到使用ChunkManager
分配新的MetaChunk
会提到),只有leader
可以扩容,如果不是,直接结束(xigao 必死) -
判断扩容策略中申请下一个
MetaChunk
的ChunkLevel
是否大于current chunk
的(代表新申请的比当前的小),如果是,也直接结束。我们这里强调下为啥扩容策略(ArenaGrowthPolicy
)中申请下一个MetaChunk
的ChunkLevel
大于current chunk
(代表新申请的比当前的小)的话,我们就不扩容了。前面我们列出了各种类型的ClassLoader
的不同空间的扩容策略,例如DelegatingClassLoader
的ClassLoaderMetaspace
数据元空间的MetaspaceArena
的ArenaGrowthPolicy
:MetachunkList
的第一个MetaChunk
大小为2K
,之后每个新MetaChunk
都是1K
。假设current chunk
是第一个,这里下一个MetaChunk
的ChunkLevel
是1K
对应的ChunkLevel
,大于current chunk
当前的ChunkLevel
,所以优先申请新的,而不是扩容。之后到第二个之后,由于之后每个新的MetaChunk
都是1K
,就会尝试扩容而不是申请新的了。 -
使用
ChunkManager
尝试扩容current chunk
到new_level
。具体扩容流程,后面会分析。
4.3.9.6. 从 MetaChunkArena
普通分配 - 从 ChunkManager
分配新的 MetaChunk
回顾下 ChunkManager
结构:
从 ChunkManager
分配新的 MetaChunk
,首先会从 FreeChunkListVector
尝试搜索有没有合适的。FreeChunkListVector
如我们之前所述,是一个以 ChunkLevel
为下标的数组,每个数组都是一个 MetaChunk
的链表。commit
多的 MetaChunk
放在链表开头,完全没有 commit
的放在链表末尾。
对应的源码是 https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace/chunkManager.cpp#L137
-
计算两个值:
max_level = 大于当前申请内存大小最接近的 ChunkLevel (即新的 MetaChunk 最小多大)
,preferred_level = "根据扩容策略(ArenaGrowthPolicy)下一个 MetaChunk 多大" 与 "max_level" 中小的那个值(也就是更大的 MetaChunk 大小)
-
优先搜索并使用
FreeChunkListVector
中那些已经commit
足够内存的MetaChunk
-
正序遍历(即
ChunkLevel
从小到大,大小从大到小)ChunkManager
的FreeChunkListVector
里面的数组 (从preferred_level
到max_level
与preferred_level
+ 2 中比较小的值,即最多搜索 3 个ChunkLevel
,根据前面的分析我们知道ChunkLevel
就是数组下标),寻找对应的MetaChunk
链表,正序遍历每个链表(我们前面提到过,commit
多的MetaChunk
放在开头),直到找到commit
大小大于申请内存大小的(chaoxi 死的更惨) -
逆序遍历(即
ChunkLevel
从大到小,大小从小到大)ChunkManager
的FreeChunkListVector
里面的数组 (从preferred_level
到最大的ChunkLevel
,即RootMetaChunk
的大小,即 4MB),寻找对应的MetaChunk
链表,正序遍历每个链表(我们前面提到过,commit
多的MetaChunk
放在开头),直到找到commit
大小大于申请内存大小的 -
正序遍历(即
ChunkLevel
从小到大,大小从大到小)ChunkManager
的FreeChunkListVector
里面的数组 (从preferred_level
到max_level
),寻找对应的MetaChunk
链表,正序遍历每个链表(我们前面提到过,commit
多的MetaChunk
放在开头),直到找到commit
大小大于申请内存大小的 -
如果搜索不到已经
commit
足够内存的MetaChunk
,就退而求其次,寻找FreeChunkListVector
存在的MetaChunk
-
正序遍历(即
ChunkLevel
从小到大,大小从大到小)ChunkManager
的FreeChunkListVector
里面的数组 (从preferred_level
到max_level
),寻找对应的MetaChunk
链表,正序遍历每个链表,直到找到一个MetaChunk
-
逆序遍历(即
ChunkLevel
从大到小,大小从小到大)ChunkManager
的FreeChunkListVector
里面的数组 (从preferred_level
到最大的ChunkLevel
,即RootMetaChunk
的大小,即 4MB),寻找对应的MetaChunk
链表,正序遍历每个链表,直到找到一个MetaChunk
-
如果前面没有找到合适的,从
VirtualSpaceList
申请新的RootMetaChunk
-
将
RootMetahChunk
分割成需要的ChunkLevel
大小,之后将分割剩余的放入FreeChunkListVector
,这个过程我们接下来会详细分析 -
判断
new_chunks_are_fully_committed
是否为true
,如果为true
则commit
整个MetaChunk
的所有内存,否则commit
要分配的大小。如果commit
失败了(证明可能到达元空间 GC 界限或者元空间大小上限),那么将MetaChunk
退回。
4.3.9.7. 从 MetaChunkArena
普通分配 - 从 ChunkManager
分配新的 MetaChunk
- 从 VirtualSpaceList
申请新的 RootMetaChunk
对应的源码是 https://github.com/openjdk/jdk/blob/jdk-21+13/src/hotspot/share/memory/metaspace/virtualSpaceList.cpp#L110
-
首先判断当前
_first_node
是否有空间分配新的RootMetaChunk
,如果有则从_first_node
上面分配新的RootMetaChunk
-
如果没有,判断是否可以扩展新的
VirtualSpaceNode
(类元空间不可以,数据元空间可以),如果可以则申请Reserve
新的VirtualSpaceNode
作为新的_first_node
,之后从_first_node
上面分配新的RootMetaChunk
4.3.9.8. 从 MetaChunkArena
普通分配 - 从 ChunkManager
分配新的 MetaChunk
- 将 RootMetaChunk
切割成为需要的 MetaChunk
这里的流程如果用流程图容易把人绕晕,我们这里举一个例子,比如我们想要一个 ChunkLevel
为 3 的 MetaChunk
:
对应的源码是 https://github.com/openjdk/jdk/blob/jdk-21%2B13/src/hotspot/share/memory/metaspace/chunkManager.cpp#L78
将 RootMetaChunk
切割成 ChunkLevel
为 3 的 MetaChunk
的流程:
-
RootMetaChunk
的ChunkLevel
为 0,对半分成两个ChunkLevel
为 1 的,第一个为leader
,第二个为follower
。 -
将上一步的
leader
对半成两个ChunkLevel
为 2 的,第一个为leader
,第二个为follower
。 -
将上一步的
leader
对半成两个ChunkLevel
为 3 的,第一个为leader
,第二个为follower
。 -
将第三步的
leader
返回,用于分配。将第一、二、三步生成的follower
放入FreeChunkListVector
用于前面 4.3.9.6 章节分析的ChunkManager
先从FreeChunkListVector
搜索合适的MetaChunk
分配。
4.3.9.9. MetaChunk
回收 - 不同情况下, MetaChunk
如何放入 FreeChunkListVector
我们前面主要分析的是分配,那么 MetaChunk
如何回收呢?从前面的流程我们很容易推测出来,其实就是放回 FreeChunkListVector
。放回的流程如果用流程图容易把人绕晕,我们还是举例子区分不同情况。其实核心思路就是,放回的时候,尽量将 MetaChunk
向上合并之后放回:
对应的源码是 https://github.com/openjdk/jdk/blob/jdk-21%2B13/src/hotspot/share/memory/metaspace/chunkManager.cpp#L255
这里我们有两个例子:
-
我们有一个
ChunkLevel
为 3 的MetaChunk
要回收,但是它不是leader
,不能向上合并。只有leader
才会尝试向上合并。这里直接放入FreeChunkListVector
。 -
我们又有一个
ChunkLevel
为 3 的MetaChunk
要回收,它是leader
。它会尝试向上合并。查看它的follower
是否是Free
的。如果是Free
的,他肯定首先在ChunkManager
的FreeChunkListVector
中, 从FreeChunkListVector
取出,与这个leader
合并为一个新的ChunkLevel
为 2。之后,它还是leader
,尝试继续合并,但是它的follower
不是空闲的,就不能继续合并了。在这里停止,放入FreeChunkListVector
。
4.3.10. ClassLoaderData
回收
在 GC 判断一个类加载器可以回收(该类加载器加载的类没有任何对象,该类加载器的对象也没有任何强引用指向它)的时候,不会立刻回收 ClassLoaderData
,而是对应的 ClassLoaderData
的 is_alive()
就会返回 false
。JVM 会定期遍历 ClassLoaderDataGraph
遍历每个 ClassLoaderData
判断 is_alive()
是否是 false
,如果是的话会放入待回收的链表中。之后在不同 GC 的不同阶段,遍历这个链表将 ClassLoaderData
回收掉。
ClassLoaderData
被回收的过程如下所示:
`
ClassLoaderData
会记录所有加载的类与相关的数据(前文提到的 Klass
等等对象),所以它的析构函数中会将这些加载的数据的内存全部释放到它独有的 MetaSpaceArena
的 FreeBlocks
中,这些内存就是通过之前我们分析的流程分配的,由于之前的空间都是从 MetaspaceArena
的 MetaChunkList
中的 MetaChunk
分配的,这样的话这些 MetaChunk
的空间也都不再占用了。当然,也会把前面提到的 ClassLoaderData
独有的数据结构释放掉,还没有利用的 MetaWord
放回 ChunkManager
中。然后,清除掉它私有的 ClassLoadMetaSpace
。根据前文分析我们知道 ClassLoaderMetaspace
在开启压缩类空间的情况下包括一个类元空间的 MetaspaceArena
和一个数据元空间的 MetaspaceArena
。这两个 MetaspaceArena
分别要清理掉。MetaspaceArena
的析构函数会把 FreeBlocks
中的每个 MetaWord
都放回 ChunkManager
,注意这里包含之前 ClassLoaderData
放回的加载类相关数据占用的空间,最后清理掉 FreeBlocks
。(你洗稿的样子真丑。)
4.4. 元空间分配与回收流程举例
我们前面介绍了元空间的组成元素,但是没有将他们完整的串联起来,我们这里举一个简单的例子,将之前的所有元素串联起来。
通过前面的分析之后,我们知道元空间的主要抽象包括:
-
全局唯一的类元空间
MetaspaceContext
,它包括: -
一个
VirtualSpaceList
,类元空间的VirtualSpaceList
只有一个VirtualSpaceNode
-
一个
ChunkManager
-
全局唯一的数据元空间
MetaspaceContext
,它包括: -
一个
VirtualSpaceList
,数据元空间的VirtualSpaceList
才是一个真正的VirtualSpaceNode
的链表 -
一个
ChunkManager
-
每个类加载器都有一个独有的
ClassLoaderData
,它包含自己独有的ClassLoaderMetaspace
,ClassLoaderMetaspace
包含: -
一个类元空间
MetaspaceArena
-
一个数据元空间
MetaspaceArena
假设我们全局只有一个类加载器,即类加载器 1,并且 UseCompressedClassPointers
为 true
,那么我们可以假设当前元空间的初始结构为:
接下来我们来看看详细的例子
4.4.1. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
1~2.首先,类加载器 1 从它私有的 ClassLoaderData
去分配空间,由于要分配的是类元空间的,所以会从私有的类元空间的 MetaspaceArena
去分配空间。
3.搜索 FreeBlocks
查看是否有可用空间,但是这是第一次分配,肯定没有。
4.尝试从 _current_chunk
分配,但是由于是第一次分配,_current_chunk
是 NULL
。
5.将要分配的内存(1023 字节)按照 8 字节对齐,即 1024 字节。大于等于它的最小 ChunkLevel
为 12,即 max_level = 12
。假设这个类加载器是 Bootstrap ClassLoader
,其实是啥无所谓,我们主要是想找一个对应的 ArenaGrowthPolicy
,根据这个 ArenaGrowthPolicy
,第一个要申请的 MeataChunk
大小是 256KB
,对应的 ChunkLevel
为 4,preferred_level
是 max_level
与这个之间相比小的那个,即 4。我们从类元空间的 ChunkManager
申请这么大的 MetaChunk
,对应的 ChunkLevel
是 4
6.首先搜索 ChunkManager
的 FreeChunkListVector
,看看是否有合适的。但是这是第一次分配,肯定没有。
7.尝试从类元空间的 VirtualSpaceList
申请 RootMetaChunk
用于分配。
8.从类元空间的 VirtualSpaceList
的唯一一个 VirtualSpaceNode
分配 RootMetaChunk
,对半切分到 ChunkLevel
为 4 的 MetaChunk
,返回 leader
的 ChunkLevel
为 4 的 MetaChunk
作为 _current_chunk
用于分配。分割出来剩下的 ChunkLevel
为 1, ChunkLevel
为 2, ChunkLevel
为 3, ChunkLevel
为 4 的各一个放入 FreeChunkListVector
中
9.commit
要分配的内存大小,如果 AlwaysPreTouch
是开启的,那么就会像之前我们分析 Java 堆内存那样进行 pre touch。
10.从 _current_chunk
分配内存,分配成功。
4.4.2. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
1~2.首先,类加载器 1 从它私有的 ClassLoaderData
去分配空间,由于要分配的是类元空间的,所以会从私有的类元空间的 MetaspaceArena
去分配空间。
3.搜索 FreeBlocks
查看是否有可用空间,目前还是没有。
4.尝试从 _current_chunk
分配,将要分配的内存(1023 字节)按照 8 字节对齐,即 1024 字节,_current_chunk
空间足够。
5.commit
要分配的内存大小,如果 AlwaysPreTouch
是开启的,那么就会像之前我们分析 Java 堆内存那样进行 pre touch。
6.从 _current_chunk
分配内存,分配成功。
4.4.3. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
1~2.首先,类加载器 1 从它私有的 ClassLoaderData
去分配空间,由于要分配的是类元空间的,所以会从私有的类元空间的 MetaspaceArena
去分配空间。
3.搜索 FreeBlocks
查看是否有可用空间,目前还是没有。
4.尝试从 _current_chunk
分配,将要分配的内存(264KB)按照 8 字节对齐,即 264KB,_current_chunk
空间不足,但是如果扩容一倍就足够,所以尝试扩大 _current_chunk
。
5.查看他的兄弟 MetaChunk
是否是空闲的,当然是,从 FreeChunkListVector
移除这个 MetaChunk
,将这个兄弟 MetaChunk
与 _current_chunk
。_current_chunk
的大小变为原来 2 倍,_current_chunk
的 ChunkLevel
减 1 之后为 3。
6.commit
要分配的内存大小,如果 AlwaysPreTouch
是开启的,那么就会像之前我们分析 Java 堆内存那样进行 pre touch。
7.从 _current_chunk
分配内存,分配成功。
4.4.4. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
1~2.首先,类加载器 1 从它私有的 ClassLoaderData
去分配空间,由于要分配的是类元空间的,所以会从私有的类元空间的 MetaspaceArena
去分配空间。
3.搜索 FreeBlocks
查看是否有可用空间,目前还是没有。
4.尝试从 _current_chunk
分配,将要分配的内存(2MB)按照 8 字节对齐,即 2MB,_current_chunk
空间不足,扩容一倍也不够,所以就不尝试扩大 _current_chunk
了。
5.要分配的大小是 2MB,大于等于它的最小 ChunkLevel
为 1,即 max_level = 1
。根据 ArenaGrowthPolicy
,下一个要申请的 MeataChunk
大小是 256KB
,对应的 ChunkLevel
为 4,preferred_level
是 max_level
与这个之间相比小的那个,即 1。从 FreeChunkListVector
寻找,发现有合适的,将其作为 current_chunk
进行分配。
6.commit
要分配的内存大小,如果 AlwaysPreTouch
是开启的,那么就会像之前我们分析 Java 堆内存那样进行 pre touch。
7.之前的 current_chunk
的剩余空间大于 2 bytes,需要回收到 FreeBlocks
中。由于大于 33 bytes,需要放入 BlockTree
。
8.从 _current_chunk
分配内存,分配成功。
4.4.5. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
1~2.首先,类加载器 1 从它私有的 ClassLoaderData
去分配空间,由于要分配的是类元空间的,所以会从私有的类元空间的 MetaspaceArena
去分配空间。
3.将要分配的内存(128KB)按照 8 字节对齐,即 128KB。搜索 FreeBlocks
查看是否有可用空间,目前 FreeBlocks
有合适的可以分配。
4.commit
要分配的内存大小,如果 AlwaysPreTouch
是开启的,那么就会像之前我们分析 Java 堆内存那样进行 pre touch。
5.从 FreeBlocks
的 BlockTree
的节点分配内存,分配成功。为啥要打击抄袭,稿主被抄袭太多所以断更很久。
4.4.6. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
1~2.首先,类加载器 1 从它私有的 ClassLoaderData
去分配空间,由于要分配的是类元空间的,所以会从私有的类元空间的 MetaspaceArena
去分配空间。
3.搜索 FreeBlocks
查看是否有可用空间,但是这是第一次分配,肯定没有。
4.尝试从 _current_chunk
分配,但是由于是第一次分配,_current_chunk
是 NULL
。
5.将要分配的内存(1023 字节)按照 8 字节对齐,即 1024 字节。大于等于它的最小 ChunkLevel
为 12,即 max_level = 12
。假设这个类加载器是 Bootstrap ClassLoader
,其实是啥无所谓,我们主要是想找一个对应的 ArenaGrowthPolicy
。根据 ArenaGrowthPolicy
,下一个要申请的 MeataChunk
大小是 256KB
,对应的 ChunkLevel
为 4,preferred_level
是 max_level
与这个之间相比小的那个,即 4。
6.首先搜索 ChunkManager
的 FreeChunkListVector
,看看是否有合适的。搜索到之前放入的 ChunkLevel
为 3 的。将其取出作为 _current_chunk
。
7.commit
要分配的内存大小,如果 AlwaysPreTouch
是开启的,那么就会像之前我们分析 Java 堆内存那样进行 pre touch。
8.从 _current_chunk
分配内存,分配成功。
4.4.7. 然后类加载器 1 被 GC 回收掉
1.将类加载器 1 消耗的所有空间放回 FreeBlocks
中。前面分配了 1024 bytes, 1024 bytes, 264KB, 2MB 还有 128KB,这次放回 BlockTree
,BlockTree
之前本身还有剩余一个 118KB。整体如图所示。
2.这样一来,原来 MetaspaceArena
中 MetaChunkList
管理的 MetaChunk
的内存全都空闲了。
-
将
MetaChunkList
管理的MetaChunk
放回全局的ChunkManager
的FreeChunkListVector
中。并且放回的都是有commit
过内存的,会放在每个ChunkLevel
对应的MetaChunk
链表的开头。
4.4.8. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
1~2.首先,类加载器 1 从它私有的 ClassLoaderData
去分配空间,由于要分配的是类元空间的,所以会从私有的类元空间的 MetaspaceArena
去分配空间。
3.搜索 FreeBlocks
查看是否有可用空间,目前还是没有。为啥要打击抄袭,稿主被抄袭太多所以断更很久。
4.尝试从 _current_chunk
分配,空间不足。并且 _current_chunk
不是 leader
,所以就不尝试扩容了。
5.将要分配的内存(1MB)按照 8 字节对齐,即 1MB。要分配的大小是 1MB,大于等于它的最小 ChunkLevel
为 2,即 max_level = 2
。根据 ArenaGrowthPolicy
,下一个要申请的 MeataChunk
大小是 256KB
,对应的 ChunkLevel
为 4,preferred_level
是 max_level
与这个之间相比小的那个,即 2。从 FreeChunkListVector
寻找,发现有合适的,将其作为 current_chunk
进行分配。这个其实就是之前从类加载器 1 回收的。
6.因为是之前回收的,里面的内存都是 committed
了,所以这里就不用 commit
了。
7.之前的 current_chunk
的剩余空间大于 2 bytes,需要回收到 FreeBlocks
中。由于大于 33 bytes,需要放入 BlockTree
。
8.从 _current_chunk
分配内存,分配成功。
4.5. 元空间大小限制与动态伸缩
前文我们没有提到,如何限制元空间的大小,其实就是限制 commit
的内存大小。元空间的限制不只是受限于我们的参数配置,并且前面我们提到了,元空间的内存回收也比较特殊,元空间的内存基本都是每个类加载器的 ClassLoaderData
申请并管理的,在类加载器被 GC 回收后,ClassLoaderData
管理的这些元空间也会被回收掉。所以,GC 是可能触发一部分元空间被回收了。所以元空间在设计的时候,还有一个动态限制 _capacity_until_GC
,即触发 GC 的元空间占用大小。当要分配的空间导致元空间整体占用超过这个限制的时候,尝试触发 GC。这个动态限制也会在每次 GC 的时候动态扩大或者缩小。动态扩大以及缩小
我们先回顾下之前提过的参数配置:
-
MetaspaceSize
:初始元空间大小,也是最小元空间大小。后面元空间大小伸缩的时候,不会小于这个大小。默认是 21M。 -
MaxMetaspaceSize
:最大元空间大小,默认是无符号 int 最大值。 -
MinMetaspaceExpansion
:每次元空间大小伸缩的时候,至少改变的大小。默认是 256K。 -
MaxMetaspaceExpansion
:每次元空间大小伸缩的时候,最多改变的大小。默认是 4M。 -
MaxMetaspaceFreeRatio
:最大元空间空闲比例,默认是 70,即 70%。 -
MinMetaspaceFreeRatio
:最小元空间空闲比例,默认是 40,即 40%。
4.5.1. CommitLimiter
的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC
CommitLimiter
是一个全局单例,用来限制元空间可以 commit
的内存大小。每次分配元空间 commit
内存的时候,都会调用 CommitLimiter::possible_expansion_words
方法,这个方法会检查:
-
当前元空间已经
commit
的内存大小加上要分配的大小是否超过了MaxMetaspaceSize
-
当前元空间已经
commit
的内存大小加上要分配的大小是否超过了_capacity_until_GC
,超过了就尝试触发 GC
尝试 GC 的核心逻辑是:
-
重新尝试分配
-
如果还是分配失败,检查
GCLocker
是否锁定禁止 GC,如果是的话,首先尝试提高_capacity_until_GC
进行分配,分配成功直接返回,否则需要阻塞等待GCLocker
释放 -
如果没有锁定,尝试触发 GC,之后回到第 1 步 (这里有个小参数
QueuedAllocationWarningCount
,如果尝试触发 GC 的次数超过这个次数,就会打印一条警告日志,当然QueuedAllocationWarningCount
默认是 0,不会打印,并且触发多次 GC 也无法满足的概率比较低)
4.5.2. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC
在 JVM 初始化的时候,_capacity_until_GC
先会设置为 MaxMetaspaceSize
,因为 JVM 初始化的时候会加载很多类,并且这时候要避免触发 GC。在初始化之后,将 _capacity_until_GC
设置为当前元空间占用大小与 MetaspaceSize
中比较大的那个值。同时,还会初始化一个 _shrink_factor
,这个 _shrink_factor
主要是如果需要缩小元空间大小,每次缩小的比例。洗稿的狗也遇到不少
之后,在每次 GC 回收之后,需要重新计算新的 _capacity_until_GC
:
-
读取
crrent_shrink_factor = _shrink_factor
,统计当前元空间使用的空间used_after_gc
。 -
首先看是否需要扩容:
-
先使用
MinMetaspaceFreeRatio
最小元空间空闲比例计算minimum_free_percentage
和maximum_used_percentage
,看是否需要扩容。 -
计算当前元空间至少要多大
minimum_desired_capacity
:使用当前元空间使用的空间used_after_gc
除以maximum_used_percentage
,并且保证它不小于初始元空间大小MetaspaceSize
,不大于最大元空间大小MaxMetaspaceSize
。 -
如果当前的
_capacity_until_GC
小于计算的当前元空间至少要多大minimum_desired_capacity
,那么就查要扩容的空间是否大于等于配置MinMetaspaceExpansion
,以及小于等于MaxMetaspaceExpansion
,只有满足才会真正扩容。 -
扩容其实就是增加
_capacity_until_GC
-
然后看是否需要缩容:
-
使用
MaxMetaspaceFreeRatio
最大元空间空闲比例计算minimum_free_percentage
和maximum_used_percentage
,看是否需要缩容。 -
计算当前元空间至少要多大
maximum_desired_capacity
:使用当前元空间使用的空间used_after_gc
除以maximum_used_percentage
,并且保证它不小于初始元空间大小MetaspaceSize
,不大于最大元空间大小MaxMetaspaceSize
。 -
如果当前的
_capacity_until_GC
大于计算的当前元空间至少要多大maximum_desired_capacity
,计算shrink_bytes
=_capacity_until_GC
减去maximum_desired_capacity
。 -
_shrink_factor
初始为 0,之后为 10%,之后每次翻 4 倍,直到 100%。扩容的大小为shrink_bytes
乘以这个百分比 -
如果缩容大于等于配置
MinMetaspaceExpansion
,以及小于等于MaxMetaspaceExpansion
,并且缩容后不会小于初始元空间大小MetaspaceSize
,就会缩容。 -
缩容其实就是减少
_capacity_until_GC
我们还可以看出,如果我们设置 MinMetaspaceFreeRatio
为 0,那么就不会扩容,如果设置 MaxMetaspaceFreeRatio
为 100,那么就不会缩容。_capacity_until_GC
就不会因为 GC 更改。
4.6. jcmd VM.metaspace
元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解
4.6.1. jcmd <pid> VM.metaspace
元空间说明
通过 jcmd <pid> VM.metaspace
命令可以查看对应 JVM 进程的元空间当前的详细使用情况,返回内容是:
1.元空间从 MetaChunk
角度的使用统计信息
Total Usage - 1383 loaders, 33006 classes (1361 shared):
Non-Class: 7964 chunks, 150.83 MB capacity, 150.77 MB (>99%) committed, 150.21 MB (>99%) used, 562.77 KB ( <1%) free, 6.65 KB ( <1%) waste , deallocated: 869 blocks with 249.52 KB
Class: 2546 chunks, 21.00 MB capacity, 20.93 MB (>99%) committed, 20.21 MB ( 96%) used, 741.42 KB ( 3%) free, 216 bytes ( <1%) waste , deallocated: 1057 blocks with 264.88 KB
Both: 10510 chunks, 171.83 MB capacity, 171.70 MB (>99%) committed, 170.42 MB (>99%) used, 1.27 MB ( <1%) free, 6.86 KB ( <1%) waste , deallocated: 1926 blocks with 514.41 KB
意思是:
-
一共 1383 个类加载器,加载了 33006 个类(其中 1361 个是共享类)。
-
capacity 是指
MetaChunk
的总容量大小(Reserved
内存);committed 是指这些MetaChunk
中committed
的内存大小,也就是实际占用系统物理内存是这么大(虽然可能会有点细微差异,参考本篇文章的第二章);used 是指这些MetaChunk
实际使用的大小,肯定比committed
的要小;free 是指剩余的大小;committed = used + free + waste;deallocated 是指回收到FreeBlocks
的大小,属于 free 的一部分,另一部分就是MetaChunk
中committed
但是还没使用的部分;waste 是指浪费的大小(前面我们提到了什么造成的浪费,主要是搜索FreeBlocks
的空间使用的时候,可能正好剩下 1 字节,就不放回了继续使用了)洗稿的狗也遇到不少 -
数据元空间使用情况:一共使用了 7964 个
MetaChunk
,这些MetaChunk
相关总容量大小是150.83 MB
,目前commit
了150.77 MB
,使用了150.21 MB
,剩余562.77 KB
可以使用,6.65 KB
的空间被浪费了。FreeBlocks
目前回收了869
块内存,一共249.52 KB
。 -
类元空间使用情况:一共使用了 2546 个
MetaChunk
,总容量大小是21.00 MB
,目前commit
了20.93 MB
,使用了20.21 MB
,剩余741.42 KB
可以使用,216 bytes
的空间被浪费了。FreeBlocks
目前回收了1057
块内存,一共264.88 KB
。 -
总的元空间使用情况(类元空间 + 数据元空间的):一共使用了 10510 个
MetaChunk
,总容量大小是171.83 MB
,目前commit
了171.70 MB
,使用了170.42 MB
,剩余1.27 MB
可以使用,6.86 KB
的空间被浪费了。FreeBlocks
目前回收了1926
块内存,一共514.41 KB
。
前面的是从 MetaChunk
的角度去查看,另一个角度是从 VirtualSpaceList
去查看,接下来的信息就是:
Virtual space:
Non-class space: 152.00 MB reserved, 150.81 MB (>99%) committed, 19 nodes.
Class space: 1.00 GB reserved, 20.94 MB ( 2%) committed, 1 nodes.
Both: 1.15 GB reserved, 171.75 MB ( 15%) committed.
意思是:
-
数据元空间的
VirtualSpaceList
:总共Reserve
了152.00 MB
,目前Commit
了150.81 MB
,一共有19
个VirtualSpaceNode
。这个与MetaChunk
的统计信息是有差异的,VirtualSpaceList
的统计信息更体现元空间实际占用的,从MetaChunk
角度统计的时候,将每个MetaChunk
统计信息相加,会有精度损失。 -
类元空间的
VirtualSpaceList
:总共Reserve
了1.00 GB
,目前Commit
了20.94 MB
,一共有1
个VirtualSpaceNode
。 -
总的元空间的
VirtualSpaceList
:总共Reserve
了1.15 GB
,目前Commit
了171.75 MB
。不要偷取他人的劳动成果,也不要浪费自己的时间和精力,让我们一起做一个有良知的写作者。
接下来是每个 ChunkManager
的 FreeChunkListVector
的统计信息:
Chunk freelists:
Non-Class:
4m: (none)
2m: (none)
1m: 2, capacity=2.00 MB, committed=0 bytes ( 0%)
512k: (none)
256k: (none)
128k: 2, capacity=256.00 KB, committed=0 bytes ( 0%)
64k: (none)
32k: 2, capacity=64.00 KB, committed=0 bytes ( 0%)
16k: (none)
8k: 2, capacity=16.00 KB, committed=0 bytes ( 0%)
4k: 2, capacity=8.00 KB, committed=0 bytes ( 0%)
2k: (none)
1k: 2, capacity=2.00 KB, committed=0 bytes ( 0%)
Total word size: 2.34 MB, committed: 0 bytes ( 0%)
Class:
4m: (none)
2m: 1, capacity=2.00 MB, committed=0 bytes ( 0%)
1m: 1, capacity=1.00 MB, committed=0 bytes ( 0%)
512k: (none)
256k: (none)
128k: (none)
64k: (none)
32k: (none)
16k: (none)
8k: (none)
4k: 1, capacity=4.00 KB, committed=0 bytes ( 0%)
2k: (none)
1k: (none)
Total word size: 3.00 MB, committed: 0 bytes ( 0%)
Both:
4m: (none)
2m: 1, capacity=2.00 MB, committed=0 bytes ( 0%)
1m: 3, capacity=3.00 MB, committed=0 bytes ( 0%)
512k: (none)
256k: (none)
128k: 2, capacity=256.00 KB, committed=0 bytes ( 0%)
64k: (none)
32k: 2, capacity=64.00 KB, committed=0 bytes ( 0%)
16k: (none)
8k: 2, capacity=16.00 KB, committed=0 bytes ( 0%)
4k: 3, capacity=12.00 KB, committed=0 bytes ( 0%)
2k: (none)
1k: 2, capacity=2.00 KB, committed=0 bytes ( 0%)
Total word size: 5.34 MB, committed: 0 bytes ( 0%)
以上的信息可能用图片更直接一些:
接下来是关于回收利用的从 MetaChunk
的角度去查看一些统计信息:
Waste (unused committed space):(percentages refer to total committed size 171.75 MB):
Waste in chunks in use: 6.86 KB ( <1%)
Free in chunks in use: 1.27 MB ( <1%)
In free chunks: 0 bytes ( 0%)
Deallocated from chunks in use: 514.41 KB ( <1%) (1926 blocks)
-total-: 1.78 MB ( 1%)
chunk header pool: 10520 items, 748.30 KB.
包含的信息是:
-
当前被使用的
MetaChunk
(即存在于每个类加载器对应的MetaspaceArena
中的MetaChunk
)中有6.86 KB
的空间被浪费了。当前被使用的MetaChunk
(即存在于每个类加载器对应的MetaspaceArena
中的MetaChunk
)中剩余1.27 MB
可以使用。在FreeChunkListVector
中没有浪费的空间,其实从前面的FreeChunkListVector
的详细信息就能看出来。 -
FreeBlocks
目前回收了1926
块内存,一共514.41 KB
。FreeBlocks
里面有1926
个FreeBlock
,一共514.41 KB
。 -
ChunkHeaderPool
目前有10520
个ChunkHeader
,一共占用748.30 KB
。
然后是一些统计信息:
Internal statistics:
num_allocs_failed_limit: 24.
num_arena_births: 2768.
num_arena_deaths: 2.
num_vsnodes_births: 20.
num_vsnodes_deaths: 0.
num_space_committed: 2746.
num_space_uncommitted: 0.
num_chunks_returned_to_freelist: 28.
num_chunks_taken_from_freelist: 10515.
num_chunk_merges: 9.
num_chunk_splits: 6610.
num_chunks_enlarged: 4139.
num_purges: 2.
num_inconsistent_stats: 0.
包含的信息是:
-
num_allocs_failed_limit
:元空间普通分批内存失败的次数(前文分析过详细流程),后面也有对应的 JFR 事件会分析。 -
num_arena_births
:MetaspaceArena
的创建次数。 -
num_arena_deaths
:MetaspaceArena
的销毁次数。发生于对应的类加载器被回收之后。 -
num_vsnodes_births
:VirtualSpaceNode
的创建次数。(根据前面的VirtualSpaceList
的统计信息可以知道是 19 + 1 = 20) -
num_vsnodes_deaths
:VirtualSpaceNode
的销毁次数。 -
num_space_committed
:Commit
内存的次数。 -
num_space_uncommitted
:Uncommit
内存的次数。 -
num_chunks_returned_to_freelist
:MetaChunk
被回收到FreeChunkListVector
的次数。 -
num_chunks_taken_from_freelist
:从FreeChunkListVector
中获取MetaChunk
进行分配的次数。 -
num_chunk_merges
:MetaChunk
合并的次数。 -
num_chunk_splits
:MetaChunk
拆分的次数。 -
num_chunks_enlarged
:MetaChunk
扩容的次数。 -
num_purges
:MetaspaceArena
的清理次数。一般等于销毁次数。 -
num_inconsistent_stats
:不一致的统计次数。这个一般不用关心,主要是为了调试用的。
最后是一些参数信息:
Settings:
MaxMetaspaceSize: unlimited
CompressedClassSpaceSize: 1.00 GB
Initial GC threshold: 40.00 MB
Current GC threshold: 210.12 MB
CDS: on
MetaspaceReclaimPolicy: balanced
- commit_granule_bytes: 65536.
- commit_granule_words: 8192.
- virtual_space_node_default_size: 1048576.
- enlarge_chunks_in_place: 1.
- new_chunks_are_fully_committed: 0.
- uncommit_free_chunks: 1.
- use_allocation_guard: 0.
- handle_deallocations: 1.
-
MaxMetaspaceSize
:元空间最大值。默认是无限制的。这里我们也没限制。 -
CompressedClassSpaceSize
:压缩类空间大小。默认是 1 GB。这里我们也没指定,所以是默认的。 -
Initial GC threshold
:初始的元空间 GC 阈值。默认是 40 MB。这里我们也没指定,所以是默认的。 -
Current GC threshold
:当前的元空间 GC 阈值。前面我们分析过这个阈值改变的机制。 -
CDS
:是否开启了 CDS。默认开启。这个我们不用太关心,主要和 CDS 特性相关(JEP 310: Application Class-Data Sharing 和 JEP 350: Dynamic CDS Archives),在以后的文章会详细分析。 -
元空间
MetaspaceReclaimPolicy
为balanced
-
commit 粒度(
commit_granule_bytes
)为 65536 字节,转化单位为字之后,是 8192 字(一 word 为 8 字节)。虚拟内存空间节点内存大小(virtual_space_node_default_size
)为 1048576 字,转化单位为字之后,是 64 MB。当前 MetaChunk 不足以分配的时候,是否尝试扩容当前MetaChunk
(enlarge_chunks_in_place
)为是,新分配的MetaChunk
是否一次性全部 commit(new_chunks_are_fully_committed
)为否,是否在MetaChunk
释放的时候 uncommit(uncommit_free_chunks
)为是。以上配置都在前文分析过。最后两个配置都是 debug 配置,正式版里面都是无法修改的,我们也不用太关心这两个配置的效果,并且handle_deallocations
已经在 Java 18 中移除了(https://github.com/openjdk/jdk/commit/157e1d5073e221dab084422389f68eea53974f4c
)
4.6.2. 元空间相关 JVM 日志
我们通过启动参数 -Xlog:metaspace*=debug::utctime,level,tags
,查看元空间相关 JVM 日志。
首先,初始化 JVM 元空间的时候,会输出元空间基本参数:
[2023-04-11T09:07:31.994+0000][info][metaspace] Initialized with strategy: balanced reclaim.
[2023-04-11T09:07:31.994+0000][info][metaspace] - commit_granule_bytes: 65536.
[2023-04-11T09:07:31.994+0000][info][metaspace] - commit_granule_words: 8192.
[2023-04-11T09:07:31.994+0000][info][metaspace] - virtual_space_node_default_size: 1048576.
[2023-04-11T09:07:31.994+0000][info][metaspace] - enlarge_chunks_in_place: 1.
[2023-04-11T09:07:31.994+0000][info][metaspace] - new_chunks_are_fully_committed: 0.
[2023-04-11T09:07:31.994+0000][info][metaspace] - uncommit_free_chunks: 1.
[2023-04-11T09:07:31.994+0000][info][metaspace] - use_allocation_guard: 0.
[2023-04-11T09:07:31.994+0000][info][metaspace] - handle_deallocations: 1.
以上这几行日志的意思是:元空间 MetaspaceReclaimPolicy
为 balanced
,commit 粒度(commit_granule_bytes
)为 65536 字节,转化单位为字之后,是 8192 字(一 word 为 8 字节)。虚拟内存空间节点内存大小(virtual_space_node_default_size
)为 1048576 字,转化单位为字之后,是 64 MB。当前 MetaChunk 不足以分配的时候,是否尝试扩容当前 MetaChunk
(enlarge_chunks_in_place
)为是,新分配的 MetaChunk
是否一次性全部 commit(new_chunks_are_fully_committed
)为否,是否在 MetaChunk
释放的时候 uncommit(uncommit_free_chunks
)为是。以上配置都在前文分析过。最后两个配置都是 debug 配置,正式版里面都是无法修改的,我们也不用太关心这两个配置的效果,并且 handle_deallocations
已经在 Java 18 中移除了(https://github.com/openjdk/jdk/commit/157e1d5073e221dab084422389f68eea53974f4c
)
接下来,初始化元空间的内存空间:
[2023-04-11T09:07:32.411+0000][info ][gc,metaspace] CDS archive(s) mapped at: [0x0000000800000000-0x0000000800bde000-0x0000000800bde000), size 12443648, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 0.
[2023-04-11T09:07:32.411+0000][info ][gc,metaspace] Compressed class space mapped at: 0x0000000800c00000-0x0000000840c00000, reserved size: 1073741824
[2023-04-11T09:07:32.411+0000][info ][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
[2023-04-11T09:07:32.417+0000][debug][metaspace ] Arena @0x0000ffff807a1cc0 (non-class sm): : born.
[2023-04-11T09:07:32.417+0000][debug][metaspace ] Arena @0x0000ffff807a1dd0 (class sm): : born.
[2023-04-11T09:07:32.417+0000][debug][metaspace ] CLMS @0x0000ffff807a1c80 : born (nonclass arena: 0x0000ffff807a1cc0, class arena: 0x0000ffff807a1dd0.
[2023-04-11T09:07:32.411+0000][debug][metaspace ] VsListNode @0x0000ffff80784ab0 base 0x0000000800c00000 : born (word_size 134217728).
[2023-04-11T09:07:32.417+0000][debug][metaspace ] VsListNode @0x0000ffff807a27b0 base 0x0000ffff52800000 : born (word_size 1048576).
这几行日志的意思是:
-
CDS 元数据映射到内存的地址范围是
[0x0000000800000000-0x0000000800bde000-0x0000000800bde000)
,大小为 12443648 字节,共享基地址为0x0000000800000000
,ArchiveRelocationMode
为关闭。这些信息我们不用太关心,主要和 CDS 特性相关(JEP 310: Application Class-Data Sharing 和 JEP 350: Dynamic CDS Archives),在以后的文章会详细分析。 -
我们这里是默认配置,所以压缩类空间是开启的,初始化压缩类空间,映射到内存的地址范围是
[0x0000000800c00000-0x0000000840c00000)
,Reserved 内存大小为 1073741824 字节(1GB),默认压缩类空间最大大小就是 1GB。加载到压缩类空间的类的基地址为0x0000000800000000
(),偏移量为 0,范围为0x100000000
,这个前面也简单分析过。 -
Bootstrap ClassLoader
创建了两个MetaspaceArena
,分别是前文分析的类元空间的MetaspaceArena
和数据元空间的MetaspaceArena
,放入对应的ClassLoadMetaSpace
中。不要偷取他人的劳动成果,也不要浪费自己的时间和精力,让我们一起做一个有良知的写作者。 -
初始化类元空间的还有数据元空间的
VirtualSpaceList
,并分别创建并放入各自的第一个VirtualSpaceNode
接下来开始加载类,从元空间申请内存进行分配:
[2023-04-11T09:07:32.411+0000][debug][metaspace] ChkMgr @0x0000ffff807863d0 (class-space): requested chunk: pref_level: lv12, max_level: lv12, min committed size: 0.
[2023-04-11T09:07:32.411+0000][debug][metaspace] VsListNode @0x0000ffff80784ab0 base 0x0000000800c00000 : new root chunk @0x0000ffff807867f0, f, base 0x0000000800c00000, level lv00.
[2023-04-11T09:07:32.411+0000][debug][metaspace] ChkMgr @0x0000ffff807863d0 (class-space): allocated new root chunk.
[2023-04-11T09:07:32.411+0000][debug][metaspace] ChkMgr @0x0000ffff807863d0 (class-space): splitting chunk @0x0000ffff807867f0, f, base 0x0000000800c00000, level lv00 to lv12.
[2023-04-11T09:07:32.411+0000][debug][metaspace] ChkMgr @0x0000ffff807863d0 (class-space): handing out chunk @0x0000ffff807867f0, u, base 0x0000000800c00000, level lv12.
这几行日志的意思分别是:
-
加载类需要从元空间申请内存,这是第一次申请,所以各个数据结构都是空的,所以需要申请新的
MetaChunk
,优先考虑的与最大的ChunkLevel
都是12
,对应 1KB。本次申请发生在ChunkManager @0x0000ffff807863d0
-
申请新的
RootMetaChunk
,基址0x0000000800c00000
-
将新的
RootMetaChunk
按照之前的算法拆分到ChunkLevel
为12
,结果是MetaChunk @0x0000ffff807867f0
,将拆出来的其他MetaChunk
放入ChunkManager @0x0000ffff807863d0
的FreeListVector
中
4.6.3. 元空间 JFR 事件详解
4.6.3.1. jdk.MetaspaceSummary
元空间定时统计事件
元空间定时统计事件 jdk.MetaspaceSummary
,包括以下属性:
-
事件开始时间:其实就是事件发生时间
-
GC Identifier:全局 GC 的 id 标识
-
When:事件发生的时机,包括
Before GC
和After GC
两种,分别是 GC 前和 GC 后的统计数据,可以根据 GC Identifier 对比 GC 前后的数据,看看 GC 之后元空间的使用情况.plagiarism和洗稿是恶意抄袭他人劳动成果的行为,是对劳动价值的漠视和践踏! -
GC Threshold:GC 阈值,即前面提的
_capacity_until_GC
-
Class:Reserved:类元空间 Reserved 的内存空间大小
-
Class:Committed:类元空间 Committed 的内存空间大小
-
Class:Used:类元空间实际保存数据使用的内存空间大小(前面的机制分析中我们会看到,Committed 的空间会比实际使用的大,主要因为类加载器回收,以及可能
MetaChunk
分配的时候 commit 所有内存) -
Data:Reserved:数据元空间 Reserved 的内存空间大小
-
Data:Committed:数据元空间 Committed 的内存空间大小
-
Data:Used:数据元空间实际保存数据使用的内存空间大小
-
Total:Reserved:整个元空间 Reserved 的内存空间大小(其实就是类元空间 + 数据元空间)
-
Total:Committed:整个元空间 Committed 的内存空间大小(其实就是类元空间 + 数据元空间)
-
Total:Used:整个元空间实际保存数据使用的内存空间大小(其实就是类元空间 + 数据元空间)
4.6.3.2. jdk.MetaspaceAllocationFailure
元空间分配失败事件
前面提到过,如果普通分配失败,那么会触发 jdk.MetaspaceAllocationFailure
这个 JFR 事件,大家可以监控这个事件,去调整元空间大小减少由于元空间不足触发的 GC,这个事件包括以下属性:
-
事件开始时间:其实就是事件发生时间
-
类加载器:触发 OOM 的类加载器
-
Hidden Class Loader:是否是隐藏类加载器
-
Metadata Type:元数据类型,分为属于类元空间的以及属于数据元空间的两种类型,分别是:
Class
和Metadata
-
Metaspace Object Type:元空间对象类型,包括
Class
、ConstantPool
、Symbol
、Method
、Klass
、Module
、Package
、Other
-
Size:本次分配的大小
这个事件也会采集堆栈信息,用来定位分配失败的源头是哪些类的加载导致的。
4.6.3.3. jdk.MetaspaceOOM
元空间 OOM 事件
前面提到过,当元空间 OOM 的时候,就会产生这个事件,这个事件包括以下属性(和 jdk.MetaspaceAllocationFailure
事件一样):
-
事件开始时间:其实就是事件发生时间
-
类加载器:触发 OOM 的类加载器
-
Hidden Class Loader:是否是隐藏类加载器
-
Metadata Type:元数据类型,分为属于类元空间的以及属于数据元空间的两种类型,分别是:
Class
和Metadata
-
Metaspace Object Type:元空间对象类型,包括
Class
、ConstantPool
、Symbol
、Method
、Klass
、Module
、Package
、Other
-
Size:本次分配的大小
与 jdk.MetaspaceAllocationFailure
事件一样,也会采集堆栈信息,用来定位 OOM 的原因。
4.6.3.4. jdk.MetaspaceGCThreshold
元空间 GC 阈值变化事件
前面我们说过,元空间的 GC 阈值(_capacity_until_GC
)是动态调整的,这个事件就是用来记录元空间 GC 阈值变化的。这个事件包括以下属性:
-
事件开始时间:其实就是事件发生时间
-
New Value:新的 GC 阈值
-
Old Value:旧的 GC 阈值
-
Updater:哪个机制触发的 GC 阈值修改,我们之前讨论过
_capacity_until_GC
有两个场景会修改: -
分配过程中,达到 GC 阈值,触发 GC,但是处于 GCLocker 处于锁定禁止 GC,就尝试增大
_capacity_until_GC
进行分配。对应的Updater
是expand_and_allocate
-
每次 GC 之后,触发重新计算
_capacity_until_GC
,如果有更新,就会生成这个事件,对应的Updater
是compute_new_size
4.6.3.5. jdk.MetaspaceChunkFreeListSummary
元空间 Chunk FreeList 统计事件
这个事件在 Java 16 引入 JEP 387: Elastic Metaspace 弹性元空间的设计之后,里面的统计数据就都是 0 了,还没有实现,参考:https://bugs.openjdk.org/browse/JDK-8251342
,所以我们先不用关心。参考源码:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/share/memory/metaspaceUtils.hpp
// (See JDK-8251342). Implement or Consolidate.
static MetaspaceChunkFreeListSummary chunk_free_list_summary(Metaspace::MetadataType mdtype) {
return MetaspaceChunkFreeListSummary(0,0,0,0,0,0,0,0);
}
5. JVM 线程内存设计(重点研究 Java 线程)
Java 19 中 Loom 终于 Preview 了,虚拟线程(VirtualThread
)是我期待已久的特性,但是这里我们说的线程内存,并不是这种 虚拟线程,还是老的线程。其实新的虚拟线程,在线程内存结构上并没有啥变化,只是存储位置的变化,实际的负载线程(CarrierThread
)还是老的线程。
同时,JVM 线程占用的内存分为两个部分:分别是线程栈占用内存,以及线程本身数据结构占用的内存。
5.1. JVM 中有哪几种线程,对应线程栈相关的参数是什么
JVM 中有如下几类线程:
-
VM 线程:全局唯一的线程,负责执行
VM Operations
,例如 JVM 的初始化,其中的操作大部分需要在安全点执行,即 Stop the world 的时候执行。所有的操作请参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/share/runtime/vmOperation.hpp
-
GC 线程:负责做 GC 操作的线程
-
Java 线程:包括 Java 应用线程(
java.lang.Thread
),以及CodeCacheSweeper
线程,JVMTI
的 Agent 与 Service 线程其实也是 JAva 线程。 -
编译器线程: JIT 编译器的线程,有 C1 和 C2 线程(xi稿滚去shi)
-
定时任务时钟线程:全局唯一的线程,即 Watcher 线程,负责计时并执行定时任务,目前 JVM 中包括的定时任务可以通过查看继承
PeriodicTask
的类看到,其中两个比较重要的任务是: -
StatSamplerTask
:定时更新采集的 JVM Performance Data(PerfData)数据, 包括 GC、类加载、运行采集等等数据,这个任务多久执行一次是通过-XX:PerfDataSamplingInterval
参数控制的,默认为 50 毫秒(参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/share/runtime/globals.hpp
)。这些数据一般通过 jstat 读取,或者通过 JMX 读取。 -
VMOperationTimeoutTask
:由于 VM 线程是单线程,执行VM Operations
,单个任务执行不能太久,否则会阻塞其他VM Operations
。所以每次执行VM Operations
的时候,这个定时任务都会检查当前执行了多久,如果超过-XX:AbortVMOnVMOperationTimeoutDelay
就会报警。AbortVMOnVMOperationTimeoutDelay
默认是 1000ms(参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/share/runtime/globals.hpp
)。 -
异步日志线程:全局唯一的线程, Java 17 引入的异步 JVM 日志特性,防止因为 JVM 日志输出阻塞影响全局安全点事件导致全局暂停过长,或者 JVM 日志输出导致线程阻塞,负责异步写日志,通过
-Xlog:async
启用 JVM 异步日志,通过-XX:AsyncLogBufferSize=
指定异步日志缓冲大小,这个大小默认是2097152
即2MB
-
JFR 采样线程:全局唯一的线程,负责采集 JFR 中的两种采样事件,一个是
jdk.ExecutionSample
,另一个是jdk.NativeMethodSample
,都是采样当前正在RUNNING
的线程,如果线程在执行 Java 代码,就属于jdk.ExecutionSample
,如果执行 native 方法,就属于jdk.NativeMethodSample
。
相关的参数有:
-
ThreadStackSize
:每个 Java 线程的栈大小,这个参数通过-Xss
也可以指定,各种平台的默认值为: -
linux 平台,x86 CPU,默认为 1024 KB,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/linux_x86/globals_linux_x86.hpp
-
linux 平台,aarch CPU,默认为 2048 KB,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/linux_aarch64/globals_linux_aarch64.hpp
-
windows 平台,x86 CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/windows_x86/globals_windows_x86.hpp
-
windows 平台,aarch CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/windows_aarch64/globals_windows_aarch64.hpp
-
VMThreadStackSize
:VM 线程,GC 线程,定时任务时钟线程,异步日志线程,JFR 采样线程的栈大小,各种平台的默认值为: -
linux 平台,x86 CPU,默认为 1024 KB,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/linux_x86/globals_linux_x86.hpp
-
linux 平台,aarch CPU,默认为 2048 KB,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/linux_aarch64/globals_linux_aarch64.hpp
-
windows 平台,x86 CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/windows_x86/globals_windows_x86.hpp
-
windows 平台,aarch CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/windows_aarch64/globals_windows_aarch64.hpp
-
CompilerThreadStackSize
:编译器线程的栈大小,各种平台的默认值为: -
linux 平台,x86 CPU,默认为 1024 KB,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/linux_x86/globals_linux_x86.hpp
-
linux 平台,aarch CPU,默认为 2048 KB,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/linux_aarch64/globals_linux_aarch64.hpp
-
windows 平台,x86 CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/windows_x86/globals_windows_x86.hpp
-
windows 平台,aarch CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/windows_aarch64/globals_windows_aarch64.hpp
-
StackYellowPages
:后面会提到并分析的黄色区域的页大小 -
linux 平台,x86 CPU,默认为 2 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
-
linux 平台,aarch CPU,默认为 2 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
-
windows 平台,x86 CPU,默认为 3 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
-
windows 平台,aarch CPU,默认为 2 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
-
StackRedPages
:后面会提到并分析的红色区域的页大小 -
linux 平台,x86 CPU,默认为 1 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
-
linux 平台,aarch CPU,默认为 1 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
-
windows 平台,x86 CPU,默认为 1 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
-
windows 平台,aarch CPU,默认为 1 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
-
StackShadowPages
:后面会提到并分析的影子区域的页大小 -
linux 平台,x86 CPU,默认为 20 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
-
linux 平台,aarch CPU,默认为 20 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
-
windows 平台,x86 CPU,默认为 8 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
-
windows 平台,aarch CPU,默认为 20 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
-
StackReservedPages
:后面会提到并分析的保留区域的页大小 -
linux 平台,x86 CPU,默认为 1 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
-
linux 平台,aarch CPU,默认为 1 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
-
windows 平台,x86 CPU,默认为 0 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
-
windows 平台,aarch CPU,默认为 1 页,参考:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
-
RestrictReservedStack
:默认为 true,与保留区域相关,保留区域会保护临界区代码(例如ReentrantLock
)在抛出StackOverflow
之前先把临界区代码执行完再结束,防止临界区代码执行到一半就抛出StackOverflow
导致状态不一致导致这个锁之后再也用不了了。标记临界区代码的注解是@jdk.internal.vm.annotation.ReservedStackAccess
。在这个配置为 true 的时候,这个注解默认只能 jdk 内部代码使用,如果你有类似于ReentrantLock
这种带有临界区的代码也想保护起来,可以设置-XX:-RestrictReservedStack
,关闭对于@jdk.internal.vm.annotation.ReservedStackAccess
的限制,这样你就可以在自己的代码中使用这个注解了。
我们接下来重点分析 Java 线程栈。
5.2. Java 线程栈内存的结构
熟悉编译器的人应该知道激活记录(Activation Record)这个概念,它是一种数据结构,其中包含支持一次函数调用所需的所有信息。它包含该函数的所有局部变量,以及指向另一个激活记录的引用(或指针),其实你可以简单理解为,每多一次方法调用就多一个激活记录。而线程栈帧(Stack Frame),就是激活记录的实际实现。每在代码中多一次方法调用就多一个栈帧,但是这个说法并不严谨,比如,JIT 可能会内联一些方法,可能会跳过某些方法的调用等等。Java 线程的栈帧有哪几种呢,其实根据 Java 线程执行的方法有 Java 方法以及原生方法(Native)就能推测出有两种:
-
Java 虚拟机栈帧(Java Virtual Machine Stack Frame):用于保存 Java 方法的执行状态,包括局部变量表、操作数栈、方法出口等信息。
-
Native 方法栈帧(Native Method Stack Frame):用于保存 Native 方法的执行状态,包括局部变量表、操作数栈、方法出口等信息。
在最早的时候,Linux 还没有线程的概念,Java 自己做了一种叫做 Green Thread
的东西即用户态线程(与现在的虚拟线程设计差异很大,不是一个概念了),但是调度有诸多问题,所以在 Linux 有线程之后,Java 也舍弃了 Green Thread
。Java 线程其实底层就是通过操作系统线程实现,是一一对应的关系。不过现在,虚拟线程也快要 release 了,但是这个并不是今天的重点。并且,在最早的时候,Java 线程栈与 Native 线程栈也是分开的,虽然可能都是一个线程执行的。后来,发现这样做对于 JIT 优化,以及线程栈大小限制,以及实现高效的 StackOverflow 检查都不利,所以就把 Java 线程栈与 Native 线程栈合并了,这样就只有一个线程栈了。
JVM 中对于线程栈可以使用的空间是限制死的。对于 Java 线程来说,这个限制是由 -Xss
或者 -XX:ThreadStackSize
来控制的,-Xss
或者 -XX:ThreadStackSize
基本等价, 一般来说,-Xss
或者 -XX:ThreadStackSize
是用来设置每个线程的栈大小的,但是更严谨的说法是,它是设置每个线程栈最大使用的内存大小,并且实际可用的大小由于保护页的存在还要小于这个值,并且设置这个值不能小于保护页需要的大小,否则没有意义。根据前面对于 JVM 其他区域的分析我们可以推测出,对于每个线程,都会先 Reserve
出 -Xss
或者 -XX:ThreadStackSize
大小的内存,之后随着线程占用内存升高而不断 Commit
内存。
同时我们还知道,对于一段 Java 代码,分为编译器执行,C1 执行,C2 执行三种情况,因此,一个 Java 线程的栈内存结构可能如下图所示:
这个图片我们展示了一个比较极端的情况,线程先解释执行方法 1,之后调用并解释执行方法 2,然后调用一个可能比较热点的方法 3,方法 3 已经被 C1 优化编译,这里执行的是编译后的代码,之后调用可能更热点的方法 4,方法 4 已经被 C2 优化编译,这里执行的是编译后的代码。最后方法 4 还需要调用一个 native 方法 5。
5.3. Java 线程如何抛出的 StackOverflowError
JVM 线程内存还有一些特殊的内存区域,结构如下:
-
保护区域(Guard Zone),保护区的内存没有映射物理内存,访问的话会像前面第三章提到的
NullPointerException
优化方式类似,即抛出SIGSEGV
被 JVM 捕获,再抛出StackOverflowError
。保护区包括以下三种: -
黄色区域(Yellow Zone):大小由前面提到的
-XX:StackYellowPages
参数决定。如果栈扩展到了黄色区域,则发生SIGSEGV
,并且信号处理程序抛出StackOverflowError
并继续执行当前线程。同时,这时候黄色页面会被映射分配内存,以提供一些额外的栈空间给异常抛出的代码使用,抛出异常结束后,黄色页面会重新去掉映射,变成保护区。 -
红色区域(Red Zone):大小由前面提到的
-XX:StackRedPages
参数决定。正常的代码只会可能到黄色区域,只有 JVM 出一些 bug 的时候会到红色区域,这个相当于最后一层保证。保留这个区域是为了出这种 bug 的时候,能有空间可以将错误信息写入hs_err_pid.log
文件用于定位。 -
保留区域(Reserved Zone):大小由前面提到的
-XX:StackReservedPages
参数决定。在 Java 9 引入(JEP 270: Reserved Stack Areas for Critical Sections)(洗稿狗的区域是细狗区),主要是为了解决 JDK 内部的临界区代码(例如ReentrantLock
)导致StackOverflowError
的时候保证内部数据结构不会处于不一致的状态导致锁无法释放或者被获取。如果没有这个区域,在ReentrantLock.lock()
方法内部调用某个内部方法的时候可能会进入黄色区域,导致StackOverflowError
,这时候可能ReentrantLock
内部的一些数据可能已经修改,抛出异常导致这些数据无法回滚让锁处于当初设计的时候没有设计的不一致状态。为了避免这个情况,引入保留区域。在执行临界区方法的时候(被@jdk.internal.vm.annotation.ReservedStackAccess
注解修饰的方法),如果进入保留区域,那么保留区域会被映射内存,用于执行完临界区方法,执行完临界区方法之后,再抛出StackOverflowError
,并解除保留区域的映射。另外,前面我们提到过,@jdk.internal.vm.annotation.ReservedStackAccess
这个注解默认只能 jdk 内部代码使用,如果你有类似于ReentrantLock
这种带有临界区的代码也想保护起来,可以设置-XX:-RestrictReservedStack
,关闭对于@jdk.internal.vm.annotation.ReservedStackAccess
的限制,这样你就可以在自己的代码中使用这个注解了。 -
影子区域(Shadow Zone):这个区域的大小由前面提到的
-XX:StackShadowPages
参数决定。影子区域只是抽象概念,跟在当前栈占用的顶部栈帧后面,随着顶部栈帧变化而变化。这个区域用于保证 Native 调用不会导致StackOverflowError
。在后面的分析我们会看到,每次调用方法前需要估算方法栈帧的占用大小,但是对于 Native 调用我们无法估算,所以我们就假设 Native 大小最大不会超过影子区域大小,在发生Native
调用前,会查看当前栈帧位置加上影子区域大小是否会达到保留区域,如果达到了保留区域,那么会抛出StackOverflowError
,如果没有达到保留区域,那么会继续执行。这里我们可以看出,JVM 假设 Native 调用占用空间不会超过影子区域大小,JDK 中自带的 native 调用也确实是这样。如果你自己实现了 Native 方法并且会占用大量栈内存,那么你需要调整StackShadowPages
。
我们看下源码中如何体现的这些区域,参考源码:https://github.com/openjdk/jdk/blob/jdk-21%2B18/src/hotspot/share/runtime/stackOverflow.hpp
size_t StackOverflow::_stack_red_zone_size = 0;
size_t StackOverflow::_stack_yellow_zone_size = 0;
size_t StackOverflow::_stack_reserved_zone_size = 0;
size_t StackOverflow::_stack_shadow_zone_size = 0;
void StackOverflow::initialize_stack_zone_sizes() {
//读取虚拟机页大小,第二章我们分析过
size_t page_size = os::vm_page_size();
//目前各个平台最小页大小基本都是 4K
size_t unit = 4*K;
//使用 StackRedPages 乘以 4K 然后对虚拟机页大小进行对齐作为红色区域大小
assert(_stack_red_zone_size == 0, "This should be called only once.");
_stack_red_zone_size = align_up(StackRedPages * unit, page_size);
//使用 StackYellowPages 乘以 4K 然后对虚拟机页大小进行对齐作为黄色区域大小
assert(_stack_yellow_zone_size == 0, "This should be called only once.");
_stack_yellow_zone_size = align_up(StackYellowPages * unit, page_size);
//使用 StackReservedPages 乘以 4K 然后对虚拟机页大小进行对齐作为保留区域大小
assert(_stack_reserved_zone_size == 0, "This should be called only once.");
_stack_reserved_zone_size = align_up(StackReservedPages * unit, page_size);
//使用 StackShadowPages 乘以 4K 然后对虚拟机页大小进行对齐作为保留区域大小
assert(_stack_shadow_zone_size == 0, "This should be called only once.");
_stack_shadow_zone_size = align_up(StackShadowPages * unit, page_size);
}
5.3.1. 解释执行与编译执行时候的判断(x86为例)
我们继续针对 Java 线程进行讨论。在前面我们已经知道,Java 线程栈的大小是有限制的,如果线程栈使用的内存超过了限制,那么就会抛出 StackOverflowError
。但是,JVM 如何知道什么时候该抛出呢?
首先,对于解释执行,一般没有任何优化,就是在调用方法前检查。不同的环境下的实现会有些差别,我们以 x86 cpu 为例:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/templateInterpreterGenerator_x86.cpp
void TemplateInterpreterGenerator::generate_stack_overflow_check(void) {
//计算栈帧的一些元数据存储的消耗
const int entry_size = frame::interpreter_frame_monitor_size() * wordSize;
const int overhead_size =
-(frame::interpreter_frame_initial_sp_offset * wordSize) + entry_size;
//读取虚拟机页大小,第二章我们分析过
const int page_size = os::vm_page_size();
//比较当前要调用的方法的元素个数,判断与除去元数据以外一页能容纳的元素个数谁大谁小
Label after_frame_check;
__ cmpl(rdx, (page_size - overhead_size) / Interpreter::stackElementSize);
__ jcc(Assembler::belowEqual, after_frame_check);
//大于的才会进行后续的判断,因为小于一页的话,绝对可以被黄色区域限制住,因为黄色区域要与页大小对齐,因此至少一页
//小于一页的栈帧不会导致跳过黄色区域,只有大于的须有后续仔细判断
Label after_frame_check_pop;
//读取线程的 stack_overflow_limit_offset
//_stack_overflow_limit = stack_end() + MAX2(stack_guard_zone_size(), stack_shadow_zone_size());
//即栈尾 加上 保护区域 或者 阴影区域 的最大值,即有效栈尾地址
//其实就是当前线程栈容量顶部减去 保护区域 或者 阴影区域 的最大值的地址,即当前线程栈只能增长到这个地址
const Address stack_limit(thread, JavaThread::stack_overflow_limit_offset());
//将前面计算的栈帧元素个数大小保存在 rax
__ mov(rax, rdx);
//将栈帧的元素个数转换为字节大小,然后加上栈帧的元数据消耗
__ shlptr(rax, Interpreter::logStackElementSize);
__ addptr(rax, overhead_size);
//加上前面计算的有效栈尾地址
__ addptr(rax, stack_limit);
//与当前栈顶地址比较,如果当前栈顶地址大于 rax 当前值,证明没有溢出
__ cmpptr(rsp, rax);
__ jcc(Assembler::above, after_frame_check_pop);
//否则抛出 StackOverflowError 异常
__ jump(ExternalAddress(StubRoutines::throw_StackOverflowError_entry()));
__ bind(after_frame_check_pop);
__ bind(after_frame_check);
}
代码的步骤大概是(plagiarism和洗稿是恶意抄袭他人劳动成果的行为,是对劳动价值的漠视和践踏! ):
-
首先判断要分配的栈帧大小,是否大于一页。
-
如果小于等于一页,不用检查,直接结束。因为如果小于一页,那么栈帧的元素个数一定小于一页,栈增长不会导致跳过保护区域,如果达到保护区域就会触发
SIGSEGV
抛出StackOverflowError
。因为每个保护区域如前面源代码所示,都是对虚拟机页大小进行对齐的,因此至少一页。 -
如果大于一页,则需要检查。检查当前已经使用的空间,加上栈帧占用的空间,加上保护区域与阴影区域的最大值,占用空间是否大于栈空间限制。如果大于,则抛出
StackOverflowError
异常。为什么是保护区域与阴影区域的最大值?阴影区域其实是我们假设的最大帧大小,最后至少要有这么多空间才一定不会导致溢出栈顶污染其他内存(当然,如之前所述,如果你自己实现一个 Native 调用并且栈帧很大,则需要修改阴影区域大小)。如果本身保护区域就比阴影区域大,那么就用保护区域的大小,就也能保证这一点。
可以看出,编译执行,虽然做了一定的优化,但是还是很复杂,就算大部分栈帧应该都小于一页,但是刚开始的判断指令还是有不小的消耗。我们看看 JIT 编译后的代码,还是以 x86 cpu 为例:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/share/asm/assembler.cpp
void AbstractAssembler::generate_stack_overflow_check(int frame_size_in_bytes) {
//读取虚拟机页大小,第二章我们分析过
const int page_size = os::vm_page_size();
//读取影子区大小
int bang_end = (int)StackOverflow::stack_shadow_zone_size();
//如果栈帧大小大于一页,那么需要将 bang_end 加上栈帧大小,之后检查每一页是否处于保护区域
const int bang_end_safe = bang_end;
if (frame_size_in_bytes > page_size) {
bang_end += frame_size_in_bytes;
}
//检查每一页是否处于保护区域
int bang_offset = bang_end_safe;
while (bang_offset <= bang_end) {
bang_stack_with_offset(bang_offset);
bang_offset += page_size;
}
}
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/macroAssembler_x86.cpp
//检查是否处于保留区域,其实就是将 rsp - offset 的地址上的值写入 rax 上,
//如果 rsp - offset 保护区域,那么就会触发 SIGSEGV
void bang_stack_with_offset(int offset) {
movl(Address(rsp, (-offset)), rax);
}
编译后执行的指令就简单多了:
-
如果栈帧大小小于一页:只需要考虑 Native 调用是否会导致
StackOverflow
即可。检查当前占用位置加上影子区域大小,之后判断是否会进入保护区域即可,不用考虑当前方法栈帧占用大小,因为肯定小于一页。验证是否进入保护区域也和之前讨论过的NullPointeException
的处理是类似的,就是将 rsp - offset 的地址上的值写入 rax 上,如果 rsp - offset 处于保护区域,那么就会触发SIGSEGV
。 -
如果栈帧大小大于一页:那么需要将当前占用位置,加上栈帧大小,加上影子区域大小,之后从当前栈帧按页检查,是否处于保护区域。因为大于一页的话,直接验证最后的位置可能会溢出到其他东西占用的内存(比如其他线程占用的内存)。
5.3.2. 一个 Java 线程 Xss 最小能指定多大
这个和平台是相关的,我们以 linux x86 为例子,假设没有大页分配,一页就是 4K,一个线程至少要保留如下的空间:
-
保护区域:
-
黄色区域:默认 2 页
-
红色区域:默认 1 页
-
保留区域:默认 1 页
-
影子区域:默认 20 页
这些加在一起是 24 页,也就是 96K。
同时,在 JVM 代码中也限制了,除了这些空间,每种线程的最小大小:
https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/os_cpu/linux_x86/os_linux_x86.cpp
size_t os::_compiler_thread_min_stack_allowed = 48 * K;
size_t os::_java_thread_min_stack_allowed = 40 * K;
size_t os::_vm_internal_thread_min_stack_allowed = 64 * K;
所以,对于 Java 线程,至少需要 40 + 96 = 136K
的空间。我们试一下:
bash-4.2$ java -Xss1k
The Java thread stack size specified is too small. Specify at least 136k
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~
另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer~