性能文章>全网最硬核 JVM 内存详解(下)>

全网最硬核 JVM 内存详解(下)原创

9月前
336936

今天又是干货满满的一天,这是全网最硬核 JVM 解析系列第四篇,往期精彩:

  • 全网最硬核 TLAB 解析

  • 全网最硬核 Java 随机数解析

  • 全网最硬核 Java 新内存模型解析

本篇是关于 JVM 内存的详细分析。网上有很多关于 JVM 内存结构的分析以及图片,但是由于不是一手的资料亦或是人云亦云导致有很错误,造成了很多误解;并且,这里可能最容易混淆的是一边是 JVM Specification 的定义,一边是 Hotspot JVM 的实际实现,有时候人们一些部分说的是 JVM Specification,一部分说的是 Hotspot 实现,给人一种割裂感与误解。本篇主要从 Hotspot 实现出发,以 Linux x86 环境为主,紧密贴合 JVM 源码并且辅以各种 JVM 工具验证帮助大家理解 JVM 内存的结构。但是,本篇仅限于对于这些内存的用途,使用限制,相关参数的分析,有些地方可能比较深入,有些地方可能需要结合本身用这块内存涉及的 JVM 模块去说,会放在另一系列文章详细描述。最后,洗稿抄袭狗不得 house

本篇全篇目录(以及涉及的 JVM 参数):

  1. 从 Native Memory Tracking 说起(全网最硬核 JVM 内存解析 - 1.从 Native Memory Tracking 说起开始)

    1. Native Memory Tracking 的开启

    2. Native Memory Tracking 的使用(涉及 JVM 参数:NativeMemoryTracking

    3. Native Memory Tracking 的 summary 信息每部分含义

    4. Native Memory Tracking 的 summary 信息的持续监控

    5. 为何 Native Memory Tracking 中申请的内存分为 reserved 和 committed

  2. JVM 内存申请与使用流程(全网最硬核 JVM 内存解析 - 2.JVM 内存申请与使用流程开始)

    1. Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)

    2. Linux 大页分配方式 - Transparent Huge Pages (THP)

    3. JVM 大页分配相关参数与机制(涉及 JVM 参数:UseLargePages,UseHugeTLBFS,UseSHM,UseTransparentHugePages,LargePageSizeInBytes

    4. JVM commit 的内存与实际占用内存的差异

    1. Linux 下内存管理模型简述

    2. JVM commit 的内存与实际占用内存的差异

    3. 大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)

  3. Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)

    1. 验证 32-bit 压缩指针模式

    2. 验证 Zero based 压缩指针模式

    3. 验证 Non-zero disjoint 压缩指针模式

    4. 验证 Non-zero based 压缩指针模式

    5. 压缩对象指针存在的意义(涉及 JVM 参数:ObjectAlignmentInBytes

    6. 压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:UseCompressedOops,UseCompressedClassPointers

    7. 压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:ObjectAlignmentInBytes,HeapBaseMinAddress

    1. 通用初始化与扩展流程

    2. 直接指定三个指标的方式(涉及 JVM 参数:MaxHeapSize,MinHeapSize,InitialHeapSize,Xmx,Xms

    3. 不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的

    4. 压缩对象指针相关机制(涉及 JVM 参数:UseCompressedOops)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)

    5. 为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:HeapBaseMinAddress

    6. 结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:HeapBaseMinAddress,ObjectAlignmentInBytes,MinHeapSize,MaxHeapSize,InitialHeapSize

    7. 使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论

    8. 堆大小的动态伸缩(涉及 JVM 参数:MinHeapFreeRatio,MaxHeapFreeRatio,MinHeapDeltaBytes)(全网最硬核 JVM 内存解析 - 6.其他 Java 堆内存相关的特殊机制开始)

    9. 适用于长期运行并且尽量将所有可用内存被堆使用的 JVM 参数 AggressiveHeap

    10. JVM 参数 AlwaysPreTouch 的作用

    11. JVM 参数 UseContainerSupport - JVM 如何感知到容器内存限制

    12. JVM 参数 SoftMaxHeapSize - 用于平滑迁移更耗内存的 GC 使用

  4. JVM 元空间设计(全网最硬核 JVM 内存解析 - 7.元空间存储的元数据开始)

    1. jcmd <pid> VM.metaspace 元空间说明

    2. 元空间相关 JVM 日志

    3. 元空间 JFR 事件详解

    4. jdk.MetaspaceSummary 元空间定时统计事件

    5. jdk.MetaspaceAllocationFailure 元空间分配失败事件

    6. jdk.MetaspaceOOM 元空间 OOM 事件

    7. jdk.MetaspaceGCThreshold 元空间 GC 阈值变化事件

    8. jdk.MetaspaceChunkFreeListSummary 元空间 Chunk FreeList 统计事件

    9. CommitLimiter 的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC

    10. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC

    11. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间

    12. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间

    13. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间

    14. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间

    15. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间

    16. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间

    17. 然后类加载器 1 被 GC 回收掉

    18. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间

    19. 元空间的整体配置以及相关参数(涉及 JVM 参数:MetaspaceSize,MaxMetaspaceSize,MinMetaspaceExpansion,MaxMetaspaceExpansion,MaxMetaspaceFreeRatio,MinMetaspaceFreeRatio,UseCompressedClassPointers,CompressedClassSpaceSize,CompressedClassSpaceBaseAddress,MetaspaceReclaimPolicy

    20. 元空间上下文 MetaspaceContext

    21. 虚拟内存空间节点列表 VirtualSpaceList

    22. 虚拟内存空间节点 VirtualSpaceNode 与 CompressedClassSpaceSize

    23. MetaChunk

    24. 类加载的入口 SystemDictionary 与保留所有 ClassLoaderData 的 ClassLoaderDataGraph

    25. 每个类加载器私有的 ClassLoaderData 以及 ClassLoaderMetaspace

    26. 管理正在使用的 MetaChunk 的 MetaspaceArena

    27. 元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)

    28. ClassLoaderData 回收

    29. ChunkHeaderPool 池化 MetaChunk 对象

    30. ChunkManager 管理空闲的 MetaChunk

    31. 类加载器到 MetaSpaceArena 的流程

    32. 从 MetaChunkArena 普通分配 - 整体流程

    33. 从 MetaChunkArena 普通分配 - FreeBlocks 回收老的 current chunk 与用于后续分配的流程

    34. 从 MetaChunkArena 普通分配 - 尝试从 FreeBlocks 分配

    35. 从 MetaChunkArena 普通分配 - 尝试扩容 current chunk

    36. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk

    37. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 从 VirtualSpaceList 申请新的 RootMetaChunk

    38. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 将 RootMetaChunk 切割成为需要的 MetaChunk

    39. MetaChunk 回收 - 不同情况下, MetaChunk 如何放入 FreeChunkListVector

    40. 什么时候用到元空间,以及释放时机

    41. 元空间保存什么

    1. 什么是元数据,为什么需要元数据

    2. 什么时候用到元空间,元空间保存什么

    3. 元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)

    4. 元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)

    5. 元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)

    6. jcmd VM.metaspace 元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)

  5. JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)

    1. 解释执行与编译执行时候的判断(x86为例)

    2. 一个 Java 线程 Xss 最小能指定多大

    1. JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:ThreadStackSize,VMThreadStackSize,CompilerThreadStackSize,StackYellowPages,StackRedPages,StackShadowPages,StackReservedPages,RestrictReservedStack

    2. Java 线程栈内存的结构

    3. Java 线程如何抛出的 StackOverflowError

4. JVM 元空间设计

4.1. 什么是元数据,为什么需要元数据

JVM 在执行 Java 应用程序时,将加载的 Java 类的许多细节记录在内存中,这些信息称为类元数据Class MetaData)。这些元数据对于 Java 的很多灵活的语言以及虚拟机特性都是很重要的,比如动态类加载、JIT 实时编译、反射以及动态代理等等。不同的 JVM 加载类保存的内存信息是不一样的,它们通常在更低的内存占用更快的执行速度之间进行权衡(类似于空间还是时间的权衡)。对于 OpenJDK Hotspot 使用的则是相对丰富的元数据模型来获得尽可能快的性能(时间优先,不影响速度的情况下尽量优化空间占用)。相比于 C,C++,Go 这些离线编译为可执行二进制文件的程序相比,像 JVM 这样的托管运行时动态解释执行或者编译执行的,则需要保留更多关于正在执行的代码的运行时信息。原因如下:

  1. 依赖类库并不是一个确定的有限集:Java 可以动态加载类,并且还有 ASM 以及 Javassist 这些工具在运行时动态定义类并加载,还有 JVMTI agent 这样的机制来动态修改类。所以,JVM 通过类元数据保存:运行时中存在哪些类,它们包含哪些方法和字段,并能够在链接加载期间动态地解析从一个类到另一个类的引用。类的链接也需要考虑类的可见性和可访问性。类元数据与类加载器相关联,同时类元数据也包括类权限和包路径以及模块信息(Java 9之后引入的模块化),以确定可访问性

  2. JVM 解释执行或者通过 JIT 实时编译执行 Java 代码的时候需要基于类元数据的很多信息才能执行:需要知道例如类与类之间的关系,类属性以及字段还有方法结构等等等等。例如在做强制转换的时候,需要检查类型的父子类关系确定是否可以强制转换等等。

  3. JVM 需要一些统计数据决定哪些代码解释执行那些代码是热点代码需要 JIT 即时编译执行

  4. 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

    • InstanceRefKlassjava.lang.ref.Reference 类以及子类对应的 Klass

    • InstanceClassLoaderKlass:Java 类加载器对应的 Klass

    • InstanceMirrorKlassjava.lang.Class 对应的 Klass

    • InstanceKlass:普通对象类的 Klass:

    • ArrayKlass:Java 数组对应的 Klass

位于数据元空间的

  • Symbol:符号常量,即类中所有的符号字符串,例如类名称,方法名称,方法定义等等。

  • ConstantPool:运行时常量池,数据来自于类文件中的常量池。

  • ConstanPoolCache:运行时常量池缓存,用于加速常量池访问

  • ConstMethod:类文件中的方法解析后,静态信息放入 ConstMethod,这部分信息可以理解为是不变的,例如字节码,行号,方法异常表,本地变量表,参数表等等。

  • MethodCounters:方法的计数器相关数据。

  • MethodData:方法数据采集,动态编译相关数据。例如某个方法需要采集一些指标,决定是否采用 C1 C2 动态编译优化性能。

  • Method:Java 方法,包含以上 ConstMethodMethodCountersMethodData 的指针以及一些额外数据。

  • 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:可以为:balancedaggressive, 以及 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:可以为:balancedaggressive, 以及 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 抽象对应的就是 MetaChunkChunkManager 从 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 指向的 SlabSlab 核心就是一个预分配好的 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 主要维护一个 FreeChunkListVectorFreeChunkListVector 里面是一个 FreeChunkList 数组(还有xigao dog 的码)。FreeChunkList 是一个 MetaChunk 链表,链表中都是 Free 的 MetaChunk,同样 ChunkLevel 的 MetaChunk 位于同一个 FreeChunkList 中。FreeChunkList 数组以 ChunkLevel 为下标,这样的数据结构可以快速找到一个所需 ChunkLevel 的 MetaChunkFreeChunkList这个链表其实是一个双向链表,包含头尾两个指针,如果一个 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 会获取类加载器的 ClassLoaderDataClassLoaderData 是每个类加载器私有的。

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 的 ArenaGrowthPolicyMetachunkList每次增长都是申请大小为 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 的 ArenaGrowthPolicyMetachunkList 的第一个 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 的 ArenaGrowthPolicyMetachunkList 的第一个 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 的 ArenaGrowthPolicyMetachunkList 的第一个 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 的 ArenaGrowthPolicyMetachunkList 每次增长都是申请大小为 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 的 ArenaGrowthPolicyMetachunkList 每次增长都是申请大小为 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 的 ArenaGrowthPolicyMetachunkList 每次增长都是申请大小为 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 的 ArenaGrowthPolicyMetachunkList 的第一个 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

整个流程如下:

  1. 首先,验证要分配的内存小于最大 MetaChunk 大小,即 RootMetaChunk 大小,即 ChunkLevel = 0 的大小,即 4MB

  2. 然后,走普通分配流程,判断当前分配的数据类型是类元空间的还是数据元空间的,分别到类元空间的 MetaSpaceArena 或者数据源空间 MetaSpaceArena 进行分配。这是下一节我们要详细分析的。

  3. 如果普通分配失败,那么会触发 jdk.MetaspaceAllocationFailure 这个 JFR 事件,大家可以监控这个事件,去调整元空间大小减少由于元空间不足触发的 GC。触发之后,抄袭狗死全家

  4. 之后,尝试 GC,以及增大元空间的 GC 界限(元空间有最大大小限制,但是还有动态计算的 GC 界限,超过 GC 界限的话,第二步的普通分配也会失败)用于分配。这个流程我们后面会详细分析

  5. 最后,如果这样还是分配失败,那么就会抛出大名鼎鼎的 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,结合流程图,我们可以整理出一个简单的分配思路:

  1. 首先,尝试从 FreeBlocks 中分配(第一次分配,肯定 FreeBlocks 里面没有可以分配的,直接进入下一分支),如果分配成功,直接返回

  2. 然后,尝试从 current chunk 分配(第一次分配肯定 current chunk 为 NULL,直接进入下一分支):

    1. current chunk 如果有足够的空间,并且这些空间是 committed 的或者可以 commit 成功,那么从 current chunk 分配。

    2. current chunk 如果没有足够的空间,尝试 current chunk 扩容,如果扩容成功,并且这些空间是 committed 的或者可以 commit 成功,那么从 current chunk 分配。

    3. 如果扩容失败,或者 commit 失败,直接进入下一分支

  3. 前面分配失败,尝试从 ChunkManager 中申请一个新的 MetaChunk

  4. 如果申请成功,则加入当前的 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

  1. 计算 MetaChunk 剩余的 committed 的空间(很简单,直接用 committed 的位置减去已分配到的位置)

  2. 判断剩余空间是否大于 FreeBlocks 最小内存块大小(即 BinList32 最小大小即 2KB):对待抄袭狗不必手软

  3. 从 MetaChunk 分配剩余的 committed 的空间,放入 FreeBlocks 中

  4. 内存大于 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

  1. 首先判断,要分配的内存大小是否大于 BinList32 最大内存块大小即 33KB:如果大于,就从 BlockTree 查找不小于内存大小的最接近的内存块;如果不大于,就从 BinList32 查找是否有对应大小的内存块。

  2. 如果找到了,计算 wastewaste = 内存块大小 - 要分配的内存大小

  3. 判断 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

  1. enlarge_chunks_in_place 是否是 true,不是的话直接结束,不过前面我们说过,目前 JVM 是代码里写死的 true

  2. 判断是否 current chunk 已经是 RootMetaChunk(代表已经不能扩容了),如果是,直接结束

  3. current chunk 已使用大小加上要分配的内存大小是否大于 RootMetaChunk 的大小即 4MB(代表已经不能扩容了),如果是,直接结束

  4. 找到大于 current chunk 已使用大小,加上要分配的内存大小的最接近的 ChunkLevel (记为 new_level

  5. 判断 new_level 是否小于 current chunk 的 ChunkLevel 减 1,代表要扩容到的大小大于原始大小的 2 倍以上(不允许一下子扩容两倍以上),如果是,直接结束

  6. current chunk 是否是 leader(这个概念后面分析到使用 ChunkManager 分配新的 MetaChunk 会提到),只有 leader 可以扩容,如果不是,直接结束(xigao 必死)

  7. 判断扩容策略中申请下一个 MetaChunk 的 ChunkLevel 是否大于 current chunk 的(代表新申请的比当前的小),如果是,也直接结束。我们这里强调下为啥扩容策略(ArenaGrowthPolicy)中申请下一个 MetaChunk 的 ChunkLevel 大于 current chunk(代表新申请的比当前的小)的话,我们就不扩容了。前面我们列出了各种类型的 ClassLoader 的不同空间的扩容策略,例如DelegatingClassLoader 的 ClassLoaderMetaspace 数据元空间的 MetaspaceArena 的 ArenaGrowthPolicyMetachunkList 的第一个 MetaChunk 大小为 2K,之后每个新 MetaChunk 都是 1K。假设 current chunk 是第一个,这里下一个 MetaChunk 的 ChunkLevel 是 1K 对应的 ChunkLevel,大于 current chunk 当前的 ChunkLevel,所以优先申请新的,而不是扩容。之后到第二个之后,由于之后每个新的 MetaChunk 都是 1K,就会尝试扩容而不是申请新的了。

  8. 使用 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

  1. 计算两个值:max_level = 大于当前申请内存大小最接近的 ChunkLevel (即新的 MetaChunk 最小多大)preferred_level = "根据扩容策略(ArenaGrowthPolicy)下一个 MetaChunk 多大" 与 "max_level" 中小的那个值(也就是更大的 MetaChunk 大小)

  2. 优先搜索并使用 FreeChunkListVector 中那些已经 commit 足够内存的 MetaChunk

  3. 正序遍历(即 ChunkLevel 从小到大,大小从大到小) ChunkManager 的 FreeChunkListVector 里面的数组 (从 preferred_level 到 max_level 与 preferred_level + 2 中比较小的值,即最多搜索 3 个 ChunkLevel,根据前面的分析我们知道 ChunkLevel 就是数组下标),寻找对应的 MetaChunk 链表,正序遍历每个链表(我们前面提到过,commit 多的 MetaChunk 放在开头),直到找到 commit 大小大于申请内存大小的(chaoxi 死的更惨)

  4. 逆序遍历(即 ChunkLevel 从大到小,大小从小到大) ChunkManager 的 FreeChunkListVector 里面的数组 (从 preferred_level 到最大的 ChunkLevel,即 RootMetaChunk 的大小,即 4MB),寻找对应的 MetaChunk 链表,正序遍历每个链表(我们前面提到过,commit 多的 MetaChunk 放在开头),直到找到 commit 大小大于申请内存大小的

  5. 正序遍历(即 ChunkLevel 从小到大,大小从大到小) ChunkManager 的 FreeChunkListVector 里面的数组 (从 preferred_level 到 max_level),寻找对应的 MetaChunk 链表,正序遍历每个链表(我们前面提到过,commit 多的 MetaChunk 放在开头),直到找到 commit 大小大于申请内存大小的

  6. 如果搜索不到已经 commit 足够内存的 MetaChunk,就退而求其次,寻找 FreeChunkListVector 存在的 MetaChunk

  7. 正序遍历(即 ChunkLevel 从小到大,大小从大到小) ChunkManager 的 FreeChunkListVector 里面的数组 (从 preferred_level 到 max_level),寻找对应的 MetaChunk 链表,正序遍历每个链表,直到找到一个 MetaChunk

  8. 逆序遍历(即 ChunkLevel 从大到小,大小从小到大) ChunkManager 的 FreeChunkListVector 里面的数组 (从 preferred_level 到最大的 ChunkLevel,即 RootMetaChunk 的大小,即 4MB),寻找对应的 MetaChunk 链表,正序遍历每个链表,直到找到一个 MetaChunk

  9. 如果前面没有找到合适的,从 VirtualSpaceList 申请新的 RootMetaChunk

  10. 将 RootMetahChunk 分割成需要的 ChunkLevel 大小,之后将分割剩余的放入 FreeChunkListVector,这个过程我们接下来会详细分析

  11. 判断 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

  1. 首先判断当前 _first_node 是否有空间分配新的 RootMetaChunk,如果有则从 _first_node 上面分配新的 RootMetaChunk

  2. 如果没有,判断是否可以扩展新的 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 的流程:

  1. RootMetaChunk 的 ChunkLevel 为 0,对半分成两个 ChunkLevel 为 1 的,第一个为 leader,第二个为 follower

  2. 将上一步的 leader 对半成两个 ChunkLevel 为 2 的,第一个为 leader,第二个为 follower

  3. 将上一步的 leader 对半成两个 ChunkLevel 为 3 的,第一个为 leader,第二个为 follower

  4. 将第三步的 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

这里我们有两个例子:

  1. 我们有一个 ChunkLevel 为 3 的 MetaChunk 要回收,但是它不是 leader,不能向上合并。只有 leader 才会尝试向上合并。这里直接放入 FreeChunkListVector

  2. 我们又有一个 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,它包含自己独有的 ClassLoaderMetaspaceClassLoaderMetaspace 包含:

    • 一个类元空间 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,这次放回 BlockTreeBlockTree 之前本身还有剩余一个 118KB。整体如图所示。

2.这样一来,原来 MetaspaceArena 中 MetaChunkList 管理的 MetaChunk 的内存全都空闲了。

  1. 将 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 方法,这个方法会检查:

  1. 当前元空间已经 commit 的内存大小加上要分配的大小是否超过了 MaxMetaspaceSize

  2. 当前元空间已经 commit 的内存大小加上要分配的大小是否超过了 _capacity_until_GC,超过了就尝试触发 GC

尝试 GC 的核心逻辑是:

  1. 重新尝试分配

  2. 如果还是分配失败,检查 GCLocker 是否锁定禁止 GC,如果是的话,首先尝试提高 _capacity_until_GC 进行分配,分配成功直接返回,否则需要阻塞等待 GCLocker 释放

  3. 如果没有锁定,尝试触发 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

  1. 读取 crrent_shrink_factor = _shrink_factor,统计当前元空间使用的空间 used_after_gc

  2. 首先看是否需要扩容:

    1. 先使用 MinMetaspaceFreeRatio 最小元空间空闲比例计算 minimum_free_percentage 和 maximum_used_percentage,看是否需要扩容。

    2. 计算当前元空间至少要多大 minimum_desired_capacity:使用当前元空间使用的空间 used_after_gc 除以 maximum_used_percentage,并且保证它不小于初始元空间大小 MetaspaceSize,不大于最大元空间大小 MaxMetaspaceSize

    3. 如果当前的 _capacity_until_GC 小于计算的当前元空间至少要多大 minimum_desired_capacity,那么就查要扩容的空间是否大于等于配置 MinMetaspaceExpansion,以及小于等于 MaxMetaspaceExpansion,只有满足才会真正扩容。

    4. 扩容其实就是增加 _capacity_until_GC

  3. 然后看是否需要缩容:

    1. 使用 MaxMetaspaceFreeRatio 最大元空间空闲比例计算 minimum_free_percentage 和 maximum_used_percentage,看是否需要缩容。

    2. 计算当前元空间至少要多大 maximum_desired_capacity:使用当前元空间使用的空间 used_after_gc 除以 maximum_used_percentage,并且保证它不小于初始元空间大小 MetaspaceSize,不大于最大元空间大小 MaxMetaspaceSize

    3. 如果当前的 _capacity_until_GC 大于计算的当前元空间至少要多大 maximum_desired_capacity,计算 shrink_bytes = _capacity_until_GC 减去 maximum_desired_capacity

    4. _shrink_factor 初始为 0,之后为 10%,之后每次翻 4 倍,直到 100%。扩容的大小为 shrink_bytes 乘以这个百分比

    5. 如果缩容大于等于配置 MinMetaspaceExpansion,以及小于等于 MaxMetaspaceExpansion,并且缩容后不会小于初始元空间大小 MetaspaceSize,就会缩容。

    6. 缩容其实就是减少 _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

意思是:

  1. 一共 1383 个类加载器,加载了 33006 个类(其中 1361 个是共享类)。

  2. capacity 是指 MetaChunk 的总容量大小(Reserved 内存);committed 是指这些 MetaChunk 中 committed 的内存大小,也就是实际占用系统物理内存是这么大(虽然可能会有点细微差异,参考本篇文章的第二章);used 是指这些 MetaChunk 实际使用的大小,肯定比 committed 的要小;free 是指剩余的大小;committed = used + free + waste;deallocated 是指回收到 FreeBlocks 的大小,属于 free 的一部分,另一部分就是 MetaChunk 中 committed 但是还没使用的部分;waste 是指浪费的大小(前面我们提到了什么造成的浪费,主要是搜索 FreeBlocks 的空间使用的时候,可能正好剩下 1 字节,就不放回了继续使用了)洗稿的狗也遇到不少

  3. 数据元空间使用情况:一共使用了 7964 个 MetaChunk,这些 MetaChunk 相关总容量大小是 150.83 MB,目前 commit 了 150.77 MB,使用了 150.21 MB,剩余 562.77 KB 可以使用,6.65 KB 的空间被浪费了。FreeBlocks 目前回收了 869 块内存,一共 249.52 KB

  4. 类元空间使用情况:一共使用了 2546 个 MetaChunk,总容量大小是 21.00 MB,目前 commit 了 20.93 MB,使用了 20.21 MB,剩余 741.42 KB 可以使用,216 bytes 的空间被浪费了。FreeBlocks 目前回收了 1057 块内存,一共 264.88 KB

  5. 总的元空间使用情况(类元空间 + 数据元空间的):一共使用了 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.

意思是:

  1. 数据元空间的 VirtualSpaceList:总共 Reserve 了 152.00 MB,目前 Commit 了 150.81 MB,一共有 19 个 VirtualSpaceNode。这个与 MetaChunk 的统计信息是有差异的,VirtualSpaceList 的统计信息更体现元空间实际占用的,从 MetaChunk 角度统计的时候,将每个 MetaChunk 统计信息相加,会有精度损失。

  2. 类元空间的 VirtualSpaceList:总共 Reserve 了 1.00 GB,目前 Commit 了 20.94 MB,一共有 1 个 VirtualSpaceNode

  3. 总的元空间的 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.

包含的信息是:

  1. 当前被使用的 MetaChunk(即存在于每个类加载器对应的 MetaspaceArena 中的 MetaChunk)中有 6.86 KB 的空间被浪费了。当前被使用的 MetaChunk(即存在于每个类加载器对应的 MetaspaceArena 中的 MetaChunk)中剩余 1.27 MB 可以使用。在 FreeChunkListVector 中没有浪费的空间,其实从前面的 FreeChunkListVector 的详细信息就能看出来。

  2. FreeBlocks 目前回收了 1926 块内存,一共 514.41 KBFreeBlocks 里面有 1926 个 FreeBlock,一共 514.41 KB

  3. 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.

包含的信息是:

  1. num_allocs_failed_limit:元空间普通分批内存失败的次数(前文分析过详细流程),后面也有对应的 JFR 事件会分析。

  2. num_arena_birthsMetaspaceArena 的创建次数。

  3. num_arena_deathsMetaspaceArena 的销毁次数。发生于对应的类加载器被回收之后。

  4. num_vsnodes_birthsVirtualSpaceNode 的创建次数。(根据前面的 VirtualSpaceList 的统计信息可以知道是 19 + 1 = 20)

  5. num_vsnodes_deathsVirtualSpaceNode 的销毁次数。

  6. num_space_committedCommit 内存的次数。

  7. num_space_uncommittedUncommit 内存的次数。

  8. num_chunks_returned_to_freelistMetaChunk 被回收到 FreeChunkListVector 的次数。

  9. num_chunks_taken_from_freelist:从 FreeChunkListVector 中获取 MetaChunk 进行分配的次数。

  10. num_chunk_mergesMetaChunk 合并的次数。

  11. num_chunk_splitsMetaChunk 拆分的次数。

  12. num_chunks_enlargedMetaChunk 扩容的次数。

  13. num_purgesMetaspaceArena 的清理次数。一般等于销毁次数。

  14. 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.

  1. MaxMetaspaceSize:元空间最大值。默认是无限制的。这里我们也没限制。

  2. CompressedClassSpaceSize:压缩类空间大小。默认是 1 GB。这里我们也没指定,所以是默认的。

  3. Initial GC threshold:初始的元空间 GC 阈值。默认是 40 MB。这里我们也没指定,所以是默认的。

  4. Current GC threshold:当前的元空间 GC 阈值。前面我们分析过这个阈值改变的机制。

  5. CDS:是否开启了 CDS。默认开启。这个我们不用太关心,主要和 CDS 特性相关(JEP 310: Application Class-Data Sharing 和 JEP 350: Dynamic CDS Archives),在以后的文章会详细分析。

  6. 元空间 MetaspaceReclaimPolicy 为 balanced

  7. commit 粒度(commit_granule_bytes)为 65536 字节,转化单位为字之后,是 8192 字(一 word 为 8 字节)。虚拟内存空间节点内存大小(virtual_space_node_default_size)为 1048576 字,转化单位为字之后,是 64 MB。当前 MetaChunk 不足以分配的时候,是否尝试扩容当前 MetaChunkenlarge_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 不足以分配的时候,是否尝试扩容当前 MetaChunkenlarge_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).

这几行日志的意思是:

  1. CDS 元数据映射到内存的地址范围是 [0x0000000800000000-0x0000000800bde000-0x0000000800bde000),大小为 12443648 字节,共享基地址为 0x0000000800000000ArchiveRelocationMode 为关闭。这些信息我们不用太关心,主要和 CDS 特性相关(JEP 310: Application Class-Data Sharing 和 JEP 350: Dynamic CDS Archives),在以后的文章会详细分析。

  2. 我们这里是默认配置,所以压缩类空间是开启的,初始化压缩类空间,映射到内存的地址范围是 [0x0000000800c00000-0x0000000840c00000),Reserved 内存大小为 1073741824 字节(1GB),默认压缩类空间最大大小就是 1GB。加载到压缩类空间的类的基地址为 0x0000000800000000(),偏移量为 0,范围为 0x100000000,这个前面也简单分析过。

  3. Bootstrap ClassLoader 创建了两个 MetaspaceArena,分别是前文分析的类元空间的 MetaspaceArena 和数据元空间的 MetaspaceArena,放入对应的 ClassLoadMetaSpace 中。不要偷取他人的劳动成果,也不要浪费自己的时间和精力,让我们一起做一个有良知的写作者。

  4. 初始化类元空间的还有数据元空间的 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.

这几行日志的意思分别是:

  1. 加载类需要从元空间申请内存,这是第一次申请,所以各个数据结构都是空的,所以需要申请新的 MetaChunk,优先考虑的与最大的 ChunkLevel 都是 12,对应 1KB。本次申请发生在 ChunkManager @0x0000ffff807863d0

  2. 申请新的 RootMetaChunk,基址 0x0000000800c00000

  3. 将新的 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:元空间对象类型,包括 ClassConstantPoolSymbolMethodKlassModulePackageOther

  • Size:本次分配的大小

这个事件也会采集堆栈信息,用来定位分配失败的源头是哪些类的加载导致的。

4.6.3.3. jdk.MetaspaceOOM 元空间 OOM 事件

前面提到过,当元空间 OOM 的时候,就会产生这个事件,这个事件包括以下属性(和 jdk.MetaspaceAllocationFailure 事件一样):

  • 事件开始时间:其实就是事件发生时间

  • 类加载器:触发 OOM 的类加载器

  • Hidden Class Loader:是否是隐藏类加载器

  • Metadata Type:元数据类型,分为属于类元空间的以及属于数据元空间的两种类型,分别是:Class 和 Metadata

  • Metaspace Object Type:元空间对象类型,包括 ClassConstantPoolSymbolMethodKlassModulePackageOther

  • 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

  • VMThreadStackSizeVM 线程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和洗稿是恶意抄袭他人劳动成果的行为,是对劳动价值的漠视和践踏! ):

  1. 首先判断要分配的栈帧大小,是否大于一页。

  2. 如果小于等于一页,不用检查,直接结束。因为如果小于一页,那么栈帧的元素个数一定小于一页,栈增长不会导致跳过保护区域,如果达到保护区域就会触发 SIGSEGV 抛出 StackOverflowError。因为每个保护区域如前面源代码所示,都是对虚拟机页大小进行对齐的,因此至少一页。

  3. 如果大于一页,则需要检查。检查当前已经使用的空间,加上栈帧占用的空间,加上保护区域与阴影区域的最大值,占用空间是否大于栈空间限制。如果大于,则抛出 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);
}

编译后执行的指令就简单多了:

  1. 如果栈帧大小小于一页:只需要考虑 Native 调用是否会导致 StackOverflow 即可。检查当前占用位置加上影子区域大小,之后判断是否会进入保护区域即可,不用考虑当前方法栈帧占用大小,因为肯定小于一页。验证是否进入保护区域也和之前讨论过的 NullPointeException 的处理是类似的,就是将 rsp - offset 的地址上的值写入 rax 上,如果 rsp - offset 处于保护区域,那么就会触发 SIGSEGV

  2. 如果栈帧大小大于一页:那么需要将当前占用位置,加上栈帧大小,加上影子区域大小,之后从当前栈帧按页检查,是否处于保护区域。因为大于一页的话,直接验证最后的位置可能会溢出到其他东西占用的内存(比如其他线程占用的内存)。

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~

 

点赞收藏
张哈希
请先登录,查看3条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

一次 GDB 源码角度分析 jvm 无响应问题

一次 GDB 源码角度分析 jvm 无响应问题

6
3