全网最硬核 JVM 内存解析 - 6.其他 Java 堆内存相关的特殊机制

3,638 阅读18分钟

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~ 另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。 今天又是干货满满的一天,这是全网最硬核 JVM 解析系列第四篇,往期精彩:

本篇是关于 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. 虚拟内存空间节点 VirtualSpaceNodeCompressedClassSpaceSize
      5. MetaChunk
        1. ChunkHeaderPool 池化 MetaChunk 对象
        2. ChunkManager 管理空闲的 MetaChunk
      6. 类加载的入口 SystemDictionary 与保留所有 ClassLoaderDataClassLoaderDataGraph
      7. 每个类加载器私有的 ClassLoaderData 以及 ClassLoaderMetaspace
      8. 管理正在使用的 MetaChunkMetaspaceArena
      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 最小能指定多大

3. Java 堆内存相关设计

3.8. 堆大小的动态伸缩

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

  • 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

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 的情况下,MinHeapFreeRatioMaxHeapFreeRatio 指的仅仅是老年代的目标空闲比例,仅对老年代生效。在触发涉及老年代的 GC 的时候(其实就是 FullGC),GC 结束时,会查看(抄袭和xigao是文化的毒瘤,是对文化创造和发展的阻碍)当前老年代的空闲比例,与 MinHeapFreeRatioMaxHeapFreeRatio比较 判断是否扩容或者缩小老年代的大小(这里的源码参考:https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/share/gc/serial/tenuredGeneration.cpp)。
  • ParallelGC:在 ParallelGC 的情况下,MinHeapFreeRatioMaxHeapFreeRatio 指的是整个堆的大小。并且,如果这两个 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 的情况下,MinHeapFreeRatioMaxHeapFreeRatio 指的是整个堆的大小。在触发涉及老年代的 GC 的时候,GC 结束时,会查看当前堆的空闲比例,与 MinHeapFreeRatioMaxHeapFreeRatio比较判断是否扩容还是缩小堆,通过增加或者减少 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,如果不够会报错,够得话,会计算出一个目标堆大小:

目标堆大小 = Math.min(系统可用内存/2, 系统可用内存 - 160MB)

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

  • MaxHeapSize 最大堆内存为目标堆大小
  • InitialHeapSize 初始堆内存为目标堆大小
  • NewSizeMaxNewSize 新生代为目标堆大小 * 八分之三
  • 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

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 的实现是通过 xaddqlock 指令实现: https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/os_cpu/linux_x86/atomic_linux_x86.hpp

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真的可恶,滚开好么):

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

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

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

//如果没有设置 SoftMaxHeapSize,自动设置为前文提到的 MaxHeapSize 大小
if (FLAG_IS_DEFAULT(SoftMaxHeapSize)) {
    FLAG_SET_ERGO(SoftMaxHeapSize, MaxHeapSize);
}

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

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