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

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

10月前
379846

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

  • 全网最硬核 TLAB 解析

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

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

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

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

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

    1. Native Memory Tracking 的开启

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    10. JVM 参数 AlwaysPreTouch 的作用

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

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

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

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

    2. 元空间相关 JVM 日志

    3. 元空间 JFR 事件详解

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

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

    6. jdk.MetaspaceOOM 元空间 OOM 事件

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

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

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

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

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

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

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

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

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

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

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

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

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

    20. 元空间上下文 MetaspaceContext

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

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

    23. MetaChunk

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

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

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

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

    28. ClassLoaderData 回收

    29. ChunkHeaderPool 池化 MetaChunk 对象

    30. ChunkManager 管理空闲的 MetaChunk

    31. 类加载器到 MetaSpaceArena 的流程

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

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

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

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

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

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

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

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

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

    41. 元空间保存什么

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

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

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

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

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

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

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

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

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

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

    2. Java 线程栈内存的结构

    3. Java 线程如何抛出的 StackOverflowError

1. 从 Native Memory Tracking 说起

JVM 内存究竟包括哪些,可能网上众说纷纭。我们这里由官方提供的一个查看 JVM 内存占用的工具引入,即 Native Memory Tracking。不过要注意的一点是,这个只能监控 JVM 原生申请的内存大小,如果是通过 JDK 封装的系统 API 申请的内存,是统计不到的,例如 Java JDK 中的 DirectBuffer 以及 MappedByteBuffer 这两个(当然,对于这两个,我们后面也有其他的办法去看到当前使用的大小。当然xigao dog 啥都不会)。以及如果你自己封装 JNI 调用系统调用去申请内存,都是 Native Memory Tracking 无法涵盖的。这点要注意。

1.1. Native Memory Tracking 的开启

Native Memory Tracking 主要是用来通过在 JVM 向系统申请内存的时候进行埋点实现的。注意,这个埋点,并不是完全没有消耗的,我们后面会看到。由于需要埋点,并且 JVM 中申请内存的地方很多,这个埋点是有不小消耗的,这个 Native Memory Tracking 默认是不开启的,并且无法动态开启(因为这是埋点采集统计的,如果可以动态开启那么没开启的时候的内存分配没有记录无法知晓,所以无法动态开启),目前只能通过在启动 JVM 的时候通过启动参数开启。即通过 -XX:NativeMemoryTracking 开启:

  • -XX:NativeMemoryTracking=off:这是默认值,即关闭 Native Memory Tracking

  • -XX:NativeMemoryTracking=summary: 开启 Native Memory Tracking,但是仅仅按照各个 JVM 子系统去统计内存占用情况

  • -XX:NativeMemoryTracking=detail:开启 Native Memory Tracking,从每次 JVM 中申请内存的不同调用路径的维度去统计内存占用情况。注意,开启 detail 比开启 summary 的消耗要大不少,因为 detail 每次都要解析 CallSite 分辨调用位置。我们一般用不到这么详细的内容,除非是 JVM 开发。只有洗稿狗才会开启这个配置导致线上崩溃而自己又很懵。

开启之后,我们可以通过 jcmd 命令去查看 Native Memory Tracking 的信息,即jcmd <pid> VM.native_memory

  • jcmd <pid> VM.native_memory或者jcmd <pid> VM.native_memory summary:两者是等价的,即查看 Native Memory Tracking 的 summary 信息。默认单位是 KB,可以指定单位为其他,例如 jcmd <pid> VM.native_memory summary scale=MB

  • jcmd <pid> VM.native_memory detail查看 Native Memory Tracking 的 detail 信息,包括 summary 信息,以及按照虚拟内存映射分组的内存使用信息,还有按照不同 CallSite 调用分组的内存使用情况。默认单位是 KB,可以指定单位为其他,例如 jcmd <pid> VM.native_memory detail scale=MB

1.2. Native Memory Tracking 的使用

对于我们这些 Java 开发以及 JVM 使用者而言(对于抄袭狗是没有好果汁吃的),我们只关心并且查看 Native Memory Tracking 的 summary 信息即可,detail 信息一般是供 JVM 开发人员使用的,我们不用太关心,我们后面的分析也只会涉及 Native Memory Tracking 的 summary 部分。

一般地,只有遇到问题的时候,我们才会考虑开启 Native Memory Tracking,并且在定位出问题后,我们想把它关闭,可以通过 jcmd <pid> VM.native_memory shutdown 进行关闭并清理掉之前 Native Memory tracking 使用的埋点以及占用的内存。如前面所述,我们无法动态开启 Native Memory tracking,所以只要动态关闭了,这个进程就无法再开启了。

jcmd 本身提供了简单的对比功能,例如:

  1. 使用 jcmd <pid> VM.native_memory baseline 记录当前内存占用信息

  2. 之后过一段时间 jcmd <pid> VM.native_memory summary.diff 会输出当前 Native Memory Tracking 的 summary 信息,如果与第一步 baseline 的有差异,会在对应位将差异输出

但是这个工具本身比较粗糙,我们有时候并不知道何时调用 jcmd <pid> VM.native_memory summary.diff 合适,因为我们不确定什么时候会有我们想看到的内存使用过大的问题。所以我们一般做成一种持续监控的方式

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

以下是一个 Native Memory Tracking 的示例输出:

Total: reserved=10575644KBcommitted=443024KB
-                 Java Heap (reserved=8323072KBcommitted=192512KB)
                            (mmapreserved=8323072KBcommitted=192512KB
 
-                     Class (reserved=1050202KBcommitted=10522KB)
                            (classes #15409)
                            (  instance classes #14405, array classes #1004)
                            (malloc=1626KB #33495) 
                            (mmapreserved=1048576KBcommitted=8896KB
                            (  Metadata: )
                            (    reserved=57344KBcommitted=57216KB)
                            (    used=56968KB)
                            (    waste=248KB =0.43%)
                            (  Class space:)
                            (    reserved=1048576KBcommitted=8896KB)
                            (    used=8651KB)
                            (    waste=245KB =2.75%)
 
-                    Thread (reserved=669351KBcommitted=41775KB)
                            (thread #653)
                            (stackreserved=667648KBcommitted=40072KB)
                            (malloc=939KB #3932) 
                            (arena=764KB #1304)
 
-                      Code (reserved=50742KBcommitted=17786KB)
                            (malloc=1206KB #9495) 
                            (mmapreserved=49536KBcommitted=16580KB
 
-                        GC (reserved=370980KBcommitted=69260KB)
                            (malloc=28516KB #8340) 
                            (mmapreserved=342464KBcommitted=40744KB
 
-                  Compiler (reserved=159KBcommitted=159KB)
                            (malloc=29KB #813) 
                            (arena=131KB #3)
 
-                  Internal (reserved=1373KBcommitted=1373KB)
                            (malloc=1309KB #6135) 
                            (mmapreserved=64KBcommitted=64KB
 
-                     Other (reserved=12348KBcommitted=12348KB)
                            (malloc=12348KB #14) 
 
-                    Symbol (reserved=18629KBcommitted=18629KB)
                            (malloc=16479KB #445877) 
                            (arena=2150KB #1)
 
-    Native Memory Tracking (reserved=8426KBcommitted=8426KB)
                            (malloc=325KB #4777) 
                            (tracking overhead=8102KB)
 
-        Shared class space (reserved=12032KBcommitted=12032KB)
                            (mmapreserved=12032KBcommitted=12032KB
 
-               Arena Chunk (reserved=187KBcommitted=187KB)
                            (malloc=187KB
 
-                   Tracing (reserved=32KBcommitted=32KB)
                            (arena=32KB #1)
 
-                   Logging (reserved=5KBcommitted=5KB)
                            (malloc=5KB #216) 
 
-                 Arguments (reserved=31KBcommitted=31KB)
                            (malloc=31KB #90) 
 
-                    Module (reserved=403KBcommitted=403KB)
                            (malloc=403KB #2919) 
 
-                 Safepoint (reserved=8KBcommitted=8KB)
                            (mmapreserved=8KBcommitted=8KB
 
-           Synchronization (reserved=56KBcommitted=56KB)
                            (malloc=56KB #789) 
 
-            Serviceability (reserved=1KBcommitted=1KB)
                            (malloc=1KB #18) 
 
-                 Metaspace (reserved=57606KBcommitted=57478KB)
                            (malloc=262KB #180) 
                            (mmapreserved=57344KBcommitted=57216KB
 
-      String Deduplication (reserved=1KBcommitted=1KB)
                            (malloc=1KB #8) 
 

我们接下来将上面的信息按不同子系统分别简单分析下其含义:

1.Java堆内存,所有 Java 对象分配占用内存的来源,由 JVM GC 管理回收,这是我们在第三章会重点分析的:

    //堆内存占用,reserve  8323072KB,当前 commit  192512KB 用于实际使用
    Java Heap (reserved=8323072KB, committed=192512KB) 
        //堆内存都是通过 mmap 系统调用方式分配的
        (mmap: reserved=8323072KB, committed=192512KB)
        //chao xi 可耻

2.元空间,JVM 将类文件加载到内存中用于后续使用占用的空间,注意是 JVM C++ 层面的内存占用,主要包括类文件中在 JVM 解析为 C++ 的 Klass 类以及相关元素。对应的 Java 反射类 Class 还是在堆内存空间中:

      //Class 是类元空间总占用,reserve  1050202KB,当前 commit  10522KB 用于实际使用
      //总共 reserved 1050202KB = mmap reserved 1048576KB + malloc 1626KB
      //总共 committed 10522KB = mmap committed 8896KB + malloc 1626KB
      Class (reserved=1050202KB, committed=10522KB)
            (classes #15409) //一共加载了 15409 个类
            ( instance classes #14405, array classes #1004) //其中 14405 个实体类,1004 个数组类
            (malloc=1626KB #33495) //通过 malloc 系统调用方式一共分配了 1626KB,一共调用了 33495  malloc
            (mmap: reserved=1048576KB, committed=8896KB) //通过 mmap 系统调用方式 reserve  1048576KB,当前 commit  8896KB 用于实际使用
            ( Metadata: )//注意,MetaData 这块不属于类元空间,属于数据元空间,后面第四章会详细分析
            ( reserved=57344KB, committed=57216KB) //数据元空间当前 reserve  57344KBcommit  57216KB 用于实际使用
            ( used=56968KB) //但是实际从 MetaChunk 的角度去看使用,只有 56968KB 用于实际数据的分配,有 248KB 的浪费
            ( waste=248KB =0.43%)
            ( Class space:)
            ( reserved=1048576KB, committed=8896KB) //类元空间当前 reserve  1048576KBcommit  8896KB 用于实际使用
            ( used=8651KB) //但是实际从 MetaChunk 的角度去看使用,只有 8651KB 用于实际数据的分配,有 245KB 的浪费
            ( waste=245KB =2.75%)
            洗稿去shi
      Shared class space (reserved=12032KB, committed=12032KB) //共享类空间,当前 reserve  12032KBcommit  12032KB 用于实际使用,这块其实属于上面 Class 的一部分
            (mmap: reserved=12032KB, committed=12032KB) 
      Module (reserved=403KB, committed=403KB) //加载并记录模块占用空间,当前 reserve  403KBcommit  403KB 用于实际使用
            (malloc=403KB #2919
      Metaspace (reserved=57606KB, committed=57478KB) //等价于上面 Class 中的 MetaChunk(除了 malloc 的部分),当前 reserve  57606KBcommit  57478KB 用于实际使用
            (malloc=262KB #180
            (mmap: reserved=57344KB, committed=57216KB) 

3.C++ 字符串即符号(Symbol)占用空间,前面加载类的时候,其实里面有很多字符串信息(注意不是 Java 字符串,是 JVM 层面 C++ 字符串),不同类的字符串信息可能会重复(维护原创打死潮汐犬)。所以统一放入符号表(Symbol table)复用。元空间中保存的是针对符号表中符号的引用。这不是本期内容的重点,我们不会详细分析

Symbol (reserved=18629KB, committed=18629KB)
(malloc=16479KB #445877) //通过 malloc 系统调用方式一共分配了 16479KB,一共调用了 445877  malloc
(arena=2150KB #1) //通过 arena 系统调用方式一共分配了 2150KB,一共调用了 1  arena

4.线程占用内存,主要是每个线程的线程栈,我们也只会主要分析线程栈占用空间(在第五章),其他的管理线程占用的空间很小,可以忽略不计。

//总共 reserve  669351KBcommit  41775KB
Thread (reserved=669351KB, committed=41775KB)
    (thread #653)//当前线程数量是 653
    (stack: reserved=667648KB, committed=40072KB) //线程栈占用的空间:我们没有指定 Xss,默认是 1MB,所以 reserved  653 * 1024 = 667648KB,当前 commit  40072KB 用于实际使用
    (malloc=939KB #3932) //通过 malloc 系统调用方式一共分配了 939KB,一共调用了 3932  malloc
    (arena=764KB #1304) //通过 JVM 内部 Arena 分配的内存,一共分配了 764KB,一共调用了 1304  Arena 分配

5.JIT编译器本身占用的空间以及JIT编译器编译后的代码占用空间,这也不是本期内容的重点,我们不会详细分析

Code (reserved=50742KB, committed=17786KB)
(malloc=1206KB #9495
(mmap: reserved=49536KB, committed=16580KB) 
//chao xi 直接去火葬场炒
Compiler (reserved=159KB, committed=159KB)
(malloc=29KB #813
(arena=131KB #3)   

6.Arena 数据结构占用空间,我们看到 Native Memory Tracking 中有很多通过 arena 分配的内存,这个就是管理 Arena 数据结构占用空间。这不是本期内容的重点,我们不会详细分析

Arena Chunk (reserved=187KB, committed=187KB)
(malloc=187KB) 

7.JVM Tracing 占用内存,包括 JVM perf 以及 JFR 占用的空间。其中 JFR 占用的空间可能会比较大,我在我的另一个关于 JFR 的系列里面分析过 JVM 内存中占用的空间。这不是本期内容的重点,我们不会详细分析

Tracing (reserved=32KB, committed=32KB)
(arena=32KB #1)

8.写 JVM 日志占用的内存-Xlog 参数指定的日志输出,并且 Java 17 之后引入了异步 JVM 日志-Xlog:async,异步日志所需的 buffer 也在这里),这不是本期内容的重点,我们不会详细分析

Logging (reserved=5KB, committed=5KB)
(malloc=5KB #216

9.JVM 参数占用内存,我们需要保存并处理当前的 JVM 参数以及用户启动 JVM 的是传入的各种参数(有时候称为 flag)。这不是本期内容的重点,我们不会详细分析

Arguments (reserved=31KB, committed=31KB)
(malloc=31KB #90

10.JVM 安全点占用内存,是固定的两页内存(我这里是一页是 4KB,后面第二章会分析这个页大小与操作系统相关),用于 JVM 安全点的实现,不会随着 JVM 运行时的内存占用而变化。JVM 安全点请期待本系列文章的下一系列:全网最硬核的 JVM 安全点与线程握手机制解析。这不是本期内容的重点,我们不会详细分析

Safepoint (reserved=8KB, committed=8KB)
(mmap: reserved=8KB, committed=8KB) 

11.Java 同步机制(例如 synchronized,还有 AQS 的基础 LockSupport)底层依赖的 C++ 的数据结构,系统内部的 mutex 等占用的内存。这不是本期内容的重点,我们不会详细分析

Synchronization (reserved=56KB, committed=56KB)
(malloc=56KB #789)

12.JVM TI 相关内存,JVMTI 是 Java 虚拟机工具接口(Java Virtual Machine Tool Interface)的缩写。它是 Java 虚拟机(JVM)的一部分,提供了一组 API,使开发人员可以开发自己的 Java 工具和代理程序,以监视、分析和调试 Java 应用程序。JVMTI API 是一组 C/C++ 函数,可以通过 JVM TI Agent Library 和 JVM 进行交互。开发人员可以使用 JVMTI API 开发自己的 JVM 代理程序或工具,以监视和操作 Java 应用程序。例如,可以使用 JVMTI API 开发性能分析工具、代码覆盖率工具、内存泄漏检测工具等等。这里的内存就是调用了 JVMTI API 之后 JVM 为了生成数据占用的内存。这不是本期内容的重点,我们不会详细分析

Serviceability (reserved=1KB, committed=1KB)
(malloc=1KB #18

13.Java 字符串去重占用内存:Java 字符串去重机制可以减少应用程序中字符串对象的内存占用。在 Java 应用程序中,字符串常量是不可变的,并且通常被使用多次。这意味着在应用程序中可能存在大量相同的字符串对象,这些对象占用了大量的内存。Java 字符串去重机制通过在堆中共享相同的字符串对象来解决这个问题。当一个字符串对象被创建时,JVM 会检查堆中是否已经存在相同的字符串对象。如果存在,那么新的字符串对象将被舍弃,而引用被返回给现有的对象。这样就可以减少应用程序中字符串对象的数量,从而减少内存占用。但是这个机制一直在某些 GC 下表现不佳,尤其是 G1GC 以及 ZGC 中,所以默认是关闭的,可以通过 -XX:+UseStringDeduplication 来启用。这不是本期内容的重点,我们不会详细分析。

String Deduplication (reserved=1KB, committed=1KB)
(malloc=1KB #8

14.JVM GC需要的数据结构与记录信息占用的空间,这块内存可能会比较大,尤其是对于那种专注于低延迟的 GC,例如 ZGC。其实 ZGC 是一种以空间换时间的思路,提高 CPU 消耗与内存占用,但是消灭全局暂停。之后的 ZGC 优化方向就是尽量降低 CPU 消耗与内存占用,相当于提高了性价比。这不是本期内容的重点,我们不会详细分析。

GC (reserved=370980KB, committed=69260KB)
(malloc=28516KB #8340
(mmap: reserved=342464KB, committed=40744KB) 

15.JVM内部(不属于其他类的占用就会归到这一类)与其他占用(不是 JVM 本身而是操作系统的某些系统调用导致额外占的空间),不会很大

Internal (reserved=1373KB, committed=1373KB)
(malloc=1309KB #6135)
(mmap: reserved=64KB, committed=64KB)

Other (reserved=12348KB, committed=12348KB)
(malloc=12348KB #14)

16.开启 Native Memory Tracking 本身消耗的内存,这个就不用多说了吧

Native Memory Tracking (reserved=8426KB, committed=8426KB)
(malloc=325KB #4777
(tracking overhead=8102KB)

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

现在 JVM 一般大部分部署在 k8s 这种云容器编排的环境中,每个 JVM 进程内存是受限的。如果超过限制,那么会触发 OOMKiller 将这个 JVM 进程杀掉。我们一般都是由于自己的 JVM 进程被 OOMKiller 杀掉,才会考虑打开 NativeMemoryTracking 去看看哪块内存占用比较多以及如何调整的。

OOMKiller 是积分制,并不是你的 JVM 进程一超过限制就立刻会被杀掉,而是超过的话会累积分,累积到一定程度,就可能会被 OOMKiller 杀掉。所以,我们可以通过定时输出 Native Memory Tracking 的 summary 信息,从而抓到超过内存限制的点进行分析

但是,我们不能仅通过 Native Memory Tracking 的数据就判断 JVM 占用的内存,因为在后面的 JVM 内存申请与使用流程的分析我们会看到,JVM 通过 mmap 分配的大量内存都是先 reserve 再 commit 之后实际往里面写入数据的时候,才会真正分配物理内存。同时,JVM 还会动态释放一些内存,这些内存可能不会立刻被操作系统回收。Native Memory Tracking 是 JVM 认为自己向操作系统申请的内存,与实际操作系统分配的内存是有所差距的,所以我们不能只查看 Native Memory Tracking 去判断,我们还需要查看能体现真正内存占用指标。这里可以查看 linux 进程监控文件 smaps_rollup 看出具体的内存占用,例如 (一般不看 Rss,因为如果涉及多个虚拟地址映射同一个物理地址的话会有不准确,所以主要关注 Pss 即可,但是 Pss 更新不是实时的,但也差不多,这就可以理解为进程占用的实际物理内存):

> cat /proc/23/smaps_rollup 
689000000-fffff53a9000 ---p 00000000 00:00 0                             [rollup]
Rss: 5870852 kB
Pss: 5849120 kB
Pss_Anon: 5842756 kB
Pss_File: 6364 kB
Pss_Shmem: 0 kB
Shared_Clean: 27556 kB
Shared_Dirty: 0 kB
Private_Clean: 524 kB
Private_Dirty: 5842772 kB
Referenced: 5870148 kB
Anonymous: 5842756 kB
LazyFree: 0 kB
AnonHugePages: 0 kB
ShmemPmdMapped: 0 kB
FilePmdMapped: 0 kB
Shared_Hugetlb: 0 kB
Private_Hugetlb: 0 kB
Swap: 0 kB
SwapPss: 0 kB
Locked: 0 kB

笔者通过在每个 Spring Cloud 微服务进程加入下面的代码,来实现定时的进程内存监控,主要通过 smaps_rollup 查看实际的物理内存占用找到内存超限的时间点,Native Memory Tracking 查看 JVM 每块内存占用的多少,用于指导优化参数。

import lombok.extern.log4j.Log4j2;
import org.apache.commons.io.FileUtils;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.List;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;

import static org.springframework.cloud.bootstrap.BootstrapApplicationListener.BOOTSTRAP_PROPERTY_SOURCE_NAME;

@Log4j2
public class MonitorMemoryRSS implements ApplicationListener<ApplicationReadyEvent> {
    private static final AtomicBoolean INITIALIZED = new AtomicBoolean(false);

    private static final ScheduledThreadPoolExecutor sc = new ScheduledThreadPoolExecutor(1);


    @Override
    public void onApplicationEvent(ApplicationReadyEvent event) {
        if (isBootstrapContext(event)) {
            return;
        }
        synchronized (INITIALIZED) {
            if (INITIALIZED.get()) {
                return;
            }
            sc.scheduleAtFixedRate(() -> {
                long pid = ProcessHandle.current().pid();
                try {
                    //读取 smaps_rollup
                    List<String> strings = FileUtils.readLines(new File("/proc/" + pid + "/smaps_rollup"));
                    log.info("MonitorMemoryRSS, smaps_rollup: {}", strings.stream().collect(Collectors.joining("\n")));
                    //读取 Native Memory Tracking 信息
                    Process process = Runtime.getRuntime().exec(new String[]{"jcmd", pid + """VM.native_memory"});
                    try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
                        log.info("MonitorMemoryRSS, native_memory: {}", reader.lines().collect(Collectors.joining("\n")));
                    }
                } catch (IOException e) {
                }

            }, 030, TimeUnit.SECONDS);
            INITIALIZED.set(true);
        }
    }

    static boolean isBootstrapContext(ApplicationReadyEvent applicationEvent) {
        return applicationEvent.getApplicationContext().getEnvironment().getPropertySources().contains(BOOTSTRAP_PROPERTY_SOURCE_NAME);
    }
}

同时,笔者还将这些输出抽象为 JFR 事件,效果是:

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

这个会在第二章详细分析

2. 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 的虚拟地址相同,那么操作系统如何区分呢?即操作系统如何将这些虚拟地址转换为物理内存。这就需要页表了,页表也是每个进程独立的,操作系统会在给进程映射物理内存用来保存用户数据的时候,将物理内存保存到进程的页表里面。然后,进程访问虚拟内存空间的时候,通过页表找到物理内存

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

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

  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 中是否有缓存,如果没有才会去页表查询。

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

  1. 取虚拟地址的 12 ~ 47 位作为 key,访问 TLB,定位到具体的用户数据物理内存页面。

  2. 使用最后的 0 ~ 11 位作为 offset,对应到用户数据物理内存页面的对应 offset。

TLB 一般很小,我们来看几个 CPU 中的 TLB 大小,以下图片来自于 https://www.bilibili.com/video/BV1Xx4y1j7Hu/?spm_id_from=333.999.0.0

我们这里不用关心 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

    //设置全局默认页大小,通过 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_NONEPROT_NONE 代表不会使用,即不能做任何操作,包括读和写。为啥要打击抄袭,稿主被抄袭太多所以断更很久。如果 JVM 使用这块内存,会发生 Segment Fault 异常。Reserve 的源码,对应的是:

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

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

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

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

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,那么我们可能分配的内存是这个样子的:

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

这样,只需要判断 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 的内存)与实际其他系统监控中体现的物理内存使用指标对不上的原因。

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 页大小

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

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

但是,要想使用这个特性,需要操作系统构建的时候开启 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,则代表系统也不支大页分配,大页分配也不启用。

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

首先,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 目录下的结构类似于:

> 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 方式大页分配)。这里只是验证下这些大页内存的分配方式是否可用,只有可用后面真正分配内存的时候才会采用那种可用的大页内存分配方式。

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

#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

//如果设置了 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 启动参数,同一个参数可以多次出现,但是只有最后一个会生效,例如:

java -XX:MaxHeapSize=8G -XX:MaxHeapSize=4G -XX:MaxHeapSize=8M -version

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

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 或者 XmxMaxHeapSize 也有自己预设的一个参考值。源码中这个预设参考值为 125MB 左右(96M*13/10)。但是一般最后不会以这个参考值为准,JVM 初始化的时候会有很复杂的计算计算出合适的值。比如你可以在你的电脑上执行下下面的命令,可以看到类似下面的输出:

>  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 的计算流程:

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

  • 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

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,也会经过下面的计算过程计算出来:

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

  • 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

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) \

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 的虚拟内存。

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

这就是压缩指针的原理,上面提到的相关 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:https://bugs.openjdk.java.net/browse/JDK-8241825 - Make compressed oops and compressed class pointers independent (x86_64, PPC, S390) 以及源码:

  • https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/cpu/x86/globalDefinitions_x86.hpp#define COMPRESSED_CLASS_POINTERS_DEPENDS_ON_COMPRESSED_OOPS EnableJVMCI 在 x86 CPU 上,UseCompressedClassPointers 是否依赖 UseCompressedOops 取决于是否启用了 JVMCI,默认使用的 JVM 发布版,EnableJVMCI 都是 false

  • https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/cpu/arm/globalDefinitions_arm.hpp#define COMPRESSED_CLASS_POINTERS_DEPENDS_ON_COMPRESSED_OOPS false 在 ARM CPU 上,UseCompressedClassPointers 不依赖 UseCompressedOops

  • https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/cpu/ppc/globalDefinitions_ppc.hpp#define COMPRESSED_CLASS_POINTERS_DEPENDS_ON_COMPRESSED_OOPS false 在 PPC CPU 上,UseCompressedClassPointers 不依赖 UseCompressedOops

  • https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/cpu/s390/globalDefinitions_s390.hpp#define COMPRESSED_CLASS_POINTERS_DEPENDS_ON_COMPRESSED_OOPS false 在 S390 CPU 上,UseCompressedClassPointers 不依赖 UseCompressedOops

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

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

进程可以申请的空间,是上图所示的原生堆空间。所以,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 释放物理内存:

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 开始。

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

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

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

总结下,上面我们说的那四种模式,对应 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 可能在访问每个引用变量进行成员字段或者方法的时候,都会做这样一个判断:

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:

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 信号的处理:

//这是在 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

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 环境,查看源码:https://github.com/openjdk/jdk/blob/jdk-21%2B3/src/hotspot/os_cpu/linux_x86/globals_linux_x86.hpp: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 依赖:

<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>

编写代码:

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) 之间的情况,使用下面的启动参数启动这个程序:

-Xmx32M -Xlog:coops*=debug

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

启动后,查看日志输出:

[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

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

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

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

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

从上图我们可以看到,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 的最大堆内存大小的值即可),日志输出是:

[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 看一下进程的具体虚拟内存的内容验证下目前的压缩对象指针的内容,前面的步骤与上一个例子一样,我们直接看最后的:

如上图所示,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,可以看到日志:

[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 启动程序,日志输出为:

[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 看一下进程的具体虚拟内存的内容验证下目前的压缩对象指针的内容,前面的步骤与上一个例子一样,我们直接看最后的:

如上图所示,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 启动程序,日志输出为:

[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 看一下进程的具体虚拟内存的内容验证下目前的压缩对象指针的内容,前面的步骤与上一个例子一样,我们直接看最后的:

如上图所示,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 解析结果一致。不要偷取他人的劳动成果

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

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,如果不够会报错,够得话,会计算出一个目标堆大小:

目标堆大小 = 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

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

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

    • Bug:https://bugs.openjdk.org/browse/JDK-8252221

    • Commit:https://github.com/openjdk/jdk/commit/9359ff03ae6b9e09e7defef148864f40e949b669

    • 从 Java 16 build 21 开始,ParallelGC 才实现并发 PreTouch:

  • G1GC

    • Bug:https://bugs.openjdk.org/browse/JDK-8157952

    • Commit:https://github.com/openjdk/jdk/commit/317f1aa044a8a71c52cfe733f1f4baf656c22c4c

    • Bug:https://bugs.openjdk.org/browse/JDK-8067469

    • Commit:https://github.com/openjdk/jdk/commit/f2e110fe7793b20a21f91e8ef7451814db2c8d73

    • 在 Java 9 build 45 之前,AlwaysPreTouch 对于 G1GC 不生效,这是一个 bug:

    • 从 Java 9 build 139 开始,G1GC 才实现并发 PreTouch:

  • ZGC

    • Bug:https://bugs.openjdk.org/browse/JDK-8234543

    • Commit:https://github.com/openjdk/jdk/commit/5e758d2368b58ceef5092e74d481b60867b5ab93

    • 从 Java 14 build 26 开始,ZGC 才实现并发 PreTouch:

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 会通过下面的流程读取内存限制:

可以看出,针对 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 会给这个软最大大小一个指导值,让软最大大小不要超过这个值。

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

微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer~

 

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

为你推荐

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

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

6
4