开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

全网最硬核 JVM 内存解析 - 9元空间内存分配流程 4

发表于 2023-04-25

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~
另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。
今天又是干货满满的一天,这是全网最硬核 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 下内存管理模型简述
    2. JVM commit 的内存与实际占用内存的差异
      1. JVM commit 的内存与实际占用内存的差异
    3. 大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
      1. Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
      2. Linux 大页分配方式 - Transparent Huge Pages (THP)
      3. JVM 大页分配相关参数与机制(涉及 JVM 参数:UseLargePages,UseHugeTLBFS,UseSHM,UseTransparentHugePages,LargePageSizeInBytes)
  3. Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
    1. 通用初始化与扩展流程
    2. 直接指定三个指标的方式(涉及 JVM 参数:MaxHeapSize,MinHeapSize,InitialHeapSize,Xmx,Xms)
    3. 不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
    4. 压缩对象指针相关机制(涉及 JVM 参数:UseCompressedOops)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)
      1. 压缩对象指针存在的意义(涉及 JVM 参数:ObjectAlignmentInBytes)
      2. 压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:UseCompressedOops,UseCompressedClassPointers)
      3. 压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:ObjectAlignmentInBytes,HeapBaseMinAddress)
    5. 为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:HeapBaseMinAddress)
    6. 结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:HeapBaseMinAddress,ObjectAlignmentInBytes,MinHeapSize,MaxHeapSize,InitialHeapSize)
    7. 使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
      1. 验证 32-bit 压缩指针模式
      2. 验证 Zero based 压缩指针模式
      3. 验证 Non-zero disjoint 压缩指针模式
      4. 验证 Non-zero based 压缩指针模式
    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. 什么是元数据,为什么需要元数据
    2. 什么时候用到元空间,元空间保存什么
    3. 2.1. 什么时候用到元空间,以及释放时机
    4. 2.2. 元空间保存什么
    5. 元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
      1. 元空间的整体配置以及相关参数(涉及 JVM 参数:MetaspaceSize,MaxMetaspaceSize,MinMetaspaceExpansion,MaxMetaspaceExpansion,MaxMetaspaceFreeRatio,MinMetaspaceFreeRatio,UseCompressedClassPointers,CompressedClassSpaceSize,CompressedClassSpaceBaseAddress,MetaspaceReclaimPolicy)
      2. 元空间上下文 MetaspaceContext
      3. 虚拟内存空间节点列表 VirtualSpaceList
      4. 虚拟内存空间节点 VirtualSpaceNode 与 CompressedClassSpaceSize
      5. MetaChunk
        1. ChunkHeaderPool 池化 MetaChunk 对象
        2. ChunkManager 管理空闲的 MetaChunk
      6. 类加载的入口 SystemDictionary 与保留所有 ClassLoaderData 的 ClassLoaderDataGraph
      7. 每个类加载器私有的 ClassLoaderData 以及 ClassLoaderMetaspace
      8. 管理正在使用的 MetaChunk 的 MetaspaceArena
      9. 元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
        1. 类加载器到 MetaSpaceArena 的流程
        2. 从 MetaChunkArena 普通分配 - 整体流程
        3. 从 MetaChunkArena 普通分配 - FreeBlocks 回收老的 current chunk 与用于后续分配的流程
        4. 从 MetaChunkArena 普通分配 - 尝试从 FreeBlocks 分配
        5. 从 MetaChunkArena 普通分配 - 尝试扩容 current chunk
        6. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk
        7. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 从 VirtualSpaceList 申请新的 RootMetaChunk
        8. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 将 RootMetaChunk 切割成为需要的 MetaChunk
        9. MetaChunk 回收 - 不同情况下, MetaChunk 如何放入 FreeChunkListVector
      10. ClassLoaderData 回收
    6. 元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
      1. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
      2. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
      3. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
      4. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
      5. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
      6. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
      7. 然后类加载器 1 被 GC 回收掉
      8. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
    7. 元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
      1. CommitLimiter 的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC
      2. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC
    8. jcmd VM.metaspace 元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)
      1. jcmd <pid> VM.metaspace 元空间说明
      2. 元空间相关 JVM 日志
      3. 元空间 JFR 事件详解
        1. jdk.MetaspaceSummary 元空间定时统计事件
        2. jdk.MetaspaceAllocationFailure 元空间分配失败事件
        3. jdk.MetaspaceOOM 元空间 OOM 事件
        4. jdk.MetaspaceGCThreshold 元空间 GC 阈值变化事件
        5. jdk.MetaspaceChunkFreeListSummary 元空间 Chunk FreeList 统计事件
  5. JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
    1. JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:ThreadStackSize,VMThreadStackSize,CompilerThreadStackSize,StackYellowPages,StackRedPages,StackShadowPages,StackReservedPages,RestrictReservedStack)
    2. Java 线程栈内存的结构
    3. Java 线程如何抛出的 StackOverflowError
      1. 解释执行与编译执行时候的判断(x86为例)
      2. 一个 Java 线程 Xss 最小能指定多大
  1. JVM 元空间设计

4.3. 元空间的核心概念与设计

4.3.9. 元空间内存分配流程

我们过一下元空间内存分配流程,我们会忽略一些 GC 相关的还有并发安全的细节,否则涉及的概念太多,一下说不过来,这些细节,会在以后的系列中详细提到。

4.3.9.1. 类加载器到 MetaSpaceArena 的流程

当类加载器加载类的时候,需要从对应的 ClassLoaderMetaspace 分配元空间进行存储。这个过程大概是:

image

图中有蓝色填充的方块是我们要重点分析的流程,我们先从从 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 普通分配的流程并不太复杂:

image

我们前面讲过 MetaspaceArena 的结构,如下所示:
image

对应的源码是 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,是一个链表的数组,同样大小的内存在同一数组下标的链表。

image

BlockTree 是一个在 Binary Search Tree(BST)的基础上,同样内存的节点在二叉树节点的后面形成链表的数据结构。

image

回收的流程十分简单,就是判断下 current chunk 的剩余空间,根据大小放到不同的数据结构:
image

对应的源码是 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 寻找是否有合适的内存,流程是:

image

对应的源码是 https://github.com/openjdk/jdk/blob/jdk-21%2B12/src/hotspot/share/memory/metaspace/freeBlocks.cpp#L42

  1. 首先判断,要分配的内存大小是否大于 BinList32 最大内存块大小即 33KB:如果大于,就从 BlockTree 查找不小于内存大小的最接近的内存块;如果不大于,就从 BinList32 查找是否有对应大小的内存块。
  2. 如果找到了,计算 waste,waste = 内存块大小 - 要分配的内存大小。
  3. 判断 waste 大于 FreeBlocks 最小内存块大小(即 BinList32 最小大小即 2KB)。如果大于,则要回收,和前面回收 MetaChunk 的流程一样将剩余的内存放回 FreeBlocks。

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

image

对应的源码是 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 的 ArenaGrowthPolicy:MetachunkList 的第一个 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 结构:

image

从 ChunkManager 分配新的 MetaChunk,首先会从 FreeChunkListVector 尝试搜索有没有合适的。FreeChunkListVector 如我们之前所述,是一个以 ChunkLevel 为下标的数组,每个数组都是一个 MetaChunk 的链表。commit 多的 MetaChunk 放在链表开头,完全没有 commit 的放在链表末尾。

image

对应的源码是 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

image

对应的源码是 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:

image

对应的源码是 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 向上合并之后放回:

image

对应的源码是 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 被回收的过程如下所示:

`image

ClassLoaderData 会记录所有加载的类与相关的数据(前文提到的 Klass 等等对象),所以它的析构函数中会将这些加载的数据的内存全部释放到它独有的 MetaSpaceArena 的 FreeBlocks 中,这些内存就是通过之前我们分析的流程分配的,由于之前的空间都是从 MetaspaceArena 的 MetaChunkList 中的 MetaChunk 分配的,这样的话这些 MetaChunk 的空间也都不再占用了。当然,也会把前面提到的 ClassLoaderData 独有的数据结构释放掉,还没有利用的 MetaWord 放回 ChunkManager 中。然后,清除掉它私有的 ClassLoadMetaSpace。根据前文分析我们知道 ClassLoaderMetaspace 在开启压缩类空间的情况下包括一个类元空间的 MetaspaceArena 和一个数据元空间的 MetaspaceArena。这两个 MetaspaceArena 分别要清理掉。MetaspaceArena 的析构函数会把 FreeBlocks 中的每个 MetaWord 都放回 ChunkManager,注意这里包含之前 ClassLoaderData 放回的加载类相关数据占用的空间,最后清理掉 FreeBlocks。(你洗稿的样子真丑。)

微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer
我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

  • 知乎:www.zhihu.com/people/zhxh…
  • B 站:space.bilibili.com/31359187

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

全网最硬核 JVM 内存解析 - 8元空间的核心概念与设计

发表于 2023-04-25

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~
另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。
今天又是干货满满的一天,这是全网最硬核 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 下内存管理模型简述
    2. JVM commit 的内存与实际占用内存的差异
      1. JVM commit 的内存与实际占用内存的差异
    3. 大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
      1. Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
      2. Linux 大页分配方式 - Transparent Huge Pages (THP)
      3. JVM 大页分配相关参数与机制(涉及 JVM 参数:UseLargePages,UseHugeTLBFS,UseSHM,UseTransparentHugePages,LargePageSizeInBytes)
  3. Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
    1. 通用初始化与扩展流程
    2. 直接指定三个指标的方式(涉及 JVM 参数:MaxHeapSize,MinHeapSize,InitialHeapSize,Xmx,Xms)
    3. 不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
    4. 压缩对象指针相关机制(涉及 JVM 参数:UseCompressedOops)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)
      1. 压缩对象指针存在的意义(涉及 JVM 参数:ObjectAlignmentInBytes)
      2. 压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:UseCompressedOops,UseCompressedClassPointers)
      3. 压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:ObjectAlignmentInBytes,HeapBaseMinAddress)
    5. 为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:HeapBaseMinAddress)
    6. 结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:HeapBaseMinAddress,ObjectAlignmentInBytes,MinHeapSize,MaxHeapSize,InitialHeapSize)
    7. 使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
      1. 验证 32-bit 压缩指针模式
      2. 验证 Zero based 压缩指针模式
      3. 验证 Non-zero disjoint 压缩指针模式
      4. 验证 Non-zero based 压缩指针模式
    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. 什么是元数据,为什么需要元数据
    2. 什么时候用到元空间,元空间保存什么
    3. 2.1. 什么时候用到元空间,以及释放时机
    4. 2.2. 元空间保存什么
    5. 元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
      1. 元空间的整体配置以及相关参数(涉及 JVM 参数:MetaspaceSize,MaxMetaspaceSize,MinMetaspaceExpansion,MaxMetaspaceExpansion,MaxMetaspaceFreeRatio,MinMetaspaceFreeRatio,UseCompressedClassPointers,CompressedClassSpaceSize,CompressedClassSpaceBaseAddress,MetaspaceReclaimPolicy)
      2. 元空间上下文 MetaspaceContext
      3. 虚拟内存空间节点列表 VirtualSpaceList
      4. 虚拟内存空间节点 VirtualSpaceNode 与 CompressedClassSpaceSize
      5. MetaChunk
        1. ChunkHeaderPool 池化 MetaChunk 对象
        2. ChunkManager 管理空闲的 MetaChunk
      6. 类加载的入口 SystemDictionary 与保留所有 ClassLoaderData 的 ClassLoaderDataGraph
      7. 每个类加载器私有的 ClassLoaderData 以及 ClassLoaderMetaspace
      8. 管理正在使用的 MetaChunk 的 MetaspaceArena
      9. 元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
        1. 类加载器到 MetaSpaceArena 的流程
        2. 从 MetaChunkArena 普通分配 - 整体流程
        3. 从 MetaChunkArena 普通分配 - FreeBlocks 回收老的 current chunk 与用于后续分配的流程
        4. 从 MetaChunkArena 普通分配 - 尝试从 FreeBlocks 分配
        5. 从 MetaChunkArena 普通分配 - 尝试扩容 current chunk
        6. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk
        7. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 从 VirtualSpaceList 申请新的 RootMetaChunk
        8. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 将 RootMetaChunk 切割成为需要的 MetaChunk
        9. MetaChunk 回收 - 不同情况下, MetaChunk 如何放入 FreeChunkListVector
      10. ClassLoaderData 回收
    6. 元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
      1. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
      2. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
      3. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
      4. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
      5. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
      6. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
      7. 然后类加载器 1 被 GC 回收掉
      8. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
    7. 元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
      1. CommitLimiter 的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC
      2. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC
    8. jcmd VM.metaspace 元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)
      1. jcmd <pid> VM.metaspace 元空间说明
      2. 元空间相关 JVM 日志
      3. 元空间 JFR 事件详解
        1. jdk.MetaspaceSummary 元空间定时统计事件
        2. jdk.MetaspaceAllocationFailure 元空间分配失败事件
        3. jdk.MetaspaceOOM 元空间 OOM 事件
        4. jdk.MetaspaceGCThreshold 元空间 GC 阈值变化事件
        5. jdk.MetaspaceChunkFreeListSummary 元空间 Chunk FreeList 统计事件
  5. JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
    1. JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:ThreadStackSize,VMThreadStackSize,CompilerThreadStackSize,StackYellowPages,StackRedPages,StackShadowPages,StackReservedPages,RestrictReservedStack)
    2. Java 线程栈内存的结构
    3. Java 线程如何抛出的 StackOverflowError
      1. 解释执行与编译执行时候的判断(x86为例)
      2. 一个 Java 线程 Xss 最小能指定多大
  1. 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:bugs.openjdk.java.net/browse/JDK-…
  • CompressedClassSpaceSize:如果启用了压缩类指针,则元空间会分为类元空间和数据元空间,否则只有数据元空间。这个参数限制类元空间的大小,范围是 1M ~ 3G。默认大小是 1G,如果指定了 MaxMetaspaceSize,那么为 1G 与 MaxMetaspaceSize * 0.8 中比较小的那个值,
  • CompressedClassSpaceBaseAddress:类元空间起始虚拟内存地址,这个一般不指定。作用和前文分析堆内存的堆起始位置的作用差不多。
  • MetaspaceReclaimPolicy:可以为:balanced, aggressive, 以及 none,需要注意一点的是 none 要被移除了(bugs.openjdk.org/browse/JDK-…)。默认是 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 要被移除了(bugs.openjdk.org/browse/JDK-…),这三个配置具体影响是:

image

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

1
kotlin复制代码class MetaspaceContext : public CHeapObj<mtMetaspace>

JVM 元空间,会在全局建立两个元空间上下文(MetaspaceContext),一个用于类元空间(我们后面称为类元空间 MetaspaceContext),一个用于数据元空间(我们后面称为数据元空间 MetaspaceContext)。当然,在没有启用压缩类指针的时候,只会初始化一个数据元空间 MetaspaceContext,不会初始化类元空间 MetaspaceContext,之后使用分配的时候,也只会用数据元空间 MetaspaceContext 进行分配。但是我们在后面讨论的时候,只会讨论开启压缩类指针的情况,因为这是默认并且常用的情况。

image

每个 MetaspaceContext 都会对应一个独立的 VirtualSpaceList,以及一个独立的 ChunkManager。

image

这个 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

1
kotlin复制代码class VirtualSpaceList : public CHeapObj<mtClass>

首先提一点,类元空间 MetaspaceContext 与数据元空间 MetaspaceContext 略有不同:类元空间 MetaspaceContext 的 VirtualSpaceList 是不可以扩展申请新的内存的,但是数据元空间 MetaspaceContext 的 VirtualSpaceList 是可以的。也就是说:类元空间 MetaspaceContext 的 VirtualSpaceList 其实只有一个 VirtualSpaceNode,但是数据元空间 MetaspaceContext 的 VirtualSpaceList 是一个包含多个 VirtualSpaceNode 的列表。

image

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

1
kotlin复制代码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

1
2
arduino复制代码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 的主要结构如下图所示:
image

4.3.5. MetaChunk

MetaChunk 是元空间内存分配的核心抽象,其本质就是描述一块连续的虚拟内存空间。MetaChunk 本身只是一个描述对象,它也是直接原生堆上面分配,Native Memory Tracking 中属于 Metaspace 那一类别,即元空间的抽象类占用的空间。这个描述对象是池化的,参考后面会分析的 ChunkHeaderPool。不要偷取他人的劳动成果!

元空间的任意分配,都是在某个 MetaChunk 上进行的(不要偷取他人的劳动成果!)。MetaChunk 有级别的概念,即 ChunkLevel,每个 MetaChunk 都有自己的 ChunkLevel,这个 ChunkLevel 主要代表了 MetaChunk 描述的内存空间的大小,每一个 level 都是下一个 level 大小的 2 倍:

image

从 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

1
kotlin复制代码class ChunkHeaderPool : public CHeapObj<mtMetaspace>

其实从这里我们可以推测出,MetaChunk 本身也是直接原生堆上面分配,Native Memory Tracking 中也是属于 Metaspace 那一类别。

ChunkHeaderPool 的结构是:

image

其实 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 那一类别,即元空间的抽象类占用的空间。不要偷取他人的劳动成果!

1
kotlin复制代码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 的整体结构是:

image

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

1
2
3
4
5
6
7
8
9
10
11
12
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

1
2
3
4
5
6
7
8
9
10
11
php复制代码//通过类加载器获取对应的 `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

1
cpp复制代码    class ClassLoaderData : public CHeapObj<mtClass>

如前所述,ClassLoaderData 是每个类加载器私有的。ClassLoaderData 包含的元素众多,我们这里只关心它其中与元空间内存分配相关的,即 ClassLoaderMetaspace:

https://github.com/openjdk/jdk/blob/jdk-21%2B11/src/hotspot/share/classfile/classLoaderData.hpp

1
cpp复制代码    ClassLoaderMetaspace * volatile _metaspace;

ClassLoaderMetaspace 本身直接原生堆上面分配,Native Memory Tracking 中属于 Class 那一类别,即元空间的加载类占用的空间。

https://github.com/openjdk/jdk/blob/jdk-21%2B11/src/hotspot/share/memory/classLoaderMetaspace.hpp

1
cpp复制代码    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。

image

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

MetaspaceArena 本身直接原生堆上面分配,Native Memory Tracking 中属于 Class 那一类别,即元空间的加载类占用的空间。这也是肯定的,因为跟着类加载器存在

1
cpp复制代码class MetaspaceArena : public CHeapObj<mtClass>

MetaspaceArena 结构如下所示:
image

MetaspaceArena 包含:

  • 一个 MetachunkList:管理在该 MetaspaceArena 分配的 MetaChunk 的列表,列表的第一个是当前分配内存的 MetaChunk。
  • 当前 MetaspaceArena 的 ArenaGrowthPolicy:在当前分配内存的 MetaChunk 不够分配的时候,申请新的 MetaChunk 的大小。
  • Freeblocks: 在当前分配内存的 MetaChunk 不够分配的时候,需要分配新的 MetaChunk。当前的 MetaChunk 剩余空间放入 Freeblocks。

Freeblocks 包含一个 BinList32 和一个 BlockTree。大小大于 33 字节的进入 BlockTree,否则进入 BinList32。

BinList32 类似于 FreeChunkListVector,是一个链表的数组,同样大小的内存在同一数组下标的链表。

image

BlockTree 是一个在 Binary Search Tree(BST)的基础上,同样内存的节点在二叉树节点的后面形成链表的数据结构。

image

不同的类加载器类型的类元空间的 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

1
2
3
4
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

1
2
3
4
5
php复制代码    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

1
2
3
4
5
6
7
8
php复制代码    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

1
2
3
4
5
6
7
8
php复制代码    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

1
2
3
4
arduino复制代码    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

1
2
3
4
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

1
2
3
4
arduino复制代码    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

1
2
3
4
5
php复制代码    static const chunklevel_t g_sequ_refl_non_class[] = {
chunklevel::CHUNK_LEVEL_2K,
chunklevel::CHUNK_LEVEL_1K
// .. repeat last
};

微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer
我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

  • 知乎:www.zhihu.com/people/zhxh…
  • B 站:space.bilibili.com/31359187

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

全网最硬核 JVM 内存解析 - 7元空间存储的元数据 4

发表于 2023-04-25

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~
另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。
今天又是干货满满的一天,这是全网最硬核 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 下内存管理模型简述
    2. JVM commit 的内存与实际占用内存的差异
      1. JVM commit 的内存与实际占用内存的差异
    3. 大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
      1. Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
      2. Linux 大页分配方式 - Transparent Huge Pages (THP)
      3. JVM 大页分配相关参数与机制(涉及 JVM 参数:UseLargePages,UseHugeTLBFS,UseSHM,UseTransparentHugePages,LargePageSizeInBytes)
  3. Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
    1. 通用初始化与扩展流程
    2. 直接指定三个指标的方式(涉及 JVM 参数:MaxHeapSize,MinHeapSize,InitialHeapSize,Xmx,Xms)
    3. 不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
    4. 压缩对象指针相关机制(涉及 JVM 参数:UseCompressedOops)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)
      1. 压缩对象指针存在的意义(涉及 JVM 参数:ObjectAlignmentInBytes)
      2. 压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:UseCompressedOops,UseCompressedClassPointers)
      3. 压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:ObjectAlignmentInBytes,HeapBaseMinAddress)
    5. 为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:HeapBaseMinAddress)
    6. 结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:HeapBaseMinAddress,ObjectAlignmentInBytes,MinHeapSize,MaxHeapSize,InitialHeapSize)
    7. 使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
      1. 验证 32-bit 压缩指针模式
      2. 验证 Zero based 压缩指针模式
      3. 验证 Non-zero disjoint 压缩指针模式
      4. 验证 Non-zero based 压缩指针模式
    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. 什么是元数据,为什么需要元数据
    2. 什么时候用到元空间,元空间保存什么
    3. 2.1. 什么时候用到元空间,以及释放时机
    4. 2.2. 元空间保存什么
    5. 元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
      1. 元空间的整体配置以及相关参数(涉及 JVM 参数:MetaspaceSize,MaxMetaspaceSize,MinMetaspaceExpansion,MaxMetaspaceExpansion,MaxMetaspaceFreeRatio,MinMetaspaceFreeRatio,UseCompressedClassPointers,CompressedClassSpaceSize,CompressedClassSpaceBaseAddress,MetaspaceReclaimPolicy)
      2. 元空间上下文 MetaspaceContext
      3. 虚拟内存空间节点列表 VirtualSpaceList
      4. 虚拟内存空间节点 VirtualSpaceNode 与 CompressedClassSpaceSize
      5. MetaChunk
        1. ChunkHeaderPool 池化 MetaChunk 对象
        2. ChunkManager 管理空闲的 MetaChunk
      6. 类加载的入口 SystemDictionary 与保留所有 ClassLoaderData 的 ClassLoaderDataGraph
      7. 每个类加载器私有的 ClassLoaderData 以及 ClassLoaderMetaspace
      8. 管理正在使用的 MetaChunk 的 MetaspaceArena
      9. 元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
        1. 类加载器到 MetaSpaceArena 的流程
        2. 从 MetaChunkArena 普通分配 - 整体流程
        3. 从 MetaChunkArena 普通分配 - FreeBlocks 回收老的 current chunk 与用于后续分配的流程
        4. 从 MetaChunkArena 普通分配 - 尝试从 FreeBlocks 分配
        5. 从 MetaChunkArena 普通分配 - 尝试扩容 current chunk
        6. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk
        7. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 从 VirtualSpaceList 申请新的 RootMetaChunk
        8. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 将 RootMetaChunk 切割成为需要的 MetaChunk
        9. MetaChunk 回收 - 不同情况下, MetaChunk 如何放入 FreeChunkListVector
      10. ClassLoaderData 回收
    6. 元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
      1. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
      2. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
      3. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
      4. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
      5. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
      6. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
      7. 然后类加载器 1 被 GC 回收掉
      8. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
    7. 元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
      1. CommitLimiter 的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC
      2. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC
    8. jcmd VM.metaspace 元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)
      1. jcmd <pid> VM.metaspace 元空间说明
      2. 元空间相关 JVM 日志
      3. 元空间 JFR 事件详解
        1. jdk.MetaspaceSummary 元空间定时统计事件
        2. jdk.MetaspaceAllocationFailure 元空间分配失败事件
        3. jdk.MetaspaceOOM 元空间 OOM 事件
        4. jdk.MetaspaceGCThreshold 元空间 GC 阈值变化事件
        5. jdk.MetaspaceChunkFreeListSummary 元空间 Chunk FreeList 统计事件
  5. JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
    1. JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:ThreadStackSize,VMThreadStackSize,CompilerThreadStackSize,StackYellowPages,StackRedPages,StackShadowPages,StackReservedPages,RestrictReservedStack)
    2. Java 线程栈内存的结构
    3. Java 线程如何抛出的 StackOverflowError
      1. 解释执行与编译执行时候的判断(x86为例)
      2. 一个 Java 线程 Xss 最小能指定多大
  1. 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 的类空间与非类空间区分开。如图所示:
image

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:bugs.openjdk.java.net/browse/JDK-… 以及源码:

  • 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

1
2
3
4
5
6
7
8
9
10
11
12
13
php复制代码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 对象头的类型指针指向的实例:
    • InstanceKlass:普通对象类的 Klass:
      • InstanceRefKlass:java.lang.ref.Reference 类以及子类对应的 Klass
      • InstanceClassLoaderKlass:Java 类加载器对应的 Klass
      • InstanceMirrorKlass:java.lang.Class 对应的 Klass
    • ArrayKlass:Java 数组对应的 Klass
      • ObjArrayKlass:普通对象数组对应的 Klass
      • TypeArrayKlass:原始类型数组对应的 Klass

位于数据元空间的:

  • Symbol:符号常量,即类中所有的符号字符串,例如类名称,方法名称,方法定义等等。
  • ConstantPool:运行时常量池,数据来自于类文件中的常量池。
  • ConstanPoolCache:运行时常量池缓存,用于加速常量池访问
  • ConstMethod:类文件中的方法解析后,静态信息放入 ConstMethod,这部分信息可以理解为是不变的,例如字节码,行号,方法异常表,本地变量表,参数表等等。
  • MethodCounters:方法的计数器相关数据。
  • MethodData:方法数据采集,动态编译相关数据。例如某个方法需要采集一些指标,决定是否采用 C1 C2 动态编译优化性能。
  • Method:Java 方法,包含以上 ConstMethod,MethodCounters,MethodData 的指针以及一些额外数据。
  • RecordComponent:对应 Java 14 新特性 Record,即从 Record 中解析出的关键信息。

以上这类型,我们在下一个系列全网最硬核 JVM 元空间解析中再详细说明。

微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer
我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

  • 知乎:www.zhihu.com/people/zhxh…
  • B 站:space.bilibili.com/31359187

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

全网最硬核 JVM 内存解析 - 6其他 Java 堆内存

发表于 2023-04-25

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~
另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。
今天又是干货满满的一天,这是全网最硬核 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 下内存管理模型简述
    2. JVM commit 的内存与实际占用内存的差异
      1. JVM commit 的内存与实际占用内存的差异
    3. 大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
      1. Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
      2. Linux 大页分配方式 - Transparent Huge Pages (THP)
      3. JVM 大页分配相关参数与机制(涉及 JVM 参数:UseLargePages,UseHugeTLBFS,UseSHM,UseTransparentHugePages,LargePageSizeInBytes)
  3. Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
    1. 通用初始化与扩展流程
    2. 直接指定三个指标的方式(涉及 JVM 参数:MaxHeapSize,MinHeapSize,InitialHeapSize,Xmx,Xms)
    3. 不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
    4. 压缩对象指针相关机制(涉及 JVM 参数:UseCompressedOops)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)
      1. 压缩对象指针存在的意义(涉及 JVM 参数:ObjectAlignmentInBytes)
      2. 压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:UseCompressedOops,UseCompressedClassPointers)
      3. 压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:ObjectAlignmentInBytes,HeapBaseMinAddress)
    5. 为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:HeapBaseMinAddress)
    6. 结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:HeapBaseMinAddress,ObjectAlignmentInBytes,MinHeapSize,MaxHeapSize,InitialHeapSize)
    7. 使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
      1. 验证 32-bit 压缩指针模式
      2. 验证 Zero based 压缩指针模式
      3. 验证 Non-zero disjoint 压缩指针模式
      4. 验证 Non-zero based 压缩指针模式
    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. 什么是元数据,为什么需要元数据
    2. 什么时候用到元空间,元空间保存什么
      1. 什么时候用到元空间,以及释放时机
      2. 元空间保存什么
    3. 元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
      1. 元空间的整体配置以及相关参数(涉及 JVM 参数:MetaspaceSize,MaxMetaspaceSize,MinMetaspaceExpansion,MaxMetaspaceExpansion,MaxMetaspaceFreeRatio,MinMetaspaceFreeRatio,UseCompressedClassPointers,CompressedClassSpaceSize,CompressedClassSpaceBaseAddress,MetaspaceReclaimPolicy)
      2. 元空间上下文 MetaspaceContext
      3. 虚拟内存空间节点列表 VirtualSpaceList
      4. 虚拟内存空间节点 VirtualSpaceNode 与 CompressedClassSpaceSize
      5. MetaChunk
        1. ChunkHeaderPool 池化 MetaChunk 对象
        2. ChunkManager 管理空闲的 MetaChunk
      6. 类加载的入口 SystemDictionary 与保留所有 ClassLoaderData 的 ClassLoaderDataGraph
      7. 每个类加载器私有的 ClassLoaderData 以及 ClassLoaderMetaspace
      8. 管理正在使用的 MetaChunk 的 MetaspaceArena
      9. 元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
        1. 类加载器到 MetaSpaceArena 的流程
        2. 从 MetaChunkArena 普通分配 - 整体流程
        3. 从 MetaChunkArena 普通分配 - FreeBlocks 回收老的 current chunk 与用于后续分配的流程
        4. 从 MetaChunkArena 普通分配 - 尝试从 FreeBlocks 分配
        5. 从 MetaChunkArena 普通分配 - 尝试扩容 current chunk
        6. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk
        7. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 从 VirtualSpaceList 申请新的 RootMetaChunk
        8. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 将 RootMetaChunk 切割成为需要的 MetaChunk
        9. MetaChunk 回收 - 不同情况下, MetaChunk 如何放入 FreeChunkListVector
      10. ClassLoaderData 回收
    4. 元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
      1. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
      2. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
      3. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
      4. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
      5. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
      6. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
      7. 然后类加载器 1 被 GC 回收掉
      8. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
    5. 元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
      1. CommitLimiter 的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC
      2. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC
    6. jcmd VM.metaspace 元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)
      1. jcmd <pid> VM.metaspace 元空间说明
      2. 元空间相关 JVM 日志
      3. 元空间 JFR 事件详解
        1. jdk.MetaspaceSummary 元空间定时统计事件
        2. jdk.MetaspaceAllocationFailure 元空间分配失败事件
        3. jdk.MetaspaceOOM 元空间 OOM 事件
        4. jdk.MetaspaceGCThreshold 元空间 GC 阈值变化事件
        5. jdk.MetaspaceChunkFreeListSummary 元空间 Chunk FreeList 统计事件
  5. JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
    1. JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:ThreadStackSize,VMThreadStackSize,CompilerThreadStackSize,StackYellowPages,StackRedPages,StackShadowPages,StackReservedPages,RestrictReservedStack)
    2. Java 线程栈内存的结构
    3. Java 线程如何抛出的 StackOverflowError
      1. 解释执行与编译执行时候的判断(x86为例)
      2. 一个 Java 线程 Xss 最小能指定多大
  1. Java 堆内存相关设计

3.8. 堆大小的动态伸缩

不同的 GC 堆大小动态伸缩有很大很大的差异(比如 ParallelGC 涉及 UseAdaptiveSizePolicy 启用的动态堆大小策略以及相关的 UsePSAdaptiveSurvivorSizePolicy、UseAdaptiveGenerationSizePolicyAtMinorCollection 等等等等的参数参与决定计算最新堆大小的方式以及时机),在这个系列以后的章节我们详细分析每个 GC 的时候再详细分析这些不同 GC 的动态伸缩策略。我们这里仅涉及大多数 GC 通用的堆大小伸缩涉及的参数:MinHeapFreeRatio 与 MaxHeapFreeRatio:

  • MinHeapFreeRatio:目标最小堆空闲比例,如果某次 GC 之后堆的某个区域(在某些 GC 是整个堆)空闲比例小于这个比例,那么就考虑将这个区域扩容。默认是 40,即默认是 40%,但是某些 GC 如果你不设置就会变成 0%。0% 代表从来不会因为没有达到目标最小堆空闲比例而扩容,配置为 0% 一般是为了堆的大小稳定。
  • MaxHeapFreeRatio:目标最大堆空闲比例,如果某次 GC 之后堆的某个区域(在某些 GC 是整个堆)空闲比例大于这个比例,那么就考虑将这个区域缩小。默认是 70,即默认是 70%,但是某些 GC 如果你不设置就会变成 100%。100% 代表从来不会因为没有达到目标最大堆空闲比例而扩容,配置为 100% 一般是为了堆的大小稳定。
  • MinHeapDeltaBytes:当扩容时,至少扩展多大的内存。默认是 166.4 KB(128*13/10)

对应的源码是:https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/share/runtime/globals.hpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
scss复制代码product(uintx, MinHeapFreeRatio, 40, MANAGEABLE,                    \
"The minimum percentage of heap free after GC to avoid expansion."\
" For most GCs this applies to the old generation. In G1 and" \
" ParallelGC it applies to the whole heap.") \
range(0, 100) \
constraint(MinHeapFreeRatioConstraintFunc,AfterErgo) \
product(uintx, MaxHeapFreeRatio, 70, MANAGEABLE, \
"The maximum percentage of heap free after GC to avoid shrinking."\
" For most GCs this applies to the old generation. In G1 and" \
" ParallelGC it applies to the whole heap.") \
range(0, 100) \
constraint(MaxHeapFreeRatioConstraintFunc,AfterErgo) \
product(size_t, MinHeapDeltaBytes, ScaleForWordSize(128*K), \
"The minimum change in heap space due to GC (in bytes)") \
range(0, max_uintx) \

这两个参数,在不同 GC 下的实际表现,如下:

  • SerialGC:在 SerialGC 的情况下,MinHeapFreeRatio 与 MaxHeapFreeRatio 指的仅仅是老年代的目标空闲比例,仅对老年代生效。在触发涉及老年代的 GC 的时候(其实就是 FullGC),GC 结束时,会查看(抄袭和xigao是文化的毒瘤,是对文化创造和发展的阻碍)当前老年代的空闲比例,与 MinHeapFreeRatio 和 MaxHeapFreeRatio比较 判断是否扩容或者缩小老年代的大小(这里的源码参考:https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/share/gc/serial/tenuredGeneration.cpp)。
  • ParallelGC:在 ParallelGC 的情况下,MinHeapFreeRatio 与 MaxHeapFreeRatio 指的是整个堆的大小。并且,如果这两个 JVM 参数没有明确指定的话,那么 MinHeapFreeRatio 就是 0,MaxHeapFreeRatio 就是 100(这里的源码参考:https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/share/gc/parallel/parallelArguments.cpp),相当于不会根据这两个参数调整堆大小。并且,如果 UseAdaptiveSizePolicy 是 false 的话,这两个参数也不会生效。
  • G1GC:在 G1GC 的情况下,MinHeapFreeRatio 与 MaxHeapFreeRatio 指的是整个堆的大小。在触发涉及老年代的 GC 的时候,GC 结束时,会查看当前堆的空闲比例,与 MinHeapFreeRatio 和 MaxHeapFreeRatio比较判断是否扩容还是缩小堆,通过增加或者减少 Region 数量进行堆的扩容与缩小(这里的源码参考:https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/share/gc/g1/g1HeapSizingPolicy.cpp)。
  • ShenandoahGC:这三个参数不生效
  • ZGC:这三个参数不生效

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

AggressiveHeap 是一种激进地让 JVM 使用当前系统的剩余内存的一种配置,开启会根据系统可用内存,自动设置堆大小等内存参数,将内存的一半分配给堆,另一半留给堆外其他的子系统占用内存,通过强制使用 ParallelGC 这种不会占用太多堆外内存的 GC 算法这种类似的思路限制堆外内存的使用(只能使用这个 GC,你指定其他 GC 的话会启动报错 Error occurred during initialization of VM. Multiple garbage collectors selected)。默认为 false 即不开启,可以通过 -XX:+AggressiveHeap 开启。

开启后,首先检查系统内存大小是否足够 256 MB,如果不够会报错,够得话,会计算出一个目标堆大小:

1
javascript复制代码目标堆大小 = Math.min(系统可用内存/2, 系统可用内存 - 160MB)

之后,开启这个参数会强制设置以下参数:

  • MaxHeapSize 最大堆内存为目标堆大小
  • InitialHeapSize 初始堆内存为目标堆大小
  • NewSize 和 MaxNewSize 新生代为目标堆大小 * 八分之三
  • BaseFootPrintEstimate 堆外内存占用大小预估为目标堆大小,用于指导一些堆外内存结构的初始化
  • UseLargePages 为开启,使用大页内存分配,增加实际物理内存的连续性
  • TLABSize 为 256K,即初始 TLAB 大小为 256 K,但是下面我们设置了 ResizeTLAB 为 false,所以 TLAB 就会保持为 256K
  • ResizeTLAB 为 false,也就是 TLAB 大小不再随着 GC 以及分配特征的改变而改变,减少没必要的计算,反正进程要长期存在了,就在初始就指定一个比较大的 TLAB 的值。如果对 TLAB 细节感兴趣,请参考系列的第一部:全网最硬核 JVM TLAB 解析
  • UseParallelGC 为 true,强制使用 ParallelGC
  • ThresholdTolerance 为最大值 100,ThresholdTolerance 用于动态控制对象晋升到老年代需要存活过的 GC 次数,如果 1 + ThresholdTolerance/100 * MinorGC 时间大于 MajorGC 的时间,我们就认为 MinorGC 占比过大,需要将更多对象晋升到老年代。反之,如果 1 + ThresholdTolerance/100 * MajorGC 时间大于 MinorGC 的时间,就认为 MajorGC 时间占比过多,需要将更少的对象晋升到老年代。调整成 100 可以实现这个晋升界限基本不变保持稳定。
  • ScavengeBeforeFullGC 设置为 false,在 FullGC 之前,先尝试执行一次 YoungGC。因为长期运行的应用,会经常 YoungGC 并晋升对象,需要 FullGC 的时候一般 YoungGC 无法回收那么多内存避免 FullGC,关闭它更有利于避免无效扫描弄脏 CPU 缓存。

3.10. JVM 参数 AlwaysPreTouch 的作用

在第二章的分析中,我们知道了 JVM 申请内存的流程,内存并不是在 JVM commit 一块内存之后就立刻被操作系统分配实际的物理内存的,只有真正往里面写数据的时候,才会关联实际的物理内存。所以对于 JVM 堆内存,我们也可以推测出,堆内存随着对象的分配才会关联实际的物理内存。那我们有没有办法提前强制让 committed 的内存关联实际的物理内存呢?很简单,往这些 committed 的内存中写入假数据就行了(一般是填充 0)。

对于不同的 GC,由于不同 GC 对于堆内存的设计不同,所以对于 AlwaysPreTouch 的处理也略有不同,在以后的系列我们详细解析每一种 GC 的时候,会详细分析每种 GC 的堆内存设计,这里我们就简单列举通用的 AlwaysPreTouch 处理。AlwaysPreTouch 打开后,所有新 commit 的堆内存,都会往里面填充 0,相当于写入空数据让 commit 的内存真正被分配。

不同操作系统环境下填充 0 的实现方式不太一样,但是基本思路是通过原子的方式给内存地址加 0 实现:https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/share/runtime/os.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
arduino复制代码void os::pretouch_memory(void* start, void* end, size_t page_size) {
if (start < end) {
//对齐起始与末尾
char* cur = static_cast<char*>(align_down(start, page_size));
void* last = align_down(static_cast<char*>(end) - 1, page_size);
//对内存写入空数据,通过 Atomic::add
for ( ; true; cur += page_size) {
Atomic::add(reinterpret_cast<int*>(cur), 0, memory_order_relaxed);
if (cur >= last) break;
}
}
}

在 linux x86 环境下,Atomic::add 的实现是通过 xaddq 加 lock 指令实现: https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/os_cpu/linux_x86/atomic_linux_x86.hpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
arduino复制代码template<>
template<typename D, typename I>
inline D Atomic::PlatformAdd<8>::fetch_and_add(D volatile* dest, I add_value,
atomic_memory_order order) const {
STATIC_ASSERT(8 == sizeof(I));
STATIC_ASSERT(8 == sizeof(D));
D old_value;
__asm__ __volatile__ ("lock xaddq %0,(%2)"
: "=r" (old_value)
: "0" (add_value), "r" (dest)
: "cc", "memory");
return old_value;
}

同时,如果只是串行地处理这些 Atomic::add,那是非常非常慢的。我们可以将要 preTouch 的内存分成不相交的区域,然后并发的填充这些不相交的内存区域,目前最新版本的 Java 都已经在各种不同的并发 GC 中实现了并发的 PreTouch,但是历史上不同 GC 出现过对于 AlwaysPreTouch 的不同问题,这里汇总下(Plagiarism真的可恶,滚开好么):

  • ParallelGC:
    • 从 Java 16 build 21 开始,ParallelGC 才实现并发 PreTouch:
      • Bug:https://bugs.openjdk.org/browse/JDK-8252221
      • Commit:https://github.com/openjdk/jdk/commit/9359ff03ae6b9e09e7defef148864f40e949b669
  • G1GC:
    • 在 Java 9 build 45 之前,AlwaysPreTouch 对于 G1GC 不生效,这是一个 bug:
      • Bug:https://bugs.openjdk.org/browse/JDK-8067469
      • Commit:https://github.com/openjdk/jdk/commit/f2e110fe7793b20a21f91e8ef7451814db2c8d73
    • 从 Java 9 build 139 开始,G1GC 才实现并发 PreTouch:
      • Bug:https://bugs.openjdk.org/browse/JDK-8157952
      • Commit:https://github.com/openjdk/jdk/commit/317f1aa044a8a71c52cfe733f1f4baf656c22c4c
  • ZGC:
    • 从 Java 14 build 26 开始,ZGC 才实现并发 PreTouch:
      • Bug:https://bugs.openjdk.org/browse/JDK-8234543
      • Commit:https://github.com/openjdk/jdk/commit/5e758d2368b58ceef5092e74d481b60867b5ab93

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

在前面的章节我们分析了 JVM 自动计算堆大小限制,其中第一步就是 JVM 读取系统内存信息。在容器的环境下,JVM 也能感知到当前是容器环境,并且读取对应的内存限制。让 JVM 感知容器环境的相关 JVM 参数是 UseContainerSupport,默认值为 true,即让 JVM 感知容器的配置,相关源码:https://github.com/openjdk/jdk/blob/jdk-21+3/src/hotspot/os/linux/globals_linux.hpp:

1
2
arduino复制代码product(bool, UseContainerSupport, true,                          \
"Enable detection and runtime container configuration support") \

这个配置默认开启,在开启的情况下,JVM 会通过下面的流程读取内存限制:

image

可以看出,针对 Cgroup V1 与 V2 的情况,以及没有限制 pod 的 Memory limit 的情况,都考虑到了。

3.12. SoftMaxHeapSize - 用于平滑迁移更耗内存的 GC 使用

由于那种完并发的 GC(目标是完全无 Stop the world 暂停或者是亚毫秒暂停的 GC),例如 ZGC ,需要在堆外使用比 G1GC 以及 ParallelGC 多的多的空间(指的就是我们后面会分析到的 Native Memory Tracking 的 GC 部分占用的内存),并且由于 ZGC 这种目前是未分代的(Java 20 之后会引入分代 ZGC),导致 GC 在堆外占用的内存会更多。所以我们一般认为,在从 G1GC,或者 ParallelGC 切换到 ZGC 的时候,就算最大堆大小等各种 JVM 参数不变,JVM 也会需要更多的物理内存。但是,在实际的生产中,修改 JVM GC 是比较简单的,修改下启动参数就行了,但是给 JVM 加内存是比较困难的,因为是实际要消耗的资源。如果不修改 JVM 内存限制参数,也不加可用内存,线上可能会在换 GC 后经常出现被 OOMkiller 干掉的情况,还有剽窃狗被干掉了。

为了能让大家更平滑的切换 GC,以及对于线上应用,我们可能实际不一定需要用原来配置的堆大小的空间,JVM 针对 ShenandoahGC 以及 ZGC 引入了 SoftMaxHeapSize 这个参数(目前这个参数只对于这种专注于避免全局暂停的 GC 生效)。这个参数虽然默认是 0,但是如果没有指定的话,会自动设置为前文提到的 MaxHeapSize 大小。参考源码:

https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/share/gc/shared/gc_globals.hpp

1
2
3
scss复制代码product(size_t, SoftMaxHeapSize, 0, MANAGEABLE,                     \
"Soft limit for maximum heap size (in bytes)") \
constraint(SoftMaxHeapSizeConstraintFunc,AfterMemoryInit) \

https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/share/gc/shared/gcArguments.cpp

1
2
3
4
scss复制代码//如果没有设置 SoftMaxHeapSize,自动设置为前文提到的 MaxHeapSize 大小
if (FLAG_IS_DEFAULT(SoftMaxHeapSize)) {
FLAG_SET_ERGO(SoftMaxHeapSize, MaxHeapSize);
}

ZGC 与 ShenandoahGC 的堆设计,都有软最大大小限制的概念。这个软最大大小是随着时间与 GC 表现(例如分配速率,空闲率等)不断变化的,这两个 GC 会在堆扩展到软最大大小之后,尽量就不扩展堆大小,尽量通过激进的 GC 回收空间。只有在暂停世界都完全无法回收足够内存用以分配的时候,才会尝试扩展,这之后最大限制就到了 MaxHeapSize。SoftMaxHeapSize 会给这个软最大大小一个指导值,让软最大大小不要超过这个值。

微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer
我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

  • 知乎:www.zhihu.com/people/zhxh…
  • B 站:space.bilibili.com/31359187

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

全网最硬核 JVM 内存解析 - 5压缩对象指针相关机制

发表于 2023-04-25

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~
另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。
今天又是干货满满的一天,这是全网最硬核 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 下内存管理模型简述
    2. JVM commit 的内存与实际占用内存的差异
      1. JVM commit 的内存与实际占用内存的差异
    3. 大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
      1. Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
      2. Linux 大页分配方式 - Transparent Huge Pages (THP)
      3. JVM 大页分配相关参数与机制(涉及 JVM 参数:UseLargePages,UseHugeTLBFS,UseSHM,UseTransparentHugePages,LargePageSizeInBytes)
  3. Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
    1. 通用初始化与扩展流程
    2. 直接指定三个指标的方式(涉及 JVM 参数:MaxHeapSize,MinHeapSize,InitialHeapSize,Xmx,Xms)
    3. 不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
    4. 压缩对象指针相关机制(涉及 JVM 参数:UseCompressedOops)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)
      1. 压缩对象指针存在的意义(涉及 JVM 参数:ObjectAlignmentInBytes)
      2. 压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:UseCompressedOops,UseCompressedClassPointers)
      3. 压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:ObjectAlignmentInBytes,HeapBaseMinAddress)
    5. 为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:HeapBaseMinAddress)
    6. 结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:HeapBaseMinAddress,ObjectAlignmentInBytes,MinHeapSize,MaxHeapSize,InitialHeapSize)
    7. 使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
      1. 验证 32-bit 压缩指针模式
      2. 验证 Zero based 压缩指针模式
      3. 验证 Non-zero disjoint 压缩指针模式
      4. 验证 Non-zero based 压缩指针模式
    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. 什么是元数据,为什么需要元数据
    2. 什么时候用到元空间,元空间保存什么
      1. 什么时候用到元空间,以及释放时机
      2. 元空间保存什么
    3. 元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
      1. 元空间的整体配置以及相关参数(涉及 JVM 参数:MetaspaceSize,MaxMetaspaceSize,MinMetaspaceExpansion,MaxMetaspaceExpansion,MaxMetaspaceFreeRatio,MinMetaspaceFreeRatio,UseCompressedClassPointers,CompressedClassSpaceSize,CompressedClassSpaceBaseAddress,MetaspaceReclaimPolicy)
      2. 元空间上下文 MetaspaceContext
      3. 虚拟内存空间节点列表 VirtualSpaceList
      4. 虚拟内存空间节点 VirtualSpaceNode 与 CompressedClassSpaceSize
      5. MetaChunk
        1. ChunkHeaderPool 池化 MetaChunk 对象
        2. ChunkManager 管理空闲的 MetaChunk
      6. 类加载的入口 SystemDictionary 与保留所有 ClassLoaderData 的 ClassLoaderDataGraph
      7. 每个类加载器私有的 ClassLoaderData 以及 ClassLoaderMetaspace
      8. 管理正在使用的 MetaChunk 的 MetaspaceArena
      9. 元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
        1. 类加载器到 MetaSpaceArena 的流程
        2. 从 MetaChunkArena 普通分配 - 整体流程
        3. 从 MetaChunkArena 普通分配 - FreeBlocks 回收老的 current chunk 与用于后续分配的流程
        4. 从 MetaChunkArena 普通分配 - 尝试从 FreeBlocks 分配
        5. 从 MetaChunkArena 普通分配 - 尝试扩容 current chunk
        6. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk
        7. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 从 VirtualSpaceList 申请新的 RootMetaChunk
        8. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 将 RootMetaChunk 切割成为需要的 MetaChunk
        9. MetaChunk 回收 - 不同情况下, MetaChunk 如何放入 FreeChunkListVector
      10. ClassLoaderData 回收
    4. 元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
      1. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
      2. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
      3. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
      4. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
      5. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
      6. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
      7. 然后类加载器 1 被 GC 回收掉
      8. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
    5. 元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
      1. CommitLimiter 的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC
      2. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC
    6. jcmd VM.metaspace 元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)
      1. jcmd <pid> VM.metaspace 元空间说明
      2. 元空间相关 JVM 日志
      3. 元空间 JFR 事件详解
        1. jdk.MetaspaceSummary 元空间定时统计事件
        2. jdk.MetaspaceAllocationFailure 元空间分配失败事件
        3. jdk.MetaspaceOOM 元空间 OOM 事件
        4. jdk.MetaspaceGCThreshold 元空间 GC 阈值变化事件
        5. jdk.MetaspaceChunkFreeListSummary 元空间 Chunk FreeList 统计事件
  5. JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
    1. JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:ThreadStackSize,VMThreadStackSize,CompilerThreadStackSize,StackYellowPages,StackRedPages,StackShadowPages,StackReservedPages,RestrictReservedStack)
    2. Java 线程栈内存的结构
    3. Java 线程如何抛出的 StackOverflowError
      1. 解释执行与编译执行时候的判断(x86为例)
      2. 一个 Java 线程 Xss 最小能指定多大
  1. Java 堆内存相关设计

3.4. 压缩对象指针相关机制 - UseCompressedOops

3.4.1. 压缩对象指针存在的意义

现代机器大部分是 64 位的,JVM 也从 9 开始仅提供 64 位的虚拟机。在 JVM 中,一个对象指针,对应进程存储这个对象的虚拟内存的起始位置,也是 64 位大小:

我们知道,对于 32 位寻址,最大仅支持 4GB 内存的寻址,这在现在的 JVM 很可能不够用,可能仅仅堆大小就超过 4GB。所以目前对象指针一般是 64 位大小来支持大内存。但是,这相对 32 位指针寻址来说,性能上却有衰减。我们知道,CPU 仅能处理寄存器里面的数据,寄存器与内存之间,有很多层 CPU 缓存,虽然内存越来越便宜也越来越大,但是 CPU 缓存并没有变大,这就导致如果使用 64 位的指针寻址,相对于之前 32 位的,CPU 缓存能容纳的指针个数小了一倍。

Java 是面向对象的语言,JVM 中最多的操作,就是对对象的操作,比如 load 一个对象的字段,store 一个对象的字段,这些都离不开访问对象指针。所以 JVM 想尽可能的优化对象指针,这就引入了压缩对象指针,让对象指针在条件满足的情况下保持原来的 32 位。

对于 32 位的指针,假设每一个 1 代表 1 字节,那么可以描述 0~2^32-1 这 2^32 字节也就是 4 GB 的虚拟内存。

image

如果我让每一个 1 代表 8 字节呢?也就是让这块虚拟内存是 8 字节对齐,也就是我在使用这块内存时候,最小的分配单元就是 8 字节。对于 Java 堆内存,也就是一个对象占用的空间,必须是 8 字节的整数倍,不足的话会填充到 8 字节的整数倍用于保证对齐。这样最多可以描述 2^32 * 8 字节也就是 32 GB 的虚拟内存。

image

这就是压缩指针的原理,上面提到的相关 JVM 参数是:ObjectAlignmentInBytes,这个 JVM 参数表示 Java 堆中的每个对象,需要按照几字节对齐,也就是堆按照几字节对齐,值范围是 8 ~ 256,必须是 2 的 n 次方,因为 2 的 n 次方能简化很多运算,例如对于 2 的 n 次方取余数就可以简化成对于 2 的 n 次方减一取与运算,乘法和除法可以简化移位。

如果配置最大堆内存超过 32 GB(当 JVM 是 8 字节对齐),那么压缩指针会失效(其实不是超过 32GB,会略小于 32GB 的时候就会失效,还有其他的因素影响,下一节会讲到)。 但是,这个 32 GB 是和字节对齐大小相关的,也就是 -XX:ObjectAlignmentInBytes=8 配置的大小(默认为8字节,也就是 Java 默认是 8 字节对齐)。如果你配置 -XX:ObjectAlignmentInBytes=16,那么最大堆内存超过 64 GB 压缩指针才会失效,如果你配置 -XX:ObjectAlignmentInBytes=32,那么最大堆内存超过 128 GB 压缩指针才会失效.

3.4.2. 压缩对象指针与压缩类指针的关系演进

老版本中, UseCompressedClassPointers 取决于 UseCompressedOops,即压缩对象指针如果没开启,那么压缩类指针也无法开启。但是从 Java 15 Build 23 开始, UseCompressedClassPointers 已经不再依赖 UseCompressedOops 了,两者在大部分情况下已经独立开来。除非在 x86 的 CPU 上面启用 JVM Compiler Interface(例如使用 GraalVM)。参考 JDK ISSUE:bugs.openjdk.java.net/browse/JDK-… 以及源码:

  • 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

3.4.3. 压缩对象指针的不同模式与寻址优化机制

对象指针与压缩对象指针如何转化,我们先来思考一些问题。通过第二章的分析我们知道,每个进程有自己的虚拟地址空间,并且从 0 开始的一些低位空间,是给进程的一些系统调用保留的空间,例如 0x0000 0000 0000 0000 ~ 0x0000 0000 0040 0000 是保留区不可使用,如下图所示(本图来自于bin 神(公众号:bin 的技术小屋)的系列文章:一步一图带你深入理解 Linux 虚拟内存管理)

image

进程可以申请的空间,是上图所示的原生堆空间。所以,JVM 进程的虚拟内存空间,肯定不会从 0x0000 0000 0000 0000 开始。不同的操作系统,这个原生堆空间的起始不太一样,这里我们不关心具体的位置,我们仅知道一点:JVM 需要从虚拟内存的某一点开始申请内存,并且,需要预留出足够多的空间,给可能的一些系统调用机制使用,比如前面我们 native memory tracking 中看到的一些 malloc 内存,其实某些就在这个预留空间中分配的。一般的,JVM 会优先考虑 Java 堆的内存在原生堆分配,之后再在原生堆分配其他的,例如元空间,代码缓存空间等等。

JVM 在 Reserve 分配 Java 堆空间的时候,会一下子 Reserve 最大 Java 堆空间的大小,然后在此基础上 Reserve 分配其他的存储空间。之后分配 Java 对象,在 Reserve 的 Java 堆内存空间内 Commit 然后写入数据映射物理内存分配 Java 对象。根据前面说的 Java 堆大小的伸缩策略,决定继续 Commit 占用更多物理内存还是 UnCommit 释放物理内存:

image

Java 是一个面向对象的语言,JVM 中执行最多的就是访问这些对象,在 JVM 的各种机制中,必须无时无刻考虑怎么优化访问这些对象的速度,对于压缩对象指针,JVM 就考虑了很多优化。如果我们要使用压缩对象指针,那么需要将这个 64 位的地址,转换为 32 位的地址。然后在读取压缩对象指针所指向的对象信息的时候,需要将这个 32 位的地址,解析为 64 位的地址之后寻址读取。这个转换公式,如下所示:

  1. 64 位地址 = 基址 + (压缩对象指针 << 对象对齐偏移)
  2. 压缩对象指针 = (64 位地址 - 基址) >> 对象对齐偏移

基址其实就是对象地址的开始,注意,这个基址不一定是 Java 堆的开始地址,我们后面就会看到。对象对齐偏移与前面提到的 ObjectAlignmentInBytes 相关,例如 ObjectAlignmentInBytes=8 的情况下,对象对齐偏移就是 3 (因为 8 是 2 的 3 次方)。我们针对这个公式进行优化:

首先,我们考虑把基址和对象对齐偏移去掉,那么压缩对象指针可以直接作为对象地址使用。什么情况下可以这样呢?那么就是对象地址从 0 开始算,并且最大堆内存 + Java 堆起始位置不大于 4GB。因为这种情况下,Java 堆中对象的最大地址不会超过 4GB,那么压缩对象指针的范围可以直接表示所有 Java 堆中的对象。可以直接使用压缩对象指针作为对象实际内存地址使用。这里为啥是最大堆内存 + Java 堆起始位置不大于 4GB?因为前面的分析,我们知道进程可以申请的空间,是原生堆空间。所以,Java 堆起始位置,肯定不会从 0x0000 0000 0000 0000 开始。

image

如果最大堆内存 + Java 堆起始位置大于 4GB,第一种优化就不能用了,对象地址偏移就无法避免了。但是如果可以保证最大堆内存 + Java 堆起始位置小于 32位 * ObjectAlignmentInBytes,默认 ObjectAlignmentInBytes=8 的情况即 32GB,我们还是可以让基址等于 0,这样 64 位地址 = (压缩对象指针 << 对象对齐偏移)

image

但是,在ObjectAlignmentInBytes=8 的情况,如果最大堆内存太大,接近 32GB,想要保证最大堆内存 + Java 堆起始位置小于 32GB,那么 Java 堆起始位置其实就快接近 0 了,这显然不行。所以在最大堆内存接近 32GB 的时候,上面第二种优化也就失效了。但是我们可以让 Java 堆从一个与 32GB 地址完全不相交的地址开始,这样加法就可以优化为取或运算,即64 位地址 = 基址 |(压缩对象指针 << 对象对齐偏移)

image

最后,在ObjectAlignmentInBytes=8 的情况,如果用户通过 HeapBaseMinAddress 自己指定了 Java 堆开始的地址,并且与 32GB 地址相交,并最大堆内存 + Java 堆起始位置大于 32GB,但是最大堆内存没有超过 32GB,那么就无法优化了,只能 64 位地址 = 基址 + (压缩对象指针 << 对象对齐偏移)

image

总结下,上面我们说的那四种模式,对应 JVM 中的压缩对象指针的四种模式(以下叙述基于 ObjectAlignmentInBytes=8 的情况,即默认情况):

  1. 32-bit 压缩指针模式:最大堆内存 + Java 堆起始位置不大于 4GB(并且 Java 堆起始位置不能太小),64 位地址 = 压缩对象指针
  2. Zero based 压缩指针模式:最大堆内存 + Java 堆起始位置不大于 32GB(并且 Java 堆起始位置不能太小),64 位地址 = (压缩对象指针 << 对象对齐偏移)
  3. Non-zero disjoint 压缩指针模式:最大堆内存不大于 32GB,由于要保证 Java 堆起始位置不能太小,最大堆内存 + Java 堆起始位置大于 32GB,64 位地址 = 基址 |(压缩对象指针 << 对象对齐偏移)
  4. Non-zero based 压缩指针模式:用户通过 HeapBaseMinAddress 自己指定了 Java 堆开始的地址,并且与 32GB 地址相交,并最大堆内存 + Java 堆起始位置大于 32GB,但是最大堆内存没有超过 32GB,64 位地址 = 基址 + (压缩对象指针 << 对象对齐偏移)

3.5. 为何预留第 0 页,压缩对象指针 null 判断擦除的实现

前面我们知道,JVM 中的压缩对象指针有四种模式。对于地址非从 0 开始的那两种,即 Non-zero disjoint 和 Non-zero based 这两种,堆的实际地址并不是从 HeapBaseMinAddress 开始,而是有一页预留下来,被称为第 0 页,这一页不映射实际内存,如果访问这一页内部的地址,会有 Segment Fault 异常。那么为什么要预留这一页呢?主要是为了 null 判断优化,实现 null 判断擦除。

我们都知道,Java 中如果对于一个 null 的引用变量进行成员字段或者方法的访问,会抛出 NullPointerException。但是,这个是如何实现的呢?我们的代码中没有明确的 null 判断,如果是 null 就抛出 NullPointerException,但是 JVM 还是针对 null 可以抛出 NullPointerException 这个 Java 异常。可以猜测出,JVM 可能在访问每个引用变量进行成员字段或者方法的时候,都会做这样一个判断:

1
2
3
csharp复制代码if (o == null) {
throw new NullPoniterException();
}

但是,如果每次访问每个引用变量进行成员字段或者方法的时候都做这样一个判断,是很低效率的行为。所以,在解释执行的时候,可能每次访问每个引用变量进行成员字段或者方法的时候都做这样一个判断。在代码运行一定次数,进入 C1,C2 的编译优化之后,这些 null 判断可能会被擦除。可能擦除的包括:

  1. 成员方法对于 this 的访问,可以将 this 的 null 判断擦除。
  2. 代码中明确判断了某个变量是否为 null,并且这个变量不是 volatile 的
  3. 前面已经有了 a.something() 类似的访问,并且 a 不是 volatile 的,后面 a.somethingElse() 就不用再做 null 检查了
  4. 等等等等…

对于无法擦除的,JVM 倾向于做出一个假设,即这个变量大概率不会为 null,JIT 优化先直接将 null 判断擦除。Java 中的 null,对应压缩对象指针的值为 0:

1
kotlin复制代码enum class narrowOop : uint32_t { null = 0 };

对于压缩对象指针地址为 0 的地方进行访问,实际上就是针对前面我们讨论的压缩对象指针基址进行访问,在四种模式下:

  1. 32-bit 压缩指针模式:就是对于 0x0000 0000 0000 0000 进行访问,但是前面我们知道,0x0000 0000 0000 0000 是保留区域,无法访问,会有 Segment Fault 错误,发出 SIGSEGV 信号
  2. Zero based 压缩指针模式:就是对于 0x0000 0000 0000 0000 进行访问,但是前面我们知道,0x0000 0000 0000 0000 是保留区域,无法访问,会有 Segment Fault 错误,发出 SIGSEGV 信号
  3. Non-zero disjoint 压缩指针模式:就是对于基址进行访问,但是前面我们知道,基址 + JVM 系统页大小为仅 Reserve 但是不会 commit 的预留区域,无法访问,会有 Segment Fault 错误,发出 SIGSEGV 信号
  4. Non-zero based 压缩指针模式:就是对于基址进行访问,但是前面我们知道,基址 + JVM 系统页大小为仅 Reserve 但是不会 commit 的预留区域,无法访问,会有 Segment Fault 错误,发出 SIGSEGV 信号

对于非压缩对象指针的情况,更简单,非压缩对象指针 null 就是 0x0000 0000 0000 0000,就是对于 0x0000 0000 0000 0000 进行访问,但是前面我们知道,0x0000 0000 0000 0000 是保留区域,无法访问,会有 Segment Fault 错误,发出 SIGSEGV 信号

可以看出,如果 JIT 优化将 null 判断擦除,那么在真的遇到 null 的时候,会有 Segment Fault 错误,发出 SIGSEGV 信号。JVM 有对于 SIGSEGV 信号的处理:

1
2
3
4
5
6
7
8
9
10
rust复制代码//这是在 AMD64 CPU 下的代码
} else if (
//如果信号是 SIGSEGV
sig == SIGSEGV &&
//并且是由于遇到擦除 null 判断的地方遇到 null 导致的 SIGSEGV(后面我们看到很多其他地方用到了 SIGSEGV)
MacroAssembler::uses_implicit_null_check(info->si_addr)
) {
// 如果是由于遇到 null 导致的 SIGSEGV,那么就需要评估下,是否要继续擦除这里的 null 判断了
stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::IMPLICIT_NULL);
}

JVM 不仅 null 检查擦除使用了 SIGSEGV 信号,还有其他地方也用到了(例如后面我们会详细分析的 StackOverflowError 的实现)。所以,我们需要通过判断下发生 SIGSEGV 信号的地址,如果地址是我们上面列出的范围,则是擦除 null 判断的地方遇到 null 导致的 SIGSEGV:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
arduino复制代码bool MacroAssembler::uses_implicit_null_check(void* address) {
uintptr_t addr = reinterpret_cast<uintptr_t>(address);
uintptr_t page_size = (uintptr_t)os::vm_page_size();
#ifdef _LP64
//如果压缩对象指针开启
if (UseCompressedOops && CompressedOops::base() != NULL) {
//如果存在预留页(第 0 页),起点是基址
uintptr_t start = (uintptr_t)CompressedOops::base();
//如果存在预留页(第 0 页),终点是基址 + 页大小
uintptr_t end = start + page_size;
//如果地址范围在第 0 页,则是擦除 null 判断的地方遇到 null 导致的 `SIGSEGV`
if (addr >= start && addr < end) {
return true;
}
}
#endif
//如果在整个虚拟空间的第 0 页,则是擦除 null 判断的地方遇到 null 导致的 `SIGSEGV`
return addr < page_size;
}

我们分别代入压缩对象指针的 4 种情况:

  1. 32-bit 压缩指针模式:就是对于 0x0000 0000 0000 0000 进行访问,地址位于第 0 页,uses_implicit_null_check 返回 true
  2. Zero based 压缩指针模式:就是对于 0x0000 0000 0000 0000 进行访问,地址位于第 0 页,uses_implicit_null_check 返回 true
  3. Non-zero disjoint 压缩指针模式:就是对于基址进行访问,地址位于第 0 页,uses_implicit_null_check 返回 true
  4. Non-zero based 压缩指针模式:就是对于基址进行访问,地址位于第 0 页,uses_implicit_null_check 返回 true

对于非压缩对象指针的情况,更简单,非压缩对象指针 null 就是 0x0000 0000 0000 0000,就是对于基址进行访问,地址位于第 0 页,uses_implicit_null_check 返回 true

这样,我们知道,JIT 可能会将 null 检查擦除,通过 SIGSEGV 信号抛出 NullPointerException。但是,通过 SIGSEGV 信号要经过系统调用,系统调用是一个很低效的行为,我们需要尽量避免(对于抄袭狗就不不必了)。但是这里的假设就是大概率不为 null,所以使用系统调用也无所谓。但是如果一个地方经常出现 null,JIT 就会考虑不这么优化了,将代码去优化并重新编译,不再擦除 null 检查而是使用显式 null 检查抛出。

最后,我们知道了,要预留第 0 页,不映射内存,实际就是为了让对于基址进行访问可以触发 Segment Fault,JVM 会捕捉这个信号,查看触发这个信号的内存地址是否属于第一页,如果属于那么 JVM 就知道了这个是对象为 null 导致的。不过从前面看,我们其实只是为了不映射基址对应的地址,那为啥要保留一整页呢?这个是处于内存对齐与寻址访问速度的考量,里面映射物理内存都是以页为单位操作的,所以内存需要按页对齐。

3.6. 结合压缩对象指针与前面提到的堆内存限制的初始化的关系

前面我们说明了不手动指定三个指标的情况下,这三个指标 (MinHeapSize,MaxHeapSize,InitialHeapSize) 是如何计算的,但是没有涉及压缩对象指针。如果压缩对象指针开启,那么堆内存限制的初始化之后,会根据参数确定压缩对象指针是否开启:

  1. 首先,确定 Java 堆的起始位置:
  2. 第一步,在不同操作系统不同 CPU 环境下,HeapBaseMinAddress 的默认值不同,大部分环境下是 2GB,例如对于 Linux x86 环境,查看源码:github.com/openjdk/jdk…:define_pd_global(size_t, HeapBaseMinAddress, 2*G);
  3. 将 DefaultHeapBaseMinAddress 设置为 HeapBaseMinAddress 的默认值,即 2GB
  4. 如果用户在启动参数中指定了 HeapBaseMinAddress,如果 HeapBaseMinAddress 小于 DefaultHeapBaseMinAddress,将 HeapBaseMinAddress 设置为 DefaultHeapBaseMinAddress
  5. 计算压缩对象指针堆的最大堆大小:
  6. 读取对象对齐大小 ObjectAlignmentInBytes 参数的值,默认为 8
  7. 对 ObjectAlignmentInBytes 取 2 的对数,记为 LogMinObjAlignmentInBytes
  8. 将 32 位左移 LogMinObjAlignmentInBytes 得到 OopEncodingHeapMax 即不考虑预留区的最大堆大小
  9. 如果需要预留区,即 Non-Zero Based Disjoint 以及 Non-Zero Based 这两种模式下,需要刨除掉预留区即第 0 页的大小,即 OopEncodingHeapMax - 第 0 页的大小
  10. 读取当前 JVM 配置的最大堆大小(前面我们分析了最大堆大小如何计算出来的)
  11. 如果 JVM 配置的最大堆小于压缩对象指针堆的最大堆大小,并且没有通过 JVM 启动参数明确关闭压缩对象指针,则开启压缩对象指针。否则,关闭压缩对象指针。你洗稿的样子真丑。
  12. 如果压缩对象指针关闭,根据前面分析过的是否压缩类指针强依赖压缩对象指针,如果是,关闭压缩类指针

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

引入 jol 依赖:

1
2
3
4
5
xml复制代码<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>

编写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码package test;

import org.openjdk.jol.info.ClassLayout;

public class TestClass {
//TestClass 对象仅仅包含一个字段 next(洗稿狗滚)
private String next = new String();

public static void main(String[] args) throws InterruptedException {
//在栈上新建一个 tt 本地变量,指向一个在堆上新建的 TestClass 对象
final TestClass tt = new TestClass();
//使用 jol 输出 tt 指向的对象的结构(抄袭不得好死)
System.out.println(ClassLayout.parseInstance(tt).toPrintable());
//无限等待,防止程序退出
Thread.currentThread().join();
}
}

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

接下来我们先测试第一种压缩对象指针模式(32-bit)的情况,即 Java 堆位于 0x0000 0000 0000 0000 ~ 0x 0000 0001 0000 0000(0~4GB) 之间的情况,使用下面的启动参数启动这个程序:

1
ruby复制代码-Xmx32M -Xlog:coops*=debug

其中 -Xlog:coops*=debug 代表查看 JVM 日志中带 coops 标签的 debug 日志。这个日志会告诉你堆的起始虚拟内存位置,以及堆 reserved 的空间大小,以及 压缩对象指针的模式。

启动后,查看日志输出:

1
2
3
4
5
6
7
8
python复制代码[0.006s][debug][gc,heap,coops] Heap address: 0x00000000fe000000, size: 32 MB, Compressed Oops mode: 32-bit
test.TestClass object internals:个人爱好钻研技术分享,请抄袭狗滚开。
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00c01000
12 4 java.lang.String TestClass.next (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

第一行日志告诉我们,堆的起始位置是 0x0000 0000 fe00 0000,大小是 32 MB,压缩对象指针模式是 32-bit。其中 0x0000 0000 fe00 0000 加上 32 MB,结果就是 4GB 0x0000 0001 0000 0000。可以看出之前说的 Java 堆会从界限减去最大堆大小的位置开始 reserve 的结论是对的。在这种情况下,0x0000 0000 0000 0000 ~ 0x0000 0000 fdff ffff 的内存就给之前所说的进程系统调用以及原生内存分配使用。

后面的日志是关于 jol 输出对象结构的,可以看出目前这个对象包含一个 markword (0x0000000000000001),一个压缩类指针(0x00c01000),以及字段 next。我们使用 jhsdb 看一下进程的具体虚拟内存的内容验证下

首先打开 jhsdb gui 模式:jhsdb hsdb

image

之后 “File” -> “Attach to Hotspot Process”,输入你的 JVM 进程号:
image

成功 Attach 之后,可以看到面板上有你的 JVM 进程的所有线程,目前我们就看 main 线程即可,点击 main 线程,之后点击下图红框的按钮(查看线程栈内存):
image

之后我们在 main 线程栈内存中可以找到代码中的本地变量 tt:
image

这里我们可以看到变量 tt 存储的值,其实就是对象的地址,我们打开 “Tools” -> “Memory Viewer”,这个是进程虚拟内存查看器,可以查看内存地址的实际值。还有 “Tools” -> “Inspector”,将地址转换为 JVM 的 C++ 对应对象。在这两个窗口都输入上面在 main 线程栈内存看到的本地变量 tt 的值:
image

从上图我们可以看到,tt 保存的对象,对象位置,也就是对象起始地址是 0x00000000ffec7450,对象头是 0x0000 0000 ffec 7450 ~ 0x0000 0000 ffec 7457,保存的值是 0x0000 0000 0000 0001,这个和上面 jol 输出的一模一样。压缩类指针是 0x0000 0000 ffec 7458 ~ 0x0000 0000 ffec 745b,保存的值是 0x00c0 1000,这个和上面 jol 输出的压缩类指针地址一模一样。之后是 next 字段值,范围是 0x0000 0000 ffec 745c ~ 0x0000 0000 ffec 745f,保存的值是 0xffec 7460,对应的字符串对象实际地址也是 0x0000 0000 ffec 7460。可以看出,和我们之前说的 32-bit 模式的压缩类指针的特点一模一样。

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

下一个我们尝试 Zero based 模式,使用参数 -Xmx2050M -Xlog:coops*=debug 启动程序(和你的平台相关,建议你查看下在你的平台 HeapBaseMinAddress 默认的大小,一般对于 x86 都是 2G,所以指定一个大于 4G - 2G = 2G 的最大堆内存大小的值即可),日志输出是:

1
2
3
4
5
6
7
8
yaml复制代码[0.006s][debug][gc,heap,coops] Heap address: 0x000000077fe00000, size: 2050 MB, Compressed Oops mode: Zero based, Oop shift amount: 3                      洗稿的狗也遇到不少
test.TestClass object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000009 (non-biasable; age: 1)
8 4 (object header: class) 0x00c01000
12 4 java.lang.String TestClass.next (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

这次我们发现,Java 堆从 0x0000 0007 7fe0 0000 开始了,如果你用 0x0000 0007 7fe0 0000 加上 2050 MB 就会发现正好等于 32GB,可以看出之前说的 Java 堆会从界限减去最大堆大小的位置开始 reserve 的结论是对的。

后面的日志是关于 jol 输出对象结构的,可以看出目前这个对象包含一个 markword(0x0000000000000009,由于我的程序启动后输出 jol 日志之前经过了一次 GC,所以当前值与前面一个例子的不一样),一个压缩类指针(0x00c01000),以及字段 next。

我们使用 jhsdb 看一下进程的具体虚拟内存的内容验证下目前的压缩对象指针的内容,前面的步骤与上一个例子一样,我们直接看最后的:
image

如上图所示,tt 保存的对象,从 0x0000 0007 9df7 2640 开始,我们找到 next 字段,它保存的值是 0xf3be ed80,将其左移三位,就是 0x0000 0007 9df7 6c00(inspector 中显示的是帮你解压缩之后的对象地址,Memory Viewer 中是虚拟内存实际保存的值)

接下来我们试一下通过 HeapBaseMinAddress 让第一个例子也变成 Zero based 模式。使用下面的启动参数 -Xmx32M -Xlog:coops*=debug -XX:HeapBaseMinAddress=4064M,其中 4064MB + 32MB = 4GB,启动后可以通过日志发现模式还是 32-bit:[0.005s][debug][gc,heap,coops] Heap address: 0x00000000fe000000, size: 32 MB, Compressed Oops mode: 32-bit。其中 0x00000000fe000000 就是 4064MB,与启动参数配置的一致。使用下面的启动参数 -Xmx32M -Xlog:coops*=debug -XX:HeapBaseMinAddress=4065M,可以看到日志:

1
2
3
4
5
6
7
8
python复制代码[0.005s][debug][gc,heap,coops] Heap address: 0x00000000fe200000, size: 32 MB, Compressed Oops mode: Zero based, Oop shift amount: 3 
test.TestClass object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00c01000
12 4 java.lang.String TestClass.next (object)
Instance size: 16 bytes chaoxi你妹啊,抄袭能给你赚几个钱,别为了这点镚子败人品了
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

可以看模式变为 Zero based,堆的起始点是 0x00000000fe200000 等于 4066MB,与我们的启动参数不符,是因为这个起始位置有对齐策略导致的,与使用的 GC 也是相关的,这个等我们以后分析 GC 的时候再关心。

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

接下来我们来看下一个模式 Non-zero disjoint,使用以下参数 -Xmx31G -Xlog:coops*=debug 启动程序,日志输出为:

1
2
3
4
5
6
7
8
9
less复制代码[0.007s][debug][gc,heap,coops] Protected page at the reserved heap base: 0x0000001000000000 / 16777216 bytes
[0.007s][debug][gc,heap,coops] Heap address: 0x0000001001000000, size: 31744 MB, Compressed Oops mode: Non-zero disjoint base: 0x0000001000000000, Oop shift amount: 3
test.TestClass object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00c01000
12 4 java.lang.String TestClass.next (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

可以看到,保护页大小为 16MB(16777216 bytes)chaoxi你妹啊,抄袭能给你赚几个钱,别为了这点镚子败人品了,实际 Java 堆开始的地址是 0x0000 0010 0100 0000。并且,基址也不再是 0(Non-zero disjoint base,而是与 32GB 完全不相交的地址 0x0000001000000000),可以将加法优化为或运算。后面 jol 输出对象结构,可以看出目前这个对象包含一个 markword(0x0000000000000001),一个压缩类指针(0x00c01000),以及字段 next。

我们使用 jhsdb 看一下进程的具体虚拟内存的内容验证下目前的压缩对象指针的内容,前面的步骤与上一个例子一样,我们直接看最后的:
image

如上图所示,tt 保存的对象,从 0x000000102045ab90 开始,我们找到 next 字段,它保存的值是 0x0408 b574,将其左移三位,就是 0x0000 0000 2045 aba0(inspector 中显示的是帮你解压缩之后的对象地址,Memory Viewer 中是虚拟内存实际保存的值),然后对基址 ``0x0000 0010 0000 0000取或运算,得到 next 指向的字符串对象的实际地址0x0000 0010 2045 aba0`,计算结果与 inspector 中显示的 next 解析结果一致。

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

最后,我们来看最后一种模式,即 Non-zero based,使用以下参数 -Xmx31G -Xlog:coops*=debug -XX:HeapBaseMinAddress=2G 启动程序,日志输出为:

1
2
3
4
5
6
7
8
9
less复制代码[0.005s][debug][gc,heap,coops] Protected page at the reserved heap base: 0x0000000080000000 / 16777216 bytes
[0.005s][debug][gc,heap,coops] Heap address: 0x0000000081000000, size: 31744 MB, Compressed Oops mode: Non-zero based: 0x0000000080000000, Oop shift amount: 3
test.TestClass object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00c01000
12 4 java.lang.String TestClass.next (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

可以看到,保护页大小为 16MB(16777216 bytes),实际 Java 堆开始的地址是 0x0000 0000 8100 0000。并且,基址也不再是 0(Non-zero based:0x0000000080000000)。后面 jol 输出对象结构,可以看出目前这个对象包含一个 markword(0x0000000000000001),一个压缩类指针(0x00c01000),以及字段 next。

我们使用 jhsdb 看一下进程的具体虚拟内存的内容验证下目前的压缩对象指针的内容,前面的步骤与上一个例子一样,我们直接看最后的:
image

如上图所示,tt 保存的对象,从 0x00000000a0431f10 开始,我们找到 next 字段,它保存的值是 0x0408 63e4,将其左移三位,就是 0x0000 0000 2043 1f20(inspector 中显示的是帮你解压缩之后的对象地址,Memory Viewer 中是虚拟内存实际保存的值),然后加上基址 ``0x0000 0000 8000 0000(其实就是 2GB,是我们在-XX:HeapBaseMinAddress=2G指定的 ),得到 next 指向的字符串对象的实际地址0x0000 0000 a043 1f20`,计算结果与 inspector 中显示的 next 解析结果一致。不要偷取他人的劳动成果

微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer
我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

  • 知乎:www.zhihu.com/people/zhxh…
  • B 站:space.bilibili.com/31359187

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

全网最硬核 JVM 内存解析 - 4Java 堆内存大小的

发表于 2023-04-25

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~
另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。
今天又是干货满满的一天,这是全网最硬核 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 下内存管理模型简述
    2. JVM commit 的内存与实际占用内存的差异
      1. JVM commit 的内存与实际占用内存的差异
    3. 大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
      1. Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
      2. Linux 大页分配方式 - Transparent Huge Pages (THP)
      3. JVM 大页分配相关参数与机制(涉及 JVM 参数:UseLargePages,UseHugeTLBFS,UseSHM,UseTransparentHugePages,LargePageSizeInBytes)
  3. Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
    1. 通用初始化与扩展流程
    2. 直接指定三个指标的方式(涉及 JVM 参数:MaxHeapSize,MinHeapSize,InitialHeapSize,Xmx,Xms)
    3. 不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
    4. 压缩对象指针相关机制(涉及 JVM 参数:UseCompressedOops)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)
      1. 压缩对象指针存在的意义(涉及 JVM 参数:ObjectAlignmentInBytes)
      2. 压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:UseCompressedOops,UseCompressedClassPointers)
      3. 压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:ObjectAlignmentInBytes,HeapBaseMinAddress)
    5. 为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:HeapBaseMinAddress)
    6. 结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:HeapBaseMinAddress,ObjectAlignmentInBytes,MinHeapSize,MaxHeapSize,InitialHeapSize)
    7. 使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
      1. 验证 32-bit 压缩指针模式
      2. 验证 Zero based 压缩指针模式
      3. 验证 Non-zero disjoint 压缩指针模式
      4. 验证 Non-zero based 压缩指针模式
    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. 什么是元数据,为什么需要元数据
    2. 什么时候用到元空间,元空间保存什么
      1. 什么时候用到元空间,以及释放时机
      2. 元空间保存什么
    3. 元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
      1. 元空间的整体配置以及相关参数(涉及 JVM 参数:MetaspaceSize,MaxMetaspaceSize,MinMetaspaceExpansion,MaxMetaspaceExpansion,MaxMetaspaceFreeRatio,MinMetaspaceFreeRatio,UseCompressedClassPointers,CompressedClassSpaceSize,CompressedClassSpaceBaseAddress,MetaspaceReclaimPolicy)
      2. 元空间上下文 MetaspaceContext
      3. 虚拟内存空间节点列表 VirtualSpaceList
      4. 虚拟内存空间节点 VirtualSpaceNode 与 CompressedClassSpaceSize
      5. MetaChunk
        1. ChunkHeaderPool 池化 MetaChunk 对象
        2. ChunkManager 管理空闲的 MetaChunk
      6. 类加载的入口 SystemDictionary 与保留所有 ClassLoaderData 的 ClassLoaderDataGraph
      7. 每个类加载器私有的 ClassLoaderData 以及 ClassLoaderMetaspace
      8. 管理正在使用的 MetaChunk 的 MetaspaceArena
      9. 元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
        1. 类加载器到 MetaSpaceArena 的流程
        2. 从 MetaChunkArena 普通分配 - 整体流程
        3. 从 MetaChunkArena 普通分配 - FreeBlocks 回收老的 current chunk 与用于后续分配的流程
        4. 从 MetaChunkArena 普通分配 - 尝试从 FreeBlocks 分配
        5. 从 MetaChunkArena 普通分配 - 尝试扩容 current chunk
        6. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk
        7. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 从 VirtualSpaceList 申请新的 RootMetaChunk
        8. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 将 RootMetaChunk 切割成为需要的 MetaChunk
        9. MetaChunk 回收 - 不同情况下, MetaChunk 如何放入 FreeChunkListVector
      10. ClassLoaderData 回收
    4. 元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
      1. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
      2. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
      3. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
      4. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
      5. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
      6. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
      7. 然后类加载器 1 被 GC 回收掉
      8. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
    5. 元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
      1. CommitLimiter 的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC
      2. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC
    6. jcmd VM.metaspace 元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)
      1. jcmd <pid> VM.metaspace 元空间说明
      2. 元空间相关 JVM 日志
      3. 元空间 JFR 事件详解
        1. jdk.MetaspaceSummary 元空间定时统计事件
        2. jdk.MetaspaceAllocationFailure 元空间分配失败事件
        3. jdk.MetaspaceOOM 元空间 OOM 事件
        4. jdk.MetaspaceGCThreshold 元空间 GC 阈值变化事件
        5. jdk.MetaspaceChunkFreeListSummary 元空间 Chunk FreeList 统计事件
  5. JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
    1. JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:ThreadStackSize,VMThreadStackSize,CompilerThreadStackSize,StackYellowPages,StackRedPages,StackShadowPages,StackReservedPages,RestrictReservedStack)
    2. Java 线程栈内存的结构
    3. Java 线程如何抛出的 StackOverflowError
      1. 解释执行与编译执行时候的判断(x86为例)
      2. 一个 Java 线程 Xss 最小能指定多大
  1. Java 堆内存相关设计

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

目前最新的 JVM,主要根据三个指标初始化堆以及扩展或缩小堆:

  • 最大堆大小
  • 最小堆大小
  • 初始堆大小

不同的 GC 情况下,初始化以及扩展的流程可能在某些细节不太一样,但是,大体的思路都是:

  1. 初始化阶段,reserve 最大堆大小,并且 commit 初始堆大小
  2. 在某些 GC 的某些阶段,根据上次 GC 的数据,动态扩展或者缩小堆大小,扩展就是 commit 更多,缩小就是 uncommit 一部分内存。但是,堆大小不会小于最小堆大小,也不会大于最大堆大小

3.2. 直接指定三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)的方式

这三个指标,直接对应的 JVM 参数是:

  • 最大堆大小:MaxHeapSize,如果没有指定的话会有默认预设值用于指导 JVM 计算这些指标的大小,下一章节会详细分析,预设值为 125MB 左右(96M*13/10)
  • 最小堆大小:MinHeapSize,默认为 0,0 代表让 JVM 自己计算,下一章节会详细分析
  • 初始堆大小:InitialHeapSize,默认为 0,0 代表让 JVM 自己计算,下一章节会详细分析

对应源码是:https://github.com/openjdk/jdk/blob/jdk-21+3/src/hotspot/share/gc/shared/gc_globals.hpp:

1
2
3
4
5
6
7
8
9
10
11
scss复制代码#define ScaleForWordSize(x) align_down((x) * 13 / 10, HeapWordSize)

product(size_t, MaxHeapSize, ScaleForWordSize(96*M), \
"Maximum heap size (in bytes)") \
constraint(MaxHeapSizeConstraintFunc,AfterErgo) \
product(size_t, MinHeapSize, 0, \
"Minimum heap size (in bytes); zero means use ergonomics") \
constraint(MinHeapSizeConstraintFunc,AfterErgo) \
product(size_t, InitialHeapSize, 0, \
"Initial heap size (in bytes); zero means use ergonomics") \
constraint(InitialHeapSizeConstraintFunc,AfterErgo) \

我们可以通过类似于 -XX:MaxHeapSize=1G 这种启动参数对这三个指标进行设置,但是,我们经常看到的可能是 Xmx 以及 Xms 这两个参数设置这三个指标,这两个参数分别对应:

  • Xmx:对应 最大堆大小 等价于 MaxHeapSize
  • Xms:相当于同时设置最小堆大小 MinHeapSize 和初始堆大小 InitialHeapSize

对应的 JVM 源码是:https://github.com/openjdk/jdk/blob/jdk-21+3/src/hotspot/share/runtime/arguments.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
arduino复制代码//如果设置了 Xms
else if (match_option(option, "-Xms", &tail)) {
julong size = 0;
//解析 Xms 大小
ArgsRange errcode = parse_memory_size(tail, &size, 0);
if (errcode != arg_in_range) {
jio_fprintf(defaultStream::error_stream(),
"Invalid initial heap size: %s\n", option->optionString);
describe_range_error(errcode);
return JNI_EINVAL;
}
//将解析的值设置到 MinHeapSize
if (FLAG_SET_CMDLINE(MinHeapSize, (size_t)size) != JVMFlag::SUCCESS) {
return JNI_EINVAL;
}
//将解析的值设置到 InitialHeapSize
if (FLAG_SET_CMDLINE(InitialHeapSize, (size_t)size) != JVMFlag::SUCCESS) {
return JNI_EINVAL;
}
//如果设置了 Xmx
} else if (match_option(option, "-Xmx", &tail) || match_option(option, "-XX:MaxHeapSize=", &tail)) {
julong long_max_heap_size = 0;
//解析 Xmx 大小
ArgsRange errcode = parse_memory_size(tail, &long_max_heap_size, 1);
if (errcode != arg_in_range) {
jio_fprintf(defaultStream::error_stream(),
"Invalid maximum heap size: %s\n", option->optionString);
describe_range_error(errcode);
return JNI_EINVAL;
}
//将解析的值设置到 MaxHeapSize
if (FLAG_SET_CMDLINE(MaxHeapSize, (size_t)long_max_heap_size) != JVMFlag::SUCCESS) {
return JNI_EINVAL;
}
}

最后提一句,JVM 启动参数,同一个参数可以多次出现,但是只有最后一个会生效,例如:

1
ruby复制代码java -XX:MaxHeapSize=8G -XX:MaxHeapSize=4G -XX:MaxHeapSize=8M -version

这个命令启动的 JVM MaxHeapSize 为 8MB。由于前面提到 Xmx 与 MaxHeapSize 是等价的,所以这么写也是可以的(虽然最后 MaxHeapSize 还是 8MB):

1
ini复制代码java -Xmx=8G -XX:MaxHeapSize=4G -XX:MaxHeapSize=8M -version

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

上一章节我们提到我们可以手动指定这三个参数,如果不指定呢?JVM 会怎么计算这三个指标的大小?首先,当然,JVM 会读取 JVM 可用内存:首先 JVM 需要知道自己可用多少内存,我们称为可用内存。由此引入第一个 JVM 参数,MaxRAM,这个参数是用来明确指定 JVM 进程可用内存大小的,如果没有指定,JVM 会自己读取系统可用内存。这个可用内存用来指导 JVM 限制最大堆内存。后面我们会看到很多 JVM 参数与这个可用内存相关。

前面我们还提到了,就算没有指定 MaxHeapSize 或者 Xmx,MaxHeapSize 也有自己预设的一个参考值。源码中这个预设参考值为 125MB 左右(96M*13/10)。但是一般最后不会以这个参考值为准,JVM 初始化的时候会有很复杂的计算计算出合适的值。比如你可以在你的电脑上执行下下面的命令,可以看到类似下面的输出:

1
2
3
4
5
6
arduino复制代码>  java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version|grep MaxHeapSize
size_t MaxHeapSize = 1572864000 {product} {ergonomic}
size_t SoftMaxHeapSize = 1572864000 {manageable} {ergonomic}
openjdk version "17.0.2" 2022-01-18 LTS
OpenJDK Runtime Environment Corretto-17.0.2.8.1 (build 17.0.2+8-LTS)
OpenJDK 64-Bit Server VM Corretto-17.0.2.8.1 (build 17.0.2+8-LTS, mixed mode, sharing)

可以看到 MaxHeapSize 的大小,以及它的值是通过 ergonomic 决定的。也就是非人工指定而是 JVM 自己算出来的。

上面提到的那个 125MB 左右的初始参考值,一般用于 JVM 计算。我们接下来就会分析这个计算流程,首先是最大堆内存 MaxHeapSize 的计算流程:

image

流程中涉及了以下几个参数,还有一些已经过期的参数,会被转换成未过期的参数:

  • MinRAMPercentage:注意不要被名字迷惑,这个参数是在可用内存比较小的时候生效,即最大堆内存占用为可用内存的这个参数指定的百分比,默认为 50,即 50%
  • MaxRAMPercentage:注意不要被名字迷惑,这个参数是在可用内存比较大的时候生效,即最大堆内存占用为可用内存的这个参数指定的百分比,默认为 25,即 25%
  • ErgoHeapSizeLimit:通过自动计算,计算出的最大堆内存大小不超过这个参数指定的大小,默认为 0 即不限制
  • MinRAMFraction: 已过期,如果配置了会转化为 MinRAMPercentage 换算关系是:MinRAMPercentage = 100.0 / MinRAMFraction,默认是 2
  • MaxRAMFraction: 已过期,如果配置了会转化为 MaxRAMPercentage 换算关系是:MaxRAMPercentage = 100.0 / MaxRAMFraction,默认是 4

对应的源码是:https://github.com/openjdk/jdk/blob/jdk-21+3/src/hotspot/share/gc/shared/gc_globals.hpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
erlang复制代码product(double, MinRAMPercentage, 50.0,                             \
"Minimum percentage of real memory used for maximum heap" \
"size on systems with small physical memory size") \
range(0.0, 100.0) \
product(double, MaxRAMPercentage, 25.0, \
"Maximum percentage of real memory used for maximum heap size") \
range(0.0, 100.0) \
product(size_t, ErgoHeapSizeLimit, 0, \
"Maximum ergonomically set heap size (in bytes); zero means use " \
"MaxRAM * MaxRAMPercentage / 100") \
range(0, max_uintx) \
product(uintx, MinRAMFraction, 2, \
"Minimum fraction (1/n) of real memory used for maximum heap " \
"size on systems with small physical memory size. " \
"Deprecated, use MinRAMPercentage instead") \
range(1, max_uintx) \
product(uintx, MaxRAMFraction, 4, \
"Maximum fraction (1/n) of real memory used for maximum heap " \
"size. " \
"Deprecated, use MaxRAMPercentage instead") \
range(1, max_uintx) \

然后如果我们也没有设置 MinHeapSize 以及 InitialHeapSize,也会经过下面的计算过程计算出来:

image

流程中涉及了以下几个参数,还有一些已经过期的参数,会被转换成未过期的参数:

  • NewSize:初始新生代大小,预设值为 1.3MB 左右(1*13/10)
  • OldSize:老年代大小,预设值为 5.2 MB 左右(4*13/10)
  • InitialRAMPercentage:初始堆内存为可用内存的这个参数指定的百分比,默认为 1.5625,即 1.5625%
  • InitialRAMFraction: 已过期,如果配置了会转化为 InitialRAMPercentage 换算关系是:InitialRAMPercentage = 100.0 / InitialRAMFraction

对应的源码是:https://github.com/openjdk/jdk/blob/jdk-21+3/src/hotspot/share/gc/shared/gc_globals.hpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
scss复制代码product(size_t, NewSize, ScaleForWordSize(1*M),                     \
"Initial new generation size (in bytes)") \
constraint(NewSizeConstraintFunc,AfterErgo) \
product(size_t, OldSize, ScaleForWordSize(4*M), \
"Initial tenured generation size (in bytes)") \
range(0, max_uintx) \
product(double, InitialRAMPercentage, 1.5625, \
"Percentage of real memory used for initial heap size") \
range(0.0, 100.0) \
product(uintx, InitialRAMFraction, 64, \
"Fraction (1/n) of real memory used for initial heap size. " \
"Deprecated, use InitialRAMPercentage instead") \
range(1, max_uintx) \

微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer
我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

  • 知乎:www.zhihu.com/people/zhxh…
  • B 站:space.bilibili.com/31359187

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

全网最硬核 JVM 内存解析 - 3大页分配 UseLar

发表于 2023-04-25

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~
另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。
今天又是干货满满的一天,这是全网最硬核 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 下内存管理模型简述
    2. JVM commit 的内存与实际占用内存的差异
      1. JVM commit 的内存与实际占用内存的差异
    3. 大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
      1. Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
      2. Linux 大页分配方式 - Transparent Huge Pages (THP)
      3. JVM 大页分配相关参数与机制(涉及 JVM 参数:UseLargePages,UseHugeTLBFS,UseSHM,UseTransparentHugePages,LargePageSizeInBytes)
  3. Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
    1. 通用初始化与扩展流程
    2. 直接指定三个指标的方式(涉及 JVM 参数:MaxHeapSize,MinHeapSize,InitialHeapSize,Xmx,Xms)
    3. 不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
    4. 压缩对象指针相关机制(涉及 JVM 参数:UseCompressedOops)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)
      1. 压缩对象指针存在的意义(涉及 JVM 参数:ObjectAlignmentInBytes)
      2. 压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:UseCompressedOops,UseCompressedClassPointers)
      3. 压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:ObjectAlignmentInBytes,HeapBaseMinAddress)
    5. 为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:HeapBaseMinAddress)
    6. 结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:HeapBaseMinAddress,ObjectAlignmentInBytes,MinHeapSize,MaxHeapSize,InitialHeapSize)
    7. 使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
      1. 验证 32-bit 压缩指针模式
      2. 验证 Zero based 压缩指针模式
      3. 验证 Non-zero disjoint 压缩指针模式
      4. 验证 Non-zero based 压缩指针模式
    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. 什么是元数据,为什么需要元数据
    2. 什么时候用到元空间,元空间保存什么
    3. 2.1. 什么时候用到元空间,以及释放时机
    4. 2.2. 元空间保存什么
    5. 元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
      1. 元空间的整体配置以及相关参数(涉及 JVM 参数:MetaspaceSize,MaxMetaspaceSize,MinMetaspaceExpansion,MaxMetaspaceExpansion,MaxMetaspaceFreeRatio,MinMetaspaceFreeRatio,UseCompressedClassPointers,CompressedClassSpaceSize,CompressedClassSpaceBaseAddress,MetaspaceReclaimPolicy)
      2. 元空间上下文 MetaspaceContext
      3. 虚拟内存空间节点列表 VirtualSpaceList
      4. 虚拟内存空间节点 VirtualSpaceNode 与 CompressedClassSpaceSize
      5. MetaChunk
        1. ChunkHeaderPool 池化 MetaChunk 对象
        2. ChunkManager 管理空闲的 MetaChunk
      6. 类加载的入口 SystemDictionary 与保留所有 ClassLoaderData 的 ClassLoaderDataGraph
      7. 每个类加载器私有的 ClassLoaderData 以及 ClassLoaderMetaspace
      8. 管理正在使用的 MetaChunk 的 MetaspaceArena
      9. 元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
        1. 类加载器到 MetaSpaceArena 的流程
        2. 从 MetaChunkArena 普通分配 - 整体流程
        3. 从 MetaChunkArena 普通分配 - FreeBlocks 回收老的 current chunk 与用于后续分配的流程
        4. 从 MetaChunkArena 普通分配 - 尝试从 FreeBlocks 分配
        5. 从 MetaChunkArena 普通分配 - 尝试扩容 current chunk
        6. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk
        7. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 从 VirtualSpaceList 申请新的 RootMetaChunk
        8. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 将 RootMetaChunk 切割成为需要的 MetaChunk
        9. MetaChunk 回收 - 不同情况下, MetaChunk 如何放入 FreeChunkListVector
      10. ClassLoaderData 回收
    6. 元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
      1. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
      2. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
      3. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
      4. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
      5. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
      6. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
      7. 然后类加载器 1 被 GC 回收掉
      8. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
    7. 元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
      1. CommitLimiter 的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC
      2. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC
    8. jcmd VM.metaspace 元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)
      1. jcmd <pid> VM.metaspace 元空间说明
      2. 元空间相关 JVM 日志
      3. 元空间 JFR 事件详解
        1. jdk.MetaspaceSummary 元空间定时统计事件
        2. jdk.MetaspaceAllocationFailure 元空间分配失败事件
        3. jdk.MetaspaceOOM 元空间 OOM 事件
        4. jdk.MetaspaceGCThreshold 元空间 GC 阈值变化事件
        5. jdk.MetaspaceChunkFreeListSummary 元空间 Chunk FreeList 统计事件
  5. JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
    1. JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:ThreadStackSize,VMThreadStackSize,CompilerThreadStackSize,StackYellowPages,StackRedPages,StackShadowPages,StackReservedPages,RestrictReservedStack)
    2. Java 线程栈内存的结构
    3. Java 线程如何抛出的 StackOverflowError
      1. 解释执行与编译执行时候的判断(x86为例)
      2. 一个 Java 线程 Xss 最小能指定多大
  1. JVM 内存申请与使用流程

2.3. 大页分配 UseLargePages

前面提到了虚拟内存需要映射物理内存才能使用,这个映射关系被保存在内存中的页表(Page Table)。现代 CPU 架构中一般有 TLB (Translation Lookaside Buffer,翻译后备缓冲,也称为页表寄存器缓冲)存在,在里面保存了经常使用的页表映射项。TLB 的大小有限,一般 TLB 如果只能容纳小于 100 个页表映射项。 我们能让程序的虚拟内存对应的页表映射项都处于 TLB 中,那么能大大提升程序性能,这就要尽量减少页表映射项的个数:页表项个数 = 程序所需内存大小 / 页大小。我们要么缩小程序所需内存,要么增大页大小。我们一般会考虑增加页大小,这就大页分配的由来,JVM 对于堆内存分配也支持大页分配,用于优化大堆内存的分配。那么 Linux 环境中有哪些大页分配的方式呢?

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

相关的 Linux 内核文档:https://www.kernel.org/doc/Documentation/vm/hugetlbpage.txt

这是出现的比较早的大页分配方式,其实就是在之前提到的页表映射上面做文章:

默认 4K 页大小:

image

PMD 直接映射实际物理页面,页面大小为 4K * 2^9 = 2M:
image
PUD 直接映射实际物理页面,页面大小为 2M * 2^9 = 1G:
image

但是,要想使用这个特性,需要操作系统构建的时候开启 CONFIG_HUGETLBFS 以及 CONFIG_HUGETLB_PAGE。之后,大的页面通常是通过系统管理控制预先分配并放入池里面的。然后,可以通过 mmap 系统调用或者 shmget,shmat 这些 SysV 的共享内存系统调用使用大页分配方式从池中申请内存。

这种大页分配的方式,需要系统预设开启大页,预分配大页之外,对于代码也是有一定侵入性的,在灵活性上面查一些。但是带来的好处就是,性能表现上更加可控。另一种灵活性很强的 Transparent Huge Pages (THP) 方式,总是可能在性能表现上有一些意想不到的情况。

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

相关的 Linux 内核文档:https://www.kernel.org/doc/Documentation/vm/transhuge.txt

THP 是一种使用大页的第二种方法,它支持页面大小的自动升级和降级,这样对于用户使用代码基本没有侵入性,非常灵活。但是,前面也提到过,这种系统自己去做页面大小的升级降级,并且系统一般考虑通用性,所以在某些情况下会出现意想不到的性能瓶颈。

2.3.3. JVM 大页分配相关参数与机制

相关的参数如下:

  • UseLargePages:明确指定是否开启大页分配,如果关闭,那么下面的参数就都不生效。在 linux 下默认为 false。
  • UseHugeTLBFS:明确指定是否使用前面第一种大页分配方式 hugetlbfs 并且通过 mmap 系统调用分配内存。在 linux 下默认为 false。
  • UseSHM:明确指定是否使用前面第一种大页分配方式 hugetlbfs 并且通过 shmget,shmat 系统调用分配内存。在 linux 下默认为 false。
  • UseTransparentHugePages:明确指定是否使用前面第二种大页分配方式 THP。在 linux 下默认为 false。
  • LargePageSizeInBytes:指定明确的大页的大小,仅适用于前面第一种大页分配方式 hugetlbfs,并且必须属于操作系统支持的页大小否则不生效。默认为 0,即不指定

首先,需要对以上参数做一个简单的判断:如果没有指定 UseLargePages,那么使用对应系统的默认 UseLargePages 的值,在 linux 下是 false,那么就不会启用大页分配。如果启动参数明确指定 UseLargePages 不启用,那么也不会启用大页分配。如果读取 /proc/meminfo 获取默认大页大小读取不到或者为 0,则代表系统也不支大页分配,大页分配也不启用。

那么如果大页分配启用的话,我们需要初始化并验证大页分配参数可行性,流程是:

image

首先,JVM 会读取根据当前所处的平台与系统环境读取支持的页的大小,当然,这个是针对前面第一种大页分配方式 hugetlbfs 的。在 Linux 环境下,JVM 会从 /proc/meminfo 读取默认的 Hugepagesize,从 /sys/kernel/mm/hugepages 目录下检索所有支持的大页大小,这块可以参考源码:https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/os/linux/os_linux.cpp。有关这些文件或者目录的详细信息,请参考前面章节提到的 Linux 内核文档:https://www.kernel.org/doc/Documentation/vm/hugetlbpage.txt

如果操作系统开启了 hugetlbfs,/sys/kernel/mm/hugepages 目录下的结构类似于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bash复制代码> tree /sys/kernel/mm/hugepages

/sys/kernel/mm/hugepages
├── hugepages-1048576kB
│ ├── free_hugepages
│ ├── nr_hugepages
│ ├── nr_hugepages_mempolicy
│ ├── nr_overcommit_hugepages
│ ├── resv_hugepages
│ └── surplus_hugepages
└── hugepages-2048kB
├── free_hugepages
├── nr_hugepages
├── nr_hugepages_mempolicy
├── nr_overcommit_hugepages
├── resv_hugepages
└── surplus_hugepages

这个 hugepages-1048576kB 就代表支持大小为 1GB 的页,hugepages-2048kB 就代表支持大小为 2KB 的页。

如果没有设置 UseHugeTLBFS,也没有设置 UseSHM,也没有设置 UseTransparentHugePages,那么其实就是走默认的,默认使用 hugetlbfs 方式,不使用 THP 方式,因为如前所述, THP 在某些场景下有意想不到的性能瓶颈表现,在大型应用中,稳定性优先于峰值性能。之后,默认优先尝试 UseHugeTLBFS(即使用 mmap 系统调用通过 hugetlbfs 方式大页分配),不行的话再尝试 UseSHM(即使用 shmget 系统调用通过 hugetlbfs 方式大页分配)。这里只是验证下这些大页内存的分配方式是否可用,只有可用后面真正分配内存的时候才会采用那种可用的大页内存分配方式。

微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer
我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

  • 知乎:www.zhihu.com/people/zhxh…
  • B 站:space.bilibili.com/31359187

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

全网最硬核 JVM 内存解析 - 2JVM 内存申请与使用

发表于 2023-04-25

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~
另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。
今天又是干货满满的一天,这是全网最硬核 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 下内存管理模型简述
    2. JVM commit 的内存与实际占用内存的差异
      1. JVM commit 的内存与实际占用内存的差异
    3. 大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
      1. Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
      2. Linux 大页分配方式 - Transparent Huge Pages (THP)
      3. JVM 大页分配相关参数与机制(涉及 JVM 参数:UseLargePages,UseHugeTLBFS,UseSHM,UseTransparentHugePages,LargePageSizeInBytes)
  3. Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
    1. 通用初始化与扩展流程
    2. 直接指定三个指标的方式(涉及 JVM 参数:MaxHeapSize,MinHeapSize,InitialHeapSize,Xmx,Xms)
    3. 不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
    4. 压缩对象指针相关机制(涉及 JVM 参数:UseCompressedOops)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)
      1. 压缩对象指针存在的意义(涉及 JVM 参数:ObjectAlignmentInBytes)
      2. 压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:UseCompressedOops,UseCompressedClassPointers)
      3. 压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:ObjectAlignmentInBytes,HeapBaseMinAddress)
    5. 为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:HeapBaseMinAddress)
    6. 结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:HeapBaseMinAddress,ObjectAlignmentInBytes,MinHeapSize,MaxHeapSize,InitialHeapSize)
    7. 使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
      1. 验证 32-bit 压缩指针模式
      2. 验证 Zero based 压缩指针模式
      3. 验证 Non-zero disjoint 压缩指针模式
      4. 验证 Non-zero based 压缩指针模式
    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. 什么是元数据,为什么需要元数据
    2. 什么时候用到元空间,元空间保存什么
    3. 2.1. 什么时候用到元空间,以及释放时机
    4. 2.2. 元空间保存什么
    5. 元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
      1. 元空间的整体配置以及相关参数(涉及 JVM 参数:MetaspaceSize,MaxMetaspaceSize,MinMetaspaceExpansion,MaxMetaspaceExpansion,MaxMetaspaceFreeRatio,MinMetaspaceFreeRatio,UseCompressedClassPointers,CompressedClassSpaceSize,CompressedClassSpaceBaseAddress,MetaspaceReclaimPolicy)
      2. 元空间上下文 MetaspaceContext
      3. 虚拟内存空间节点列表 VirtualSpaceList
      4. 虚拟内存空间节点 VirtualSpaceNode 与 CompressedClassSpaceSize
      5. MetaChunk
        1. ChunkHeaderPool 池化 MetaChunk 对象
        2. ChunkManager 管理空闲的 MetaChunk
      6. 类加载的入口 SystemDictionary 与保留所有 ClassLoaderData 的 ClassLoaderDataGraph
      7. 每个类加载器私有的 ClassLoaderData 以及 ClassLoaderMetaspace
      8. 管理正在使用的 MetaChunk 的 MetaspaceArena
      9. 元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
        1. 类加载器到 MetaSpaceArena 的流程
        2. 从 MetaChunkArena 普通分配 - 整体流程
        3. 从 MetaChunkArena 普通分配 - FreeBlocks 回收老的 current chunk 与用于后续分配的流程
        4. 从 MetaChunkArena 普通分配 - 尝试从 FreeBlocks 分配
        5. 从 MetaChunkArena 普通分配 - 尝试扩容 current chunk
        6. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk
        7. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 从 VirtualSpaceList 申请新的 RootMetaChunk
        8. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 将 RootMetaChunk 切割成为需要的 MetaChunk
        9. MetaChunk 回收 - 不同情况下, MetaChunk 如何放入 FreeChunkListVector
      10. ClassLoaderData 回收
    6. 元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
      1. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
      2. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
      3. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
      4. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
      5. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
      6. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
      7. 然后类加载器 1 被 GC 回收掉
      8. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
    7. 元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
      1. CommitLimiter 的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC
      2. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC
    8. jcmd VM.metaspace 元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)
      1. jcmd <pid> VM.metaspace 元空间说明
      2. 元空间相关 JVM 日志
      3. 元空间 JFR 事件详解
        1. jdk.MetaspaceSummary 元空间定时统计事件
        2. jdk.MetaspaceAllocationFailure 元空间分配失败事件
        3. jdk.MetaspaceOOM 元空间 OOM 事件
        4. jdk.MetaspaceGCThreshold 元空间 GC 阈值变化事件
        5. jdk.MetaspaceChunkFreeListSummary 元空间 Chunk FreeList 统计事件
  5. JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
    1. JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:ThreadStackSize,VMThreadStackSize,CompilerThreadStackSize,StackYellowPages,StackRedPages,StackShadowPages,StackReservedPages,RestrictReservedStack)
    2. Java 线程栈内存的结构
    3. Java 线程如何抛出的 StackOverflowError
      1. 解释执行与编译执行时候的判断(x86为例)
      2. 一个 Java 线程 Xss 最小能指定多大
  1. JVM 内存申请与使用流程

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

Linux 内存管理模型不是咱们这个系列的讨论重点,我们这里只会简单提一些对于咱们这个系列需要了解到的,如果读者想要深入理解,建议大家查看 bin 神(公众号:bin 的技术小屋)的系列文章:一步一图带你深入理解 Linux 虚拟内存管理

CPU 是通过寻址来访问内存的,目前大部分 CPU 都是 64 位的,即寻址范围是:0x0000 0000 0000 0000 ~ 0xFFFF FFFF FFFF FFFF,即可以管理 16EB 的内存。但是,实际程序并不会直接通过 CPU 寻址访问到实际的物理内存,而是通过引入 MMU(Memory Management Unit 内存管理单元)与实际物理地址隔了一层虚拟内存的抽象。这样,程序申请以及访问的其实是虚拟内存地址,MMU 会将这个虚拟内存地址映射为实际的物理内存地址。同时,为了减少内存碎片,以及增加内存分配效率,在 MMU 的基础上 Linux 抽象了内存分页(Paging)的概念,将虚拟地址按固定大小分割成页(默认是 4K,如果平台支持更多更大的页大小 JVM 也是可以利用的,我们后面分析相关的 JVM 参数会看到),并在页被实际使用写入数据的时候,映射同样大小的实际的物理内存(页帧,Page Frame),或者是在物理内存不足的时候,将某些不常用的页转移到其他存储设备比如磁盘上。

一般系统中会有多个进程使用内存,每个进程都有自己独立的虚拟内存空间,假设我们这里有三个进程,进程 A 访问的虚拟地址可以与进程 B 和进程 C 的虚拟地址相同,那么操作系统如何区分呢?即操作系统如何将这些虚拟地址转换为物理内存。这就需要页表了,页表也是每个进程独立的,操作系统会在给进程映射物理内存用来保存用户数据的时候,将物理内存保存到进程的页表里面。然后,进程访问虚拟内存空间的时候,通过页表找到物理内存:

image

页表如何将一个虚拟内存地址(我们需要注意一点,目前虚拟内存地址,用户空间与内核空间可以使用从 0x0000 0000 0000 0000 ~ 0x0000 FFFF FFFF FFFF 的地址,即 256TB),转化为物理内存的呢?下面我们举一个在 x86,64 位环境下四级页表的结构视图:

image

在这里,页表分为四个级别:PGD(Page Global Directory),PUD(Page Upper Directory),PMD(Page Middle Directory),PTE(Page Table Entry)。每个页表,里面的页表项,保存了指向下一个级别的页表的引用,除了最后一层的 PTE 里面的页表项保存的是指向用户数据内存的指针。如何将虚拟内存地址通过页表找到对应用户数据内存从而读取数据,过程是:

image

  1. 取虚拟地址的 39 ~ 47 位(因为用户空间与内核空间可以使用从 0x0000 0000 0000 0000 ~ 0x0000 FFFF FFFF FFFF 的地址,即 47 位以下的地址)作为 offset,在唯一的 PGD 页面根据 offset 定位到 PGD 页表项 pgd_t
  2. 使用 pgd_t 定位到具体的 PUD 页面
  3. 取虚拟地址的 30 ~ 38 位作为 offset,在对应的 PUD 页面根据 offset 定位到 PUD 页表项 pud_t
  4. 使用 pud_t 定位到具体的 PMD 页面
  5. 取虚拟地址的 21 ~ 29 位作为 offset,在对应的 PMD 页面根据 offset 定位到 PMD 页表项 pmd_t
  6. 使用 pmd_t 定位到具体的 PTE 页面
  7. 取虚拟地址的 12 ~ 20 位作为 offset,在对应的 PTE 页面根据 offset 定位到 PTE 页表项 pte_t
  8. 使用 pte_t 定位到具体的用户数据物理内存页面
  9. 使用最后的 0 ~ 11 位作为 offset,对应到用户数据物理内存页面的对应 offset

如果每次访问虚拟内存,都需要访问这个页表翻译成实际物理内存的话,性能太差。所以一般 CPU 里面都有一个 TLB(Translation Lookaside Buffer,翻译后备缓冲)存在,一般它属于 CPU 的 MMU 的一部分。TLB 负责缓存虚拟内存与实际物理内存的映射关系,一般 TLB 容量很小。每次访问虚拟内存,先查看 TLB 中是否有缓存,如果没有才会去页表查询。

image

默认情况下,TLB 缓存的 key 为地址的 12 ~ 47 位,value 是实际的物理内存页面。这样前面从第 1 到第 7 步就可以被替换成访问 TLB 了:

  1. 取虚拟地址的 12 ~ 47 位作为 key,访问 TLB,定位到具体的用户数据物理内存页面。
  2. 使用最后的 0 ~ 11 位作为 offset,对应到用户数据物理内存页面的对应 offset。

image

TLB 一般很小,我们来看几个 CPU 中的 TLB 大小,以下图片来自于 www.bilibili.com/video/BV1Xx…
image

我们这里不用关心 iTLB,dTLB,sTLB 分别是什么意思,只要可以看出两点即可:1. TLB 整体可以容纳个数不多;2. 页大小越大,TLB 能容纳的个数越少。但是整体看,TLB 能容纳的页大小还是增多的(比如 Nehalem 的 iTLB,页大小 4K 的时候,一共可以容纳 128 * 4 = 512K 的内存,页大小 2M 的时候,一共可以容纳 2 * 7 = 14M 的内存)。

JVM 中很多地方需要知道页大小,JVM 在初始化的时候,通过系统调用 sysconf(_SC_PAGESIZE) 读取出页大小,并保存下来以供后续使用。参考源码:https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/os/linux/os_linux.cpp:

1
2
3
4
5
6
7
8
cpp复制代码    //设置全局默认页大小,通过 Linux::page_size() 可以获取全局默认页大小
Linux::set_page_size(sysconf(_SC_PAGESIZE));
if (Linux::page_size() == -1) {
fatal("os_linux.cpp: os::init: sysconf failed (%s)",
os::strerror(errno));
}
//将默认页大小加入可选的页大小列表,在涉及大页分配的时候有用
_page_sizes.add(Linux::page_size());

2.2. JVM 主要内存申请分配流程

第一步,JVM 的每个子系统(例如 Java 堆,元空间,JIT 代码缓存,GC 等等等等),如果需要的话,在初始化的时候首先 Reserve 要分配区域的最大限制大小的内存(这个最大大小,需要按照页大小对齐(即是页大小的整数倍),默认页大小是前面提到的 Linux::page_size()),例如对于 Java 堆,就是最大堆大小(通过 -Xmx 或者 -XX:MaxHeapSize限制),还有对于代码缓存,也是最大代码缓存大小(通过 -XX:ReservedCodeCacheSize 限制)。Reserve 的目的是在虚拟内存空间划出一块内存专门给某个区域使用,这样做的好处是:

  1. 隔离每个 JVM 子系统使用的内存的虚拟空间,这样在 JVM 代码有 bug 的时候(例如发生 Segment Fault 异常),通过报错中的虚拟内存地址可以快速定位到是哪个子系统出了问题。
  2. 可以很方便的限制这个区域使用的最大内存大小。
  3. 便于管理,Reserve 不会触发操作系统分配映射实际物理内存,这个区域可以在 Reserve 的区域内按需伸缩。
  4. 便于一些 JIT 优化,例如我们故意将这个区域保留起来但是故意不将这个区域的虚拟内存映射物理内存,访问这块内存会造成 Segment Fault 异常。JVM 会预设 Segment Fault 异常的处理器,在处理器里面检查发生 Segment Fault 异常的内存地址属于哪个子系统的 Reserve 的区域,判断要做什么操作。后面我们会看到,null 检查抛出 NullPointerException 异常的优化,全局安全点,抛出 StackOverflowError 的实现,都和这个机制有关。

在 Linux 的环境下,Reserve 通过 mmap(2) 系统调用实现,参数传入 prot = PROT_NONE,PROT_NONE 代表不会使用,即不能做任何操作,包括读和写。为啥要打击抄袭,稿主被抄袭太多所以断更很久。如果 JVM 使用这块内存,会发生 Segment Fault 异常。Reserve 的源码,对应的是:

入口为:https://github.com/openjdk/jdk/blob/jdk-21+9/src/hotspot/share/runtime/os.cpp

1
2
3
4
5
6
7
8
arduino复制代码char* os::reserve_memory(size_t bytes, bool executable, MEMFLAGS flags) {
//调用每个操作系统实现不同的 pd_reserve_memory 函数进行 reserve
char* result = pd_reserve_memory(bytes, executable);
if (result != NULL) {
MemTracker::record_virtual_memory_reserve(result, bytes, CALLER_PC, flags);
}不要偷取他人的劳动成果,也不要浪费自己的时间和精力,让我们一起做一个有良知的写作者。
return result;
}

对应 linux 的实现是:https://github.com/openjdk/jdk/blob/jdk-21+9/src/hotspot/os/linux/os_linux.cpp

1
2
3
4
5
6
7
8
9
10
11
arduino复制代码char* os::pd_reserve_memory(size_t bytes, bool exec) {
return anon_mmap(nullptr, bytes);
}

static char* anon_mmap(char* requested_addr, size_t bytes) {
const int flags = MAP_PRIVATE | MAP_NORESERVE | MAP_ANONYMOUS;
//这里的关键是 PROT_NONE,代表仅仅是在虚拟空间保留,不实际映射物理内存
//fd 传入的是 -1,因为没有实际映射文件,我们这里目的是为了分配内存,不是将某个文件映射到内存中
char* addr = (char*)::mmap(requested_addr, bytes, PROT_NONE, flags, -1, 0);
return addr == MAP_FAILED ? NULL : addr;
}

第二步,JVM 的每个子系统,按照各自的策略,通过 Commit 第一步 Reserve 的区域的一部分扩展内存(大小也一般页大小对齐的),从而向操作系统申请映射物理内存,通过 Uncommit 已经 Commit 的内存来释放物理内存给操作系统。抄袭和xigao是文化的毒瘤,是对文化创造和发展的阻碍!

Commit 的源码入口为:https://github.com/openjdk/jdk/blob/jdk-21+9/src/hotspot/share/runtime/os.cpp

1
2
3
4
5
6
7
8
9
arduino复制代码bool os::commit_memory(char* addr, size_t bytes, bool executable) {
assert_nonempty_range(addr, bytes);
//调用每个操作系统实现不同的 pd_commit_memory 函数进行 commit
bool res = pd_commit_memory(addr, bytes, executable);
if (res) {
MemTracker::record_virtual_memory_commit((address)addr, bytes, CALLER_PC);
}
return res;
}

对应 linux 的实现是:https://github.com/openjdk/jdk/blob/jdk-21+9/src/hotspot/os/linux/os_linux.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
arduino复制代码bool os::pd_commit_memory(char* addr, size_t size, bool exec) {
return os::Linux::commit_memory_impl(addr, size, exec) == 0;
}
int os::Linux::commit_memory_impl(char* addr, size_t size, bool exec) {
//这里的关键是 PROT_READ|PROT_WRITE,即申请需要读写这块内存
int prot = exec ? PROT_READ|PROT_WRITE|PROT_EXEC : PROT_READ|PROT_WRITE;
uintptr_t res = (uintptr_t) ::mmap(addr, size, prot,
MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0);
if (res != (uintptr_t) MAP_FAILED) {
if (UseNUMAInterleaving) {
numa_make_global(addr, size);
}
return 0;
}

int err = errno; // save errno from mmap() call above

if (!recoverable_mmap_error(err)) {
warn_fail_commit_memory(addr, size, exec, err);
vm_exit_out_of_memory(size, OOM_MMAP_ERROR, "committing reserved memory.");
}

return err;
}

Commit 内存之后,并不是操作系统会立刻分配物理内存,而是在向 Commit 的内存里面写入数据的时候,操作系统才会实际映射内存。plagiarism和洗稿是恶意抄袭他人劳动成果的行为,是对劳动价值的漠视和践踏!JVM 有对应的参数,可以在 Commit 内存后立刻写入 0 来强制操作系统分配内存,即 AlwaysPreTouch 这个参数,这个参数我们后面会详细分析以及历史版本存在的缺陷。

我们再来看下为什么先 Reserve 之后 Commit 这样好 Debug。看这个例子,如果我们没有第一步 Reserve,直接是第二步 Commit,那么我们可能分配的内存是这个样子的:

image

假设此时,我们不小心在 JVM 中写了个 bug,导致洗稿狗没了妈,导致 MetaSpace 2 这块内存被回收了,这时候保留指向 MetaSpace 2 的内存的指针就会报 Segment Fault,但是通过 Segment Fault 里面带的地址,我们并不知道是这个地址属于哪里,除非我们有另外的内存结构保存每个子系统 Commit 内存的列表,但是这样效率太低了。如果我们先 Reserve 大块之后在里面 Commit,那么情况就不同了:
image

这样,只需要判断 Segment Fault 里面带的地址处于的范围,就能知道是哪个子系统

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

前面一节我们知道了,JVM 中大块内存,基本都是先 reserve 一大块,之后 commit 其中需要的一小块,然后开始读写处理内存,在 Linux 环境下,底层基于 mmap(2) 实现。但是需要注意一点的是,commit 之后,内存并不是立刻被分配了物理内存,而是真正往内存中 store 东西的时候,才会真正映射物理内存,如果是 load 读取也是可能不映射物理内存的。

这其实是可能你平常看到但是忽略的现象,如果你使用的是 SerialGC,ParallelGC 或者 CMS GC,老年代的内存在有对象晋升到老年代之前,可能是不会映射物理内存的,虽然这块内存已经被 commit 了。并且年轻代可能也是随着使用才会映射物理内存。如果你用的是 ZGC,G1GC,或者 ShenandoahGC,那么内存用的会更激进些(主要因为分区算法划分导致内存被写入),这是你在换 GC 之后看到物理内存内存快速上涨的原因之一。JVM 有对应的参数,可以在 Commit 内存后立刻写入 0 来强制操作系统分配内存,即 AlwaysPreTouch 这个参数,这个参数我们后面会详细分析以及历史版本存在的缺陷。还有的差异,主要来源于在 uncommit 之后,系统可能还没有来的及将这块物理内存真正回收。

所以,JVM 认为自己 commit 的内存,与实际系统分配的物理内存,可能是有差异的,可能 JVM 认为自己 commit 的内存比系统分配的物理内存多,也可能少。这就是为啥 Native Memory Tracking(JVM 认为自己 commit 的内存)与实际其他系统监控中体现的物理内存使用指标对不上的原因。

微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer
我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

  • 知乎:www.zhihu.com/people/zhxh…
  • B 站:space.bilibili.com/31359187

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

断更19个月,携 Threejs Shader 归来!(下

发表于 2023-04-21

原文:「断更19个月,携 Three.js Shader 归来!(下) - 牛衣古柳」 有很多视频例子,掘金只能放图片,所以大家可以去原文看看!

前文回顾及补漏

上篇文章「断更19个月,携 Three.js Shader 归来!(上) - 牛衣古柳」讲到古柳虽然也曾做过3D可视化的作品,但其实一直有意回避不想碰 Three.js 和图形学等3D内容;后来因为看群友分享了 TikTok 上很火的 3D+AR/VR 可视化视频,因此终于一时兴起学起 Three.js;

然后古柳在油管偶然发现了 Yuri 的宝藏频道,并从他复现 Pepyaka 效果的教程了解到 Shader 并光速入坑。

何为 Shader ?

但讲到现在可能大家还是不知道 Shader 到底是啥?

一言以蔽之,Shader/着色器就是一段给 GPU 执行的程序,我们可以通过 Vertex Shader/顶点着色器来改变几何体的顶点坐标以及通过 Fragment Shader/片元着色器来改变每个片元的颜色,片元可以粗略地理解成像素。

对应到 Three.js 就是 ShaderMaterial 里 vertexShader 和 fragmentShader 两部分的代码,其使用类C语言的 GLSL 编程语言,即 OpenGL Shading Language 进行编写。这里大家先有个印象即可,后续教程会讲解更多内容带大家入门。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
js复制代码const vertex = `
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;

const fragment = `
void main() {
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
`;

const material = new THREE.ShaderMaterial({
vertexShader: vertex,
fragmentShader: fragment,
uniforms: {
uTime: { value: 0 },
},
});

于是在将3D物体渲染到屏幕画布的过程中,我们可以通过 Shader 在中间改变物体顶点坐标与像素颜色,这样就能像使用“魔法”一样做出各种其他技术所无法实现的、超酷炫超丝滑、令人印象深刻的效果。

Chris 的 Shader 介绍视频

这里推荐大家看下“猛男♂小哥” Chris 的2分钟 Shader 介绍视频 「Three.js Shaders in 2 Minutes - Chris Courses - YouTube」,以便有更多直观了解。

最初是看 Chris 的 Canvas 教程发现讲解地超好,然后意外地发现这肌肉太帅了,于是瞎叫他“猛男♂小哥” doge,老群友应该记得。

亿点点 GPU 带来的震撼

扯回来。上面的讲解还是比较粗浅、偏科普,想更深入了解的朋友,可以搜索关键词渲染管线/Render Pipeline/Graphic Pipeline等。

正因为有 GPU 并行计算的加持,即使在一张有上百万像素的图片上添加动画“特效”,也能在确保60FPS的同时实现丝滑效果,而如此多的像素每秒还更新60次,这是一般 CPU 下 Canvas 里遍历图片所有像素进行改变所无法想象的。

NVIDIA/英伟达有个视频「Mythbusters Demo GPU versus CPU - YouTube」直观地展示 CPU 和 GPU 绘图的不同。如果 CPU 是一个像素一个像素绘制的话,那么 GPU 就是一次性绘制所有像素,电光火石间一切就完成了。看完这个视频估计大家再难忘记 GPU 所带来的亿点点震撼了。

  • 链接:视频 IT从业者基础:CPU和GPU工作有啥不同,看看这个现场DEMO - B站搬运

Photomosh 神器

而说到给图片加“特效”,就不得不提下之前古柳在某个平面设计课程里了解到的 photomosh 这个神器。

课程里讲点线面的面时,讲到随机面具有自然和个性的特点,然后演示了使用 photomosh 里的 wobble/摇晃 功能对黑底白字的文字图片进行扭曲变形,最后将具有随机面效果的文字放到海报中使用。

当时古柳第一反应就觉得这网站应该用了 Shader,但毕竟那会接触的还不多,不敢打包票,想着没准用了其他不知道的技术实现的。于是跑去体验一番,并看了下网页源码……发现果然就是 Shader!

对于这个神器,设计师朋友可以将其纳入工具箱、应用到生产实践里;程序员朋友则可以好好研究下这些 Shader 效果分别怎么实现的,毕竟这么多效果整齐地罗列着,不学习一番岂不是暴殄天物。后续古柳应该也会从中选择一些效果拆解给大家讲讲,敬请期待!

Photoshop 等软件同理

讲到这,有用过 Photoshop、Adobe Illustrator 的朋友,是不是觉得上面一些扭曲变形等操作很眼熟,这些软件也可以做出类似效果,因为它们也是支持 GPU 模式的,可以确定多半也用到 Shader。

再比如前些天,古柳看到的这个用 PS 做长虹玻璃质感海报的教程,里面这些黑白渐变、渐变重复、纹理、扭曲度/平滑度/缩放等概念,学过 Shader 后就很熟悉。后续复现试试看,再写成教程教大家。

越来越多获奖的、酷炫网站里用到 Shader

上面讲了这么多,但古柳最爱的还是很多国外 Studio/工作室用 Three.js Shader 等技术做的令人印象深刻的网站,而且这些网站许多也在 Awwwards 上获奖。

比如 Yuri 在他的多个教程里提到的带他入坑 Shader 的网站 Tao Tajima | Filmmaker——这是日本公司 ホムンクルス / homunculus Inc. 制作的于2017年12月上线的网站——其中的图片和视频上有波浪、翻页过渡等交互效果,非常的与众不同。

  • 链接:www.awwwards.com/sites/tao-t…

不论是对此前没接触过这类网站的朋友,还是对看过不少用到 Shader 的网站的 Yuri 还是古柳自己,这样的网站都是令人觉得惊艳、印象深刻。

同样的 homunculus 的官网也用到许多 Shader 效果,大家也可以去体验一番。

类似的网站还有很多,古柳也难以一一介绍,但还是精选了一些供大家前去感受下开开眼。如果有人因此迷上 Shader,在心里埋下种子,并且日后做出比肩这些作品的网站,那古柳也算是做回“引路人”了。

  • 链接:mathis-biabiany.fr/
  • 链接:aristidebenoist.com/
  • 链接:robin-noguier.com/
  • 链接:www.pola.co.jp/wecaremore/…
  • 链接:designembraced.com/
  • 链接:www.heleneblanck.fr/
  • 链接:tismes.com/
  • 链接:www.utsubo.co/

不过,截止目前在古柳的印象里还没怎么见到国內有类似网站用到 Shader,相较之下上述就有好几个网站是日本的,甚至 taotajima 是2017年底就有的,如此看来虽然已经2023年,但国内在这方面仍略显“荒漠”。

记得之前有人从小红书过来提到想做类似网站,但问了国内一些工作室都说难度太大……希望将来能看到更多国内的类似网站。

可视化里也有用到

毕竟古柳之前输出的都是 D3.js 数据可视化方面内容,很多人也是因为可视化而关注我的。那么可视化里能用到 Shader 吗?

虽然本身3D的数据可视化相较2D的就少些,用到 Shader 的3D可视化就更少,但巧的是古柳还真知道几个,这里介绍给大家。

Shirley Wu 的 Legends 作品

首先是古柳安利过很多次的 Nadieh Bremer 和 Shirley Wu 的 「Data Sketches」 里的 Legends 这个作品就有用到 Shader。

  • 源码链接:sxywu/legends - GitHub

这是 Shirley Wu 为纪念1901年以来51位获得诺贝尔奖的传奇女性而以水晶为视觉元素所作的可视化作品,其中的水晶就是基于一般的球体几何体改变顶点坐标并且通过 fragment shader 应用上不同渐变颜色以区分自然科学与人文社会科学而得到的。

还记得当时古柳还没入坑 Three.js Shader,觉得可能一辈子都看不懂也做不出类似的作品,没想到没多久就去入坑学了起来,虽然至今有些地方不明白,但却更靠近了一步,也是蛮感慨的!

值得一提的是 「Data Sketches」 这书之前古柳和出版社的编辑推荐过,没想到后来真的会引进出中文版。而一晃也两年过去了,3月份时听说4月会上市,虽然目前还没明确消息,但应该也快了。古柳自己超喜欢的书、也是带自己入坑 D3.js 可视化的一本可视化书籍,没想到出中文版会和自己有些关系,至今觉得不可思议。

详情见:独家号外!「Data Sketches」确认引进将会有中文版! - 牛衣古柳 - 20210528。

澎湃美数课的作品

再比如澎湃美数课2020年9月有个关于新冠百万逝者的可视化作品——「一百万,一个全球悲剧」,其中的3D面积图上实现黑白渐变就用到了 Shader。

一开始古柳觉得这个作品像连绵起伏的群山,后来想起美籍华裔建筑师、林徽因的侄女林璎21岁时还在耶鲁大学读本科期间就在全美国众多参与者之中脱颖而出,并最终设计了华盛顿越战纪念碑这件事。于是又觉得美数课的作品更有纪念碑的感觉。

  • 链接:林璎:强烈而清晰的洞察力 纪录片中文字幕版

言归正传

杂七杂八讲了不少,可能大家都看烦了。其实本篇文章主要还是带大家初步了解 Three.js Shader,知道它是什么、能做什么、哪里有用到,并且为后续古柳开始写 Shader 相关教程做个引子。

不论是去年4月5月初学 Shader(那时还红码被拉去酒店隔离,疫情也是恍如隔世),还是今年年初重新啃了好多期 Yuri 频道的教程,输入或多或少在进行着,而输出却一直没开始。这次也是终于行动起来,趁着对 Shader 的热爱还在,不如就此开始写写教程,也希望能帮到想学习这方面内容的朋友。

当然 Shader/GLSL 代码和一般编程还是很不同的,理解起来也蛮抽象。古柳自身也还没多厉害仅仅入门水平而已、也依旧对后续能写出什么样的教程没那么大信心。

但就像大家都知道的王安石《游褒禅山记》那段话所说的一样:“而世之奇伟、瑰怪,非常之观,常在于险远,而人之所罕至焉”。

Shader 有多酷想来大家也有所了解了,希望大家也能和古柳一样有极大的热情一起来开启这段充满未知的 Three.js Shader 之旅。

照例

最后欢迎加入「可视化交流群」,进群多多交流,对本文任何地方有疑惑的可以群里提问。加古柳微信:xiaoaizhj,备注「可视化加群」即可。

欢迎关注古柳的公众号「牛衣古柳」,并设置星标,以便第一时间收到更新。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

大型Android项目架构:基于组件化+模块化+Kotlin

发表于 2023-04-20

SumTea_Android_头图.jpeg

前言:苟有恒,何必三更眠五更起;最无益,莫过一日曝十日寒。

前言

之前一直想写个 WanAndroid 项目来巩固自己对 Kotlin+Jetpack+协程 等知识的学习,但是一直没有时间。这里重新行动起来,从项目搭建到完成前前后后用了两个月时间,平常时间比较少,基本上都是只能利用零碎的时间来写。但不再是想写一个简单的玩安卓项目,我从多个大型项目中学习和吸取经验,从0到1打造一个符合大型项目的架构模式。

这或许是一个缩影,但是麻雀虽小,五脏俱全,这肯定能给大家带来一些想法和思考。当然这个项目的功能并未全部完善,因为我们的目的不是造一个 WanAndroid 客户端,而是学习搭建和使用 Kotlin+协程+Flow+Retrofit+Jetpack+MVVM+组件化+模块化+短视频 这一种架构,更好的提升自己。后续我也会不断完善和优化,在保证拥有一个正常的 APP 功能之外,继续加入 Compose,依赖注入Hint,性能优化,MVI模式,支付功能等的实践。

一、项目简介

  • 项目采用 Kotlin 语言编写,结合 Jetpack 相关控件,Navigation,Lifecyle,DataBinding,LiveData,ViewModel等搭建的 MVVM 架构模式;
  • 通过组件化,模块化拆分,实现项目更好解耦和复用,ARouter 实现模块间通信;
  • 使用 协程+Flow+Retrofit+OkHttp 优雅地实现网络请求;
  • 通过 mmkv,Room 数据库等实现对数据缓存的管理;
  • 使用谷歌 ExoPlayer 实现短视频播放;
  • 使用 Glide 完成图片加载;
  • 通过 WanAndroid 提供的 API 实现的一款玩安卓客户端。

SumTea工程架构.png
项目使用 MVVM架构模式,基本上遵循 Google 推荐的架构,对于 Repository,Google 认为 ViewModel 仅仅用来做数据的存储,数据加载应该由 Repository 来完成。通过 Room 数据库实现对数据的缓存,在无网络或者弱网的情况下优先展示缓存数据。

image.png

项目截图:






项目地址: github.com/suming77/Su…

二、项目详情

2.1 基础架构

(1) BaseActicity

通过单一职责原则,实现职能分级,使用者只需要按需继承即可。

  • BaseActivity:     封装了通用的 init 方法,初始化布局,加载弹框等方法,提供了原始的添加布局的方式;
  • BaseDataBindActivity:继承自 BaseActivity,通过 dataBinding 绑定布局,利用泛型参数反射创建布局文件实例,获取布局 view,不再需要 findViewById();
1
2
3
4
5
Kotlin复制代码val type = javaClass.genericSuperclass
val vbClass: Class<DB> = type!!.saveAs<ParameterizedType>().actualTypeArguments[0].saveAs()
val method = vbClass.getDeclaredMethod("inflate", LayoutInflater::class.java)
mBinding = method.invoke(this, layoutInflater)!!.saveAsUnChecked()
setContentView(mBinding.root)
  • BaseMvvmActivity: 继承自 BaseDataBindActivity,通过泛型参数反射自动创建 ViewModel 实例,更方便使用 ViewModel 实现网络请求。
1
2
Kotlin复制代码val argument = (this.javaClass.genericSuperclass as ParameterizedType).actualTypeArguments
mViewModel = ViewModelProvider(this).get(argument[1] as Class<VM>)

(2) BaseFragment

BaseFragment 的封装与上面的 BaseActivity 类似。

(3) BaseRecyclerViewAdapter

  • BaseRecyclerViewAdapter:封装了 RecyclerViewAdapter 基类,实现提供创建 ViewHolder 能力,提供添加头尾布局能力,通用的 Item 点击事件,提供 dataBinding 能力,不再需要 findViewById(),提供了多种刷新数据的方式,全局刷新,局部刷新等等。
  • BaseMultiItemAdapter:  提供了实现多种不同布局的 Adapter,根据不同的 ViewType 实现不同的 ViewBinding,再创建返回不同的 ViewHolder。

(4) Ext拓展类

项目中提供了大量控件扩展类,能够快速开发,提高效率:

  1. ResourceExt:  资源文件扩展类;
  2. TextViewExt:  TextView 扩展类;
  3. SpanExt:    Span 拓展类,实现多种 Span 效果;
  4. RecyclerViewExt:一行代码快速实现添加垂直分割线,网格分割线;
  5. ViewExt:    View 扩展类,实现点击防抖,添加间距,设置宽度,设置可见性等等;
  6. EditTextExt:  通过 Flow 构建输入框文字变化流,filter{} 实现数据过滤,避免无效请求,debounce() 实现防抖;
  7. GsonExt:    一行代码快速实现 Bean 和 Json 之间的相互转换。
1
2
3
4
5
6
7
8
Kotlin复制代码//将Bean对象转换成json字符串
fun Any.toJson(includeNulls: Boolean = true): String {
return gson(includeNulls).toJson(this)
}
//将json字符串转换成目标Bean对象
inline fun <reified T> String.toBean(includeNulls: Boolean = true): T {
return gson(includeNulls).fromJson(this, object : TypeToken<T>() {}.type)
}

(5) xlog

XLog 是一个高性能文本存储方案,在真实环境中经受了微信数亿级别的考验,具有很好的稳定性。由于其是使用C语言来实现的,占用性能忧、内存小,存储速度快等优点,支持多线程,甚至多进程的使用,支持定期删除日志,同时,拥有特定算法,进行了文件的压缩,甚至可以配置文件加密。

利用 Xlog 建设客户端运行时日志体系,远程日志按需回捞,以打点的形式记录关键执行流程。

2.2 Jetpack组件

Android_jetpack_2.png
Android Jetpack是一组 Android 软件组件、工具和指南,它们可以帮助开发者构建高质量、稳定的 Android 应用程序。Jetpack 中包含多个库,它们旨在解决 Android 应用程序开发中的常见问题,并提供一致的 API 和开发体验。

项目中仅仅使用到上图的一小部分组件。

(1) Navtgation

Navtgation 作为构建应用内界面的框架,重点是让单 Activity 应用成为首选架构(一个应用只需一个 Activity),它的定位是页面路由。

项目中主页分为5个 Tab,主要为首页、分类、体系、我的。使用 BottomNavigationView + Navigation 来搭建。通过 menu 来配置底部菜单,通过 NavHostFragment 来配置各个 Fragment。同时解决了 Navigation 与 BottomNavigationView 结合使用时,点击 tab,Fragment 每次都会重新创建问题。解决方法是自定义 FragmentNavigator,将内部 replace() 替换为 show()/hide()。

(2) ViewBinding&DataBinding

  • ViewBinding 的出现就是不再需要写 findViewById();
  • DataBinding 是一种工具,它解决了 View 和数据之间的双向绑定;减少代码模板,不再需要写findViewById();释放 Activity/Fragment,可以在 XML 中完成数据,事件绑定工作,让 Activity/Fragment 更加关心核心业务;数据绑定空安全,在 XML 中绑定数据它是空安全的,因为 DataBinding 在数据绑定上会自动装箱和空判断,所以大大减少了 NPE 问题。

(3) ViewModel

ViewModel 具备生命感知能力的数据存储组件。页面配置更改数据不会丢失,数据共享(单 Activity 多 Fragment 场景下的数据共享),以生命周期的方式管理界面相关的数据,通常和 DataBinding 配合使用,为实现 MVVM 架构提供了强有力的支持。

(4) LiveData

LiveData 是一个具有生命周期感知能力的数据订阅,分发组件。支持共享资源(一个数据支持被多个观察者接收的),支持粘性事件的分发,不再需要手动处理生命周期(和宿主生命周期自动关联),确保界面符合数据状态。在底层数据库更改时通知 View。

(5) Room

一个轻量级 orm 数据库,本质上是一个 SQLite 抽象层。使用更加简单(Builder 模式,类似 Retrofit),通过注解的形式实现相关功能,编译时自动生成实现类 IMPL。

这里主要用于首页视频列表缓存数据,与 LiveData 和 Flow 结合处理可以避免不必要的 NPE,可以监听数据库表中的数据的变化,也可以和 RXJava 的 Observer 使用,一旦发生了 insert,update,delete等操作,Room 会自动读取表中最新的数据,发送给 UI 层,刷新页面。

Room 库架构的示意图:
image.png
Room 包含三个主要组件:

  • 数据库类:用于保存数据库并作为应用持久性数据底层连接的主要访问点;
  • 数据实体:用于表示应用的数据库中的表;
  • 数据访问对象 (DAO):提供您的应用可用于查询、更新、插入和删除数据库中的数据的方法。

Dao

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
Kotlin复制代码@Dao
interface VideoListCacheDao {
//插入单个数据
@Insert(entity = VideoInfo::class, onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(videoInfo: VideoInfo)

//插入多个数据
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(videoList: MutableList<VideoInfo>)

//删除指定item 使用主键将传递的实体实例与数据库中的行进行匹配。如果没有具有相同主键的行,则不会进行任何更改
@Delete
fun delete(videoInfo: VideoInfo): Int

//删除表中所有数据
@Query("DELETE FROM $TABLE_VIDEO_LIST")
suspend fun deleteAll()

//更新某个item,不指定的entity也可以,会根据你传入的参数对象来找到你要操作的那张表
@Update
fun update(videoInfo: VideoInfo): Int

//根据id更新数据
@Query("UPDATE $TABLE_VIDEO_LIST SET title=:title WHERE id=:id")
fun updateById(id: Long, title: String)

//查询所有数据
@Query("SELECT * FROM $TABLE_VIDEO_LIST")
fun queryAll(): MutableList<VideoInfo>?

//根据id查询某个数据
@Query("SELECT * FROM $TABLE_VIDEO_LIST WHERE id=:id")
fun query(id: Long): VideoInfo?

//通过LiveData以观察者的形式获取数据库数据,可以避免不必要的NPE
@Query("SELECT * FROM $TABLE_VIDEO_LIST")
fun queryAllLiveData(): LiveData<List<VideoInfo>>
}

Database

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Kotlin复制代码@Database(entities = [VideoInfo::class], version = 1, exportSchema = false)
abstract class SumDataBase : RoomDatabase() {
//抽象方法或者抽象类标记
abstract fun videoListDao(): VideoListCacheDao

companion object {
private var dataBase: SumDataBase? = null

//同步锁,可能在多个线程中同时调用
@Synchronized
fun getInstance(): SumDataBase {
return dataBase ?: Room.databaseBuilder(SumAppHelper.getApplication(), SumDataBase::class.java, "SumTea_DB")
//是否允许在主线程查询,默认是false
.allowMainThreadQueries()
.build()
}
}
}

注意:Room 数据库中的 Dao 中定义数据库操作的方法一定要确保用法正确,否则会导致 Room 编译时生成的实现类错误,编译不通过等问题。

2.3 网络请求库

项目的网络请求封装提供了两种方式的实现,一种是协程+Retrofit+ViewModel+Repository,像官网那样加一层 Repository 去管理网络请求调用;另一种方式是通过 Flow 流配合 Retrofit 更优雅实现网络请求,对比官网的做法更加简洁。

(1) Retrofit+协程+Repository

BaseViewModel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Kotlin复制代码open class BaseViewModel : ViewModel() {
//需要运行在协程作用域中
suspend fun <T> safeApiCall(
errorBlock: suspend (Int?, String?) -> Unit,
responseBlock: suspend () -> T?
): T? {
try {
return responseBlock()
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(e)
val exception = ExceptionHandler.handleException(e)
errorBlock(exception.errCode, exception.errMsg)
}
return null
}
}

BaseRepository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Kotlin复制代码open class BaseRepository {
//IO中处理请求
suspend fun <T> requestResponse(requestCall: suspend () -> BaseResponse<T>?): T? {
val response = withContext(Dispatchers.IO) {
withTimeout(10 * 1000) {
requestCall()
}
} ?: return null

if (response.isFailed()) {
throw ApiException(response.errorCode, response.errorMsg)
}
return response.data
}
}

HomeRepository的使用

1
2
3
4
5
6
7
8
Kotlin复制代码class HomeRepository : BaseRepository() {
//项目tab
suspend fun getProjectTab(): MutableList<ProjectTabItem>? {
return requestResponse {
ApiManager.api.getProjectTab()
}
}
}

HomeViewModel的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
Kotlin复制代码class HomeViewModel : BaseViewModel() {
//请求项目Tab数据
fun getProjectTab(): LiveData<MutableList<ProjectTabItem>?> {
return liveData {
val response = safeApiCall(errorBlock = { code, errorMsg ->
TipsToast.showTips(errorMsg)
}) {
homeRepository.getProjectTab()
}
emit(response)
}
}
}

(2) Flow优雅实现网络请求

Flow 其实和 RxJava 很像,非常方便,用它来做网络请求更加简洁。
image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Kotlin复制代码suspend fun <T> requestFlowResponse(
errorBlock: ((Int?, String?) -> Unit)? = null,
requestCall: suspend () -> BaseResponse<T>?,
showLoading: ((Boolean) -> Unit)? = null
): T? {
var data: T? = null
//1.执行请求
flow {
val response = requestCall()

if (response?.isFailed() == true) {
errorBlock.invoke(response.errorCode, response.errorMsg)
}
//2.发送网络请求结果回调
emit(response)
//3.指定运行的线程,flow {}执行的线程
}.flowOn(Dispatchers.IO)
.onStart {
//4.请求开始,展示加载框
showLoading?.invoke(true)
}
//5.捕获异常
.catch { e ->
e.printStackTrace()
LogUtil.e(e)
val exception = ExceptionHandler.handleException(e)
errorBlock?.invoke(exception.errCode, exception.errMsg)
}
//6.请求完成,包括成功和失败
.onCompletion {
showLoading?.invoke(false)
//7.调用collect获取emit()回调的结果,就是请求最后的结果
}.collect {
data = it?.data
}
return data
}

2.4 图片加载库

Glide

图片加载利用 Glide 进行了简单的封装,对 ImageView 做扩展函数处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
Kotlin复制代码//加载图片,开启缓存
fun ImageView.setUrl(url: String?) {
if (ActivityManager.isActivityDestroy(context)) {
return
}
Glide.with(context).load(url)
.placeholder(R.mipmap.default_img) // 占位符,异常时显示的图片
.error(R.mipmap.default_img) // 错误时显示的图片
.skipMemoryCache(false) //启用内存缓存
.diskCacheStrategy(DiskCacheStrategy.RESOURCE) //磁盘缓存策略
.into(this)
}

//加载圆形图片
fun ImageView.setUrlCircle(url: String?) {
if (ActivityManager.isActivityDestroy(context)) return
//请求配置
val options = RequestOptions.circleCropTransform()
Glide.with(context).load(url)
.placeholder(R.mipmap.default_head)
.error(R.mipmap.default_head)
.skipMemoryCache(false) //启用内存缓存
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.apply(options)// 圆形
.into(this)
}

//加载圆角图片
fun ImageView.setUrlRound(url: String?, radius: Int = 10) {
if (ActivityManager.isActivityDestroy(context)) return
Glide.with(context).load(url)
.placeholder(R.mipmap.default_img)
.error(R.mipmap.default_img)
.skipMemoryCache(false) // 启用内存缓存
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.transform(CenterCrop(), RoundedCorners(radius))
.into(this)
}

//加载Gif图片
fun ImageView.setUrlGif(url: String?) {
if (ActivityManager.isActivityDestroy(context)) return
Glide.with(context).asGif().load(url)
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.placeholder(R.mipmap.default_img)
.error(R.mipmap.default_img)
.into(this)
}

/**
* 设置图片高斯模糊
* @param radius 设置模糊度(在0.0到25.0之间),默认25
* @param sampling 图片缩放比例,默认1
*/
fun ImageView.setBlurView(url: String?, radius: Int = 25, sampling: Int = 1) {
if (ActivityManager.isActivityDestroy(context)) return
//请求配置
val options = RequestOptions.bitmapTransform(BlurTransformation(radius, sampling))
Glide.with(context)
.load(url)
.placeholder(R.mipmap.default_img)
.error(R.mipmap.default_img)
.apply(options)
.into(this)
}
  1. 修复 Glide 的图片裁剪和 ImageView 的 scaleType 的冲突问题,Bitmap 会先圆角裁剪,再加载到 ImageView 中,如果 Bitmap 图片尺寸大于 ImageView 尺寸,则会看不到,使用 CenterCrop() 重载,会先将 Bitmap 居中裁剪,再进行圆角处理,这样就能看到。
  2. 提供了 GIF 图加载和图片高斯模糊效果功能。

2.5 WebView

我们都知道原生的 WebView 存在很多问题,使用腾讯X5内核 WebView 进行封装,兼容性,稳定性,安全性,速度都有很大的提升。

项目中使用 WebView 展示文章详情页。

2.6 MMKV

MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化 / 反序列化使用 protobuf 实现,性能高,稳定性强。使用简单,支持多进程。

在 App 启动时初始化 MMKV,设定 MMKV 的根目录(files/mmkv/),例如在 Application 里:

1
2
3
4
5
6
Kotlin复制代码public void onCreate() {
super.onCreate();

String rootDir = MMKV.initialize(this);
LogUtil.e("mmkv root: " + rootDir);
}

MMKV 提供一个全局的实例,可以直接使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
Kotlin复制代码import com.tencent.mmkv.MMKV;
//……

MMKV kv = MMKV.defaultMMKV();

kv.encode("bool", true);
boolean bValue = kv.decodeBool("bool");

kv.encode("int", Integer.MIN_VALUE);
int iValue = kv.decodeInt("int");

kv.encode("string", "Hello from mmkv");
String str = kv.decodeString("string");

循环写入随机的 int 1k 次,有如下性能对比:
image.png
项目中使用 MMKV 保存用户相关信息,包括用户登录 Cookies,用户名称,手机号码,搜索历史数据等信息。

2.7 ExoPlayer视频播放器

ExoPlayer 是 google 推出的开源播放器,主要是集成了 Android 提供的一套解码系统来解析视频和音频,将 MediaCodec 封装地非常完善,形成了一个性能优越,播放稳定性较好的一个开发播放器,支持更多的视频播放格式(包含 DASH 和 SmoothStreaming,这2种 MediaPlayer 不支持),通过组件化自定义播放器,方便扩展定制,持久的高速缓存,另外 ExoPlayer 包大小轻便,接入简单。

项目中使用 ExoPlayer 实现防抖音短视频播放:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
Kotlin复制代码class VideoPlayActivity : BaseDataBindActivity<ActivityVideoPlayBinding>() {
//创建exoplayer播放器实例,视屏画面渲染工厂类,语音选择器,缓存控制器
private fun initPlayerView(): Boolean {
//创建exoplayer播放器实例
mPlayView = initStylePlayView()

// 创建 MediaSource 媒体资源 加载的工厂类
mMediaSource = ProgressiveMediaSource.Factory(buildCacheDataSource())

mExoPlayer = initExoPlayer()
//缓冲完成自动播放
mExoPlayer?.playWhenReady = mStartAutoPlay
//将显示控件绑定ExoPlayer
mPlayView?.player = mExoPlayer

//资源准备,如果设置 setPlayWhenReady(true) 则资源准备好就立马播放。
mExoPlayer?.prepare()
return true
}

//初始化ExoPlayer
private fun initExoPlayer(): ExoPlayer {
val playerBuilder = ExoPlayer.Builder(this).setMediaSourceFactory(mMediaSource)
//视频每一帧的画面如何渲染,实现默认的实现类
val renderersFactory: RenderersFactory = DefaultRenderersFactory(this)
playerBuilder.setRenderersFactory(renderersFactory)
//视频的音视频轨道如何加载,使用默认的轨道选择器
playerBuilder.setTrackSelector(DefaultTrackSelector(this))
//视频缓存控制逻辑,使用默认的即可
playerBuilder.setLoadControl(DefaultLoadControl())

return playerBuilder.build()
}

//创建exoplayer播放器实例
private fun initStylePlayView(): StyledPlayerView {
return StyledPlayerView(this).apply {
controllerShowTimeoutMs = 10000
setKeepContentOnPlayerReset(false)
setShowBuffering(SHOW_BUFFERING_NEVER)//不展示缓冲view
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
useController = false //是否使用默认控制器,如需要可参考PlayerControlView
// keepScreenOn = true
}
}

//创建能够 边播放边缓存的 本地资源加载和http网络数据写入的工厂类
private fun buildCacheDataSource(): DataSource.Factory {
//创建http视频资源如何加载的工厂对象
val upstreamFactory = DefaultHttpDataSource.Factory()

//创建缓存,指定缓存位置,和缓存策略,为最近最少使用原则,最大为200m
mCache = SimpleCache(
application.cacheDir,
LeastRecentlyUsedCacheEvictor(1024 * 1024 * 200),
StandaloneDatabaseProvider(this)
)

//把缓存对象cache和负责缓存数据读取、写入的工厂类CacheDataSinkFactory 相关联
val cacheDataSinkFactory = CacheDataSink.Factory().setCache(mCache).setFragmentSize(Long.MAX_VALUE)
return CacheDataSource.Factory()
.setCache(mCache)
.setUpstreamDataSourceFactory(upstreamFactory)
.setCacheReadDataSourceFactory(FileDataSource.Factory())
.setCacheWriteDataSinkFactory(cacheDataSinkFactory)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
}

2.8 组件化&模块化

组件化&模块化有利于业务模块分离,高内聚,低耦合,代码边界清晰。有利于团队合作多线开发,加快编译速度,提高开发效率,管理更加方便,利于维护和迭代。
SumTea工程架构 (1).png
宿主 App 中只有一个 Application,整个业务被拆分为各个 mod 模块和 lib 组件库。对一些功能组件进行封装抽取为 lib,给上层提供依赖。mod 模块之间没有任务依赖关系,通过 Arouter 进行通信。

(1) 模块化

项目中通过以业务为维度把 App 拆分成主页模块,登录模块,搜索模块,用户模块,视频模块等,相互间不可以访问不可以作为依赖,与此同时他们共同依赖于基础库,网络请求库,公共资源库,图片加载库等。如果还需要使用到启动器组件、Banner组件、数据库Room组件等则单独按需添加。

APP 壳工程负责打包环境,签名,混淆规则,业务模块集成,APP 主题等配置等工作,一般不包含任何业务。

(2) 组件化

模块化和组件化最明显的区别就是模块相对组件来说粒度更大。一个模块中可能包含多个组件。在划分的时候,模块化是业务导向,组件化是功能导向。组件化是建立在模块化思想上的一次演进。

项目中以功能维度拆分了启动器组件、Banner组件、数据库Room组件等组件。模块化&组件化拆分后工程图:

image.png

(3) 组件间通信

组件化之后就无法直接访问其他模块的类和方法,这是个比较突出的问题,就像原来可以直接使用 LogintManager 来拉起登录,判断是否已登录,但是这个类已经被拆分到了 mod_login 模块下,而业务模块之间是不能互相作为依赖的,所以无法在其他模块直接使用 LogintManager。

主要借助阿里的路由框架 ARouter 实现组件间通信,把对外提供的能力,以接口的形式暴露出去。

比如在公共资源库中的 service 包下创建 ILoginService,提供对外暴露登录的能力,在 mod_login 模块中提供 LoginServiceImpl 实现类,任意模块就可以通过 LoginServiceProvider 使用 iLoginService 对外提供暴露的能力。

  1. 公共资源库中创建 ILoginService,提供对外暴露登录的能力。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Kotlin复制代码interface ILoginService : IProvider {
//是否登录
fun isLogin(): Boolean

//跳转登录页
fun login(context: Context)

//登出
fun logout(
context: Context,
lifecycleOwner: LifecycleOwner?,
observer: Observer<Boolean>
)
}
  1. mod_login 模块中 LoginService 提供 ILoginService 的具体实现。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Kotlin复制代码@Route(path = LOGIN_SERVICE_LOGIN)
class LoginService : ILoginService {

//是否登录
override fun isLogin(): Boolean {
return UserServiceProvider.isLogin()
}

//跳转登录页
override fun login(context: Context) {
context.startActivity(Intent(context, LoginActivity::class.java))
}

//登出
override fun logout(
context: Context,
lifecycleOwner: LifecycleOwner?,
observer: Observer<Boolean>
) {
val scope = lifecycleOwner?.lifecycleScope ?: GlobalScope
scope.launch {
val response = ApiManager.api.logout()
if (response?.isFailed() == true) {
TipsToast.showTips(response.errorMsg)
return@launch
}
LogUtil.e("logout${response?.data}", tag = "smy")
observer.onChanged(response?.isFailed() == true)
login(context)
}
}

override fun init(context: Context?) {}
}
  1. 公共资源库中创建 LoginServiceProvider,获取 LoginService,提供使用方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Kotlin复制代码object LoginServiceProvider {
//获取loginService实现类
val loginService = ARouter.getInstance().build(LOGIN_SERVICE_LOGIN).navigation() as? ILoginService

//是否登录
fun isLogin(): Boolean {
return loginService.isLogin()
}

//跳转登录
fun login(context: Context) {
loginService.login(context)
}

//登出
fun logout(
context: Context,
lifecycleOwner: LifecycleOwner?,
observer: Observer<Boolean>
) {
loginService.logout(context, lifecycleOwner, observer)
}
}

那么其他模块就可以通过 LoginServiceProvider 使用 iLoginService 对外提供暴露的能力。虽然看起来这么做会显得更复杂,单一工程可能更加适合我们,每个类都能直接访问,每个方法都能直接调用,但是我们不能局限于单人开发的环境,在实际场景上多人协作是常态,模块化开发是主流。

(4) Module单独运行

使得模块可以在集成和独立调试之间切换特性。在打包时是 library,在调试是 application。

  1. 在 config.gradle 文件中加入 isModule 参数:
1
2
Kotlin复制代码//是否单独运行某个module
isModule = false
  1. 在每个 Module 的 build.gradle 中加入 isModule 的判断,以区分是 application 还是 library:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Kotlin复制代码// 组件模式和基础模式切换
def root = rootProject.ext
if (root.isModule) {
apply plugin: 'com.android.application'
} else {
apply plugin: 'com.android.library'
}

android {
sourceSets {
main {
if (rootProject.ext.isModule) {
manifest.srcFile 'src/main/debug/AndroidManifest.xml'
} else {
manifest.srcFile 'src/main/AndroidManifest.xml'
//library模式下排除debug文件夹中的所有Java文件
java {
exclude 'debug/**'
}
}
}
}
}
  1. 将通过修改 SourceSets 中的属性,可以指定需要被编译的源文件,如果是library,则编译 manifest 下 AndroidManifest.xml,反之则直接编译 debug 目录下 AndroidManifest.xml,同时加入 Application 和 intent-filter 等参数。

存疑一:

至于模块单独编译单独运行,这种是一个伪需求,实际上必然存在多个模块间通信的场景。不然跨模块的服务提取和获取,初始化任务,模块间的联合测试该怎么解决呢?一个模块运行后需要和其他的模块通信,比如对外提供服务,获取服务,与之相关联的模块如果没有运行起来的话是无法使用的。

与此同时还需要在 suorceSets 下维护两套 AndoidManifest 以及 Javasource 目录,这个不仅麻烦而且每次更改都需要同步一段时间。所以这种流传的模块化独立编译的形式,是否真的适合就仁者见仁了。

三、写在最后

如需要更详细的代码可以到项目源码中查看,地址在下面给出。由于时间仓促,项目中有部分功能尚未完善,或者部分实现方式有待优化,也有更多的Jetpack组件尚未在项目中实践,比如 依赖注入Hilt,相机功能CameraX,权限处理Permissions, 分页处理Paging等等。项目的持续迭代更新依然是一项艰苦持久战。

除去可以学到 Kotlin + MVVM + Android Jetpack + 协程 + Flow + 组件化 + 模块化 + 短视频 的知识,相信你还可以在我的项目中学到:

  1. 如何使用 Charles 抓包。
  2. 提供大量扩展函数,快速开发,提高效率。
  3. ChipGroup 和 FlexboxLayoutManager 等多种原生方式实现流式布局。
  4. 符合阿里巴巴 Java 开发规范和阿里巴巴 Android 开发规范,并有良好的注释。
  5. CoordinatorLayout 和 Toolbar 实现首页栏目吸顶效果和轮播图电影效果。
  6. 利用 ViewOutlineProvider 给控件添加圆角,大大减少手写 shape 圆角 xml。
  7. ConstraintLayout 的使用,几乎每个界面布局都采用的 ConstraintLayout。
  8. 异步任务启动器,优雅地处理 Application 中同步初始化任务问题,有效减少 APP启动耗时。
  9. 无论是模块化或者组件化,它们本质思想都是一样的,都是化整为零,化繁为简,两者的目的都是为了重用和解耦,只是叫法不一样。

项目地址:ST_Wan_Android

点关注,不迷路

好了各位,以上就是这篇文章的全部内容了,很感谢您阅读这篇文章。我是suming,感谢支持和认可,您的点赞就是我创作的最大动力。山水有相逢,我们下篇文章见!

本人水平有限,文章难免会有错误,请批评指正,不胜感激 !

感谢

API: 鸿洋提供的 WanAndroid API

主要使用的开源框架:

  • Retrofit
  • OkHttp
  • Glide
  • ARouter
  • MMKV
  • RxPermission
  • SmartRefreshLayout

希望我们能成为朋友,在 Github、掘金 上一起分享知识,一起共勉!Keep Moving!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…777879…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%