全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计

2023-05-01 16:24:19 浏览数 (1)

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 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. 虚拟内存空间节点 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 最小能指定多大

5. JVM 线程内存设计(重点研究 Java 线程)

Java 19 中 Loom 终于 Preview 了,虚拟线程(VirtualThread)是我期待已久的特性,但是这里我们说的线程内存,并不是这种 虚拟线程,还是老的线程。其实新的虚拟线程,在线程内存结构上并没有啥变化,只是存储位置的变化,实际的负载线程(CarrierThread)还是老的线程。

同时,JVM 线程占用的内存分为两个部分:分别是线程栈占用内存,以及线程本身数据结构占用的内存。

5.1. JVM 中有哪几种线程,对应线程栈相关的参数是什么

JVM 中有如下几类线程:

  • VM 线程:全局唯一的线程,负责执行 VM Operations,例如 JVM 的初始化,其中的操作大部分需要在安全点执行,即 Stop the world 的时候执行。所有的操作请参考:https://github.com/openjdk/jdk/blob/jdk-21 17/src/hotspot/share/runtime/vmOperation.hpp
  • GC 线程:负责做 GC 操作的线程
  • Java 线程:包括 Java 应用线程(java.lang.Thread),以及 CodeCacheSweeper 线程, JVMTI 的 Agent 与 Service 线程其实也是 JAva 线程。
  • 编译器线程: JIT 编译器的线程,有 C1 和 C2 线程(xi稿滚去shi)
  • 定时任务时钟线程:全局唯一的线程,即 Watcher 线程,负责计时并执行定时任务,目前 JVM 中包括的定时任务可以通过查看继承 PeriodicTask 的类看到,其中两个比较重要的任务是:
    • StatSamplerTask:定时更新采集的 JVM Performance Data(PerfData)数据, 包括 GC、类加载、运行采集等等数据,这个任务多久执行一次是通过 -XX:PerfDataSamplingInterval 参数控制的,默认为 50 毫秒(参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/share/runtime/globals.hpp)。这些数据一般通过 jstat 读取,或者通过 JMX 读取。
    • VMOperationTimeoutTask:由于 VM 线程是单线程,执行 VM Operations,单个任务执行不能太久,否则会阻塞其他 VM Operations。所以每次执行 VM Operations 的时候,这个定时任务都会检查当前执行了多久,如果超过 -XX:AbortVMOnVMOperationTimeoutDelay 就会报警。AbortVMOnVMOperationTimeoutDelay 默认是 1000ms(参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/share/runtime/globals.hpp)。
  • 异步日志线程:全局唯一的线程, Java 17 引入的异步 JVM 日志特性,防止因为 JVM 日志输出阻塞影响全局安全点事件导致全局暂停过长,或者 JVM 日志输出导致线程阻塞,负责异步写日志,通过 -Xlog:async 启用 JVM 异步日志,通过 -XX:AsyncLogBufferSize= 指定异步日志缓冲大小,这个大小默认是 20971522MB
  • JFR 采样线程:全局唯一的线程,负责采集 JFR 中的两种采样事件,一个是 jdk.ExecutionSample,另一个是 jdk.NativeMethodSample,都是采样当前正在 RUNNING 的线程,如果线程在执行 Java 代码,就属于 jdk.ExecutionSample,如果执行 native 方法,就属于 jdk.NativeMethodSample

相关的参数有:

  • ThreadStackSize:每个 Java 线程的栈大小,这个参数通过 -Xss 也可以指定,各种平台的默认值为:
    • linux 平台,x86 CPU,默认为 1024 KB,参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/os_cpu/linux_x86/globals_linux_x86.hpp
    • linux 平台,aarch CPU,默认为 2048 KB,参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/os_cpu/linux_aarch64/globals_linux_aarch64.hpp
    • windows 平台,x86 CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/os_cpu/windows_x86/globals_windows_x86.hpp
    • windows 平台,aarch CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/os_cpu/windows_aarch64/globals_windows_aarch64.hpp
  • VMThreadStackSizeVM 线程GC 线程定时任务时钟线程异步日志线程JFR 采样线程的栈大小,各种平台的默认值为:
    • linux 平台,x86 CPU,默认为 1024 KB,参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/os_cpu/linux_x86/globals_linux_x86.hpp
    • linux 平台,aarch CPU,默认为 2048 KB,参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/os_cpu/linux_aarch64/globals_linux_aarch64.hpp
    • windows 平台,x86 CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/os_cpu/windows_x86/globals_windows_x86.hpp
    • windows 平台,aarch CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/os_cpu/windows_aarch64/globals_windows_aarch64.hpp
  • CompilerThreadStackSize编译器线程的栈大小,各种平台的默认值为:
    • linux 平台,x86 CPU,默认为 1024 KB,参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/os_cpu/linux_x86/globals_linux_x86.hpp
    • linux 平台,aarch CPU,默认为 2048 KB,参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/os_cpu/linux_aarch64/globals_linux_aarch64.hpp
    • windows 平台,x86 CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/os_cpu/windows_x86/globals_windows_x86.hpp
    • windows 平台,aarch CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/os_cpu/windows_aarch64/globals_windows_aarch64.hpp
  • StackYellowPages:后面会提到并分析的黄色区域的页大小
    • linux 平台,x86 CPU,默认为 2 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/x86/globals_x86.hpp
    • linux 平台,aarch CPU,默认为 2 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
    • windows 平台,x86 CPU,默认为 3 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/x86/globals_x86.hpp
    • windows 平台,aarch CPU,默认为 2 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
  • StackRedPages:后面会提到并分析的红色区域的页大小
    • linux 平台,x86 CPU,默认为 1 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/x86/globals_x86.hpp
    • linux 平台,aarch CPU,默认为 1 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
    • windows 平台,x86 CPU,默认为 1 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/x86/globals_x86.hpp
    • windows 平台,aarch CPU,默认为 1 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
  • StackShadowPages:后面会提到并分析的影子区域的页大小
    • linux 平台,x86 CPU,默认为 20 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/x86/globals_x86.hpp
    • linux 平台,aarch CPU,默认为 20 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
    • windows 平台,x86 CPU,默认为 8 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/x86/globals_x86.hpp
    • windows 平台,aarch CPU,默认为 20 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
  • StackReservedPages:后面会提到并分析的保留区域的页大小
    • linux 平台,x86 CPU,默认为 1 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/x86/globals_x86.hpp
    • linux 平台,aarch CPU,默认为 1 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
    • windows 平台,x86 CPU,默认为 0 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/x86/globals_x86.hpp
    • windows 平台,aarch CPU,默认为 1 页,参考:https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
  • RestrictReservedStack:默认为 true,与保留区域相关,保留区域会保护临界区代码(例如 ReentrantLock)在抛出 StackOverflow 之前先把临界区代码执行完再结束,防止临界区代码执行到一半就抛出 StackOverflow 导致状态不一致导致这个锁之后再也用不了了。标记临界区代码的注解是 @jdk.internal.vm.annotation.ReservedStackAccess。在这个配置为 true 的时候,这个注解默认只能 jdk 内部代码使用,如果你有类似于 ReentrantLock 这种带有临界区的代码也想保护起来,可以设置 -XX:-RestrictReservedStack,关闭对于 @jdk.internal.vm.annotation.ReservedStackAccess 的限制,这样你就可以在自己的代码中使用这个注解了。

我们接下来重点分析 Java 线程栈。

5.2. Java 线程栈内存的结构

熟悉编译器的人应该知道激活记录(Activation Record)这个概念,它是一种数据结构,其中包含支持一次函数调用所需的所有信息。它包含该函数的所有局部变量,以及指向另一个激活记录的引用(或指针),其实你可以简单理解为,每多一次方法调用就多一个激活记录。而线程栈帧(Stack Frame),就是激活记录的实际实现。每在代码中多一次方法调用就多一个栈帧,但是这个说法并不严谨,比如,JIT 可能会内联一些方法,可能会跳过某些方法的调用等等。Java 线程的栈帧有哪几种呢,其实根据 Java 线程执行的方法有 Java 方法以及原生方法(Native)就能推测出有两种:

  • Java 虚拟机栈帧(Java Virtual Machine Stack Frame):用于保存 Java 方法的执行状态,包括局部变量表、操作数栈、方法出口等信息。
  • Native 方法栈帧(Native Method Stack Frame):用于保存 Native 方法的执行状态,包括局部变量表、操作数栈、方法出口等信息。

在最早的时候,Linux 还没有线程的概念,Java 自己做了一种叫做 Green Thread 的东西即用户态线程(与现在的虚拟线程设计差异很大,不是一个概念了),但是调度有诸多问题,所以在 Linux 有线程之后,Java 也舍弃了 Green Thread。Java 线程其实底层就是通过操作系统线程实现,是一一对应的关系。不过现在,虚拟线程也快要 release 了,但是这个并不是今天的重点。并且,在最早的时候,Java 线程栈与 Native 线程栈也是分开的,虽然可能都是一个线程执行的。后来,发现这样做对于 JIT 优化,以及线程栈大小限制,以及实现高效的 StackOverflow 检查都不利,所以就把 Java 线程栈与 Native 线程栈合并了,这样就只有一个线程栈了。

JVM 中对于线程栈可以使用的空间是限制死的。对于 Java 线程来说,这个限制是由 -Xss 或者 -XX:ThreadStackSize 来控制的-Xss 或者 -XX:ThreadStackSize 基本等价, 一般来说,-Xss 或者 -XX:ThreadStackSize 是用来设置每个线程的栈大小的,但是更严谨的说法是,它是设置每个线程栈最大使用的内存大小,并且实际可用的大小由于保护页的存在还要小于这个值,并且设置这个值不能小于保护页需要的大小,否则没有意义。根据前面对于 JVM 其他区域的分析我们可以推测出,对于每个线程,都会先 Reserve-Xss 或者 -XX:ThreadStackSize 大小的内存,之后随着线程占用内存升高而不断 Commit 内存。

同时我们还知道,对于一段 Java 代码,分为编译器执行,C1 执行,C2 执行三种情况,因此,一个 Java 线程的栈内存结构可能如下图所示:

这个图片我们展示了一个比较极端的情况,线程先解释执行方法 1,之后调用并解释执行方法 2,然后调用一个可能比较热点的方法 3,方法 3 已经被 C1 优化编译,这里执行的是编译后的代码,之后调用可能更热点的方法 4,方法 4 已经被 C2 优化编译,这里执行的是编译后的代码。最后方法 4 还需要调用一个 native 方法 5。

5.3. Java 线程如何抛出的 StackOverflowError

JVM 线程内存还有一些特殊的内存区域,结构如下:

  • 保护区域(Guard Zone),保护区的内存没有映射物理内存,访问的话会像前面第三章提到的 NullPointerException 优化方式类似,即抛出 SIGSEGV 被 JVM 捕获,再抛出 StackOverflowError。保护区包括以下三种:
    • 黄色区域(Yellow Zone):大小由前面提到的 -XX:StackYellowPages 参数决定。如果栈扩展到了黄色区域,则发生 SIGSEGV,并且信号处理程序抛出 StackOverflowError 并继续执行当前线程。同时,这时候黄色页面会被映射分配内存,以提供一些额外的栈空间给异常抛出的代码使用,抛出异常结束后,黄色页面会重新去掉映射,变成保护区。
    • 红色区域(Red Zone):大小由前面提到的 -XX:StackRedPages 参数决定。正常的代码只会可能到黄色区域,只有 JVM 出一些 bug 的时候会到红色区域,这个相当于最后一层保证。保留这个区域是为了出这种 bug 的时候,能有空间可以将错误信息写入 hs_err_pid.log 文件用于定位。
    • 保留区域(Reserved Zone):大小由前面提到的 -XX:StackReservedPages 参数决定。在 Java 9 引入(JEP 270: Reserved Stack Areas for Critical Sections)(洗稿狗的区域是细狗区),主要是为了解决 JDK 内部的临界区代码(例如ReentrantLock)导致 StackOverflowError 的时候保证内部数据结构不会处于不一致的状态导致锁无法释放或者被获取。如果没有这个区域,在 ReentrantLock.lock() 方法内部调用某个内部方法的时候可能会进入黄色区域,导致 StackOverflowError,这时候可能 ReentrantLock 内部的一些数据可能已经修改,抛出异常导致这些数据无法回滚让锁处于当初设计的时候没有设计的不一致状态。为了避免这个情况,引入保留区域。在执行临界区方法的时候(被 @jdk.internal.vm.annotation.ReservedStackAccess 注解修饰的方法),如果进入保留区域,那么保留区域会被映射内存,用于执行完临界区方法,执行完临界区方法之后,再抛出 StackOverflowError,并解除保留区域的映射。另外,前面我们提到过,@jdk.internal.vm.annotation.ReservedStackAccess 这个注解默认只能 jdk 内部代码使用,如果你有类似于 ReentrantLock 这种带有临界区的代码也想保护起来,可以设置 -XX:-RestrictReservedStack,关闭对于 @jdk.internal.vm.annotation.ReservedStackAccess 的限制,这样你就可以在自己的代码中使用这个注解了。
  • 影子区域(Shadow Zone):这个区域的大小由前面提到的 -XX:StackShadowPages 参数决定。影子区域只是抽象概念,跟在当前栈占用的顶部栈帧后面,随着顶部栈帧变化而变化。这个区域用于保证 Native 调用不会导致 StackOverflowError在后面的分析我们会看到,每次调用方法前需要估算方法栈帧的占用大小,但是对于 Native 调用我们无法估算,所以我们就假设 Native 大小最大不会超过影子区域大小,在发生 Native 调用前,会查看当前栈帧位置加上影子区域大小是否会达到保留区域,如果达到了保留区域,那么会抛出 StackOverflowError,如果没有达到保留区域,那么会继续执行。这里我们可以看出,JVM 假设 Native 调用占用空间不会超过影子区域大小,JDK 中自带的 native 调用也确实是这样。如果你自己实现了 Native 方法并且会占用大量栈内存,那么你需要调整 StackShadowPages

我们看下源码中如何体现的这些区域,参考源码:https://github.com/openjdk/jdk/blob/jdk-21+18/src/hotspot/share/runtime/stackOverflow.hpp

代码语言:javascript复制
size_t StackOverflow::_stack_red_zone_size = 0;
size_t StackOverflow::_stack_yellow_zone_size = 0;
size_t StackOverflow::_stack_reserved_zone_size = 0;
size_t StackOverflow::_stack_shadow_zone_size = 0;

void StackOverflow::initialize_stack_zone_sizes() {
  //读取虚拟机页大小,第二章我们分析过
  size_t page_size = os::vm_page_size();
  //目前各个平台最小页大小基本都是 4K
  size_t unit = 4*K;
  //使用 StackRedPages 乘以 4K 然后对虚拟机页大小进行对齐作为红色区域大小
  assert(_stack_red_zone_size == 0, "This should be called only once.");
  _stack_red_zone_size = align_up(StackRedPages * unit, page_size);
  //使用 StackYellowPages 乘以 4K 然后对虚拟机页大小进行对齐作为黄色区域大小
  assert(_stack_yellow_zone_size == 0, "This should be called only once.");
  _stack_yellow_zone_size = align_up(StackYellowPages * unit, page_size);
  //使用 StackReservedPages 乘以 4K 然后对虚拟机页大小进行对齐作为保留区域大小
  assert(_stack_reserved_zone_size == 0, "This should be called only once.");
  _stack_reserved_zone_size = align_up(StackReservedPages * unit, page_size);
  //使用 StackShadowPages 乘以 4K 然后对虚拟机页大小进行对齐作为保留区域大小
  assert(_stack_shadow_zone_size == 0, "This should be called only once.");
  _stack_shadow_zone_size = align_up(StackShadowPages * unit, page_size);
}

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

我们继续针对 Java 线程进行讨论。在前面我们已经知道,Java 线程栈的大小是有限制的,如果线程栈使用的内存超过了限制,那么就会抛出 StackOverflowError。但是,JVM 如何知道什么时候该抛出呢?

首先,对于解释执行,一般没有任何优化,就是在调用方法前检查。不同的环境下的实现会有些差别,我们以 x86 cpu 为例:

https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/x86/templateInterpreterGenerator_x86.cpp

代码语言:javascript复制
void TemplateInterpreterGenerator::generate_stack_overflow_check(void) {
  //计算栈帧的一些元数据存储的消耗
  const int entry_size = frame::interpreter_frame_monitor_size() * wordSize;
  const int overhead_size =
    -(frame::interpreter_frame_initial_sp_offset * wordSize)   entry_size;
  //读取虚拟机页大小,第二章我们分析过
  const int page_size = os::vm_page_size();
  //比较当前要调用的方法的元素个数,判断与除去元数据以外一页能容纳的元素个数谁大谁小
  Label after_frame_check;
  __ cmpl(rdx, (page_size - overhead_size) / Interpreter::stackElementSize);
  __ jcc(Assembler::belowEqual, after_frame_check);
  //大于的才会进行后续的判断,因为小于一页的话,绝对可以被黄色区域限制住,因为黄色区域要与页大小对齐,因此至少一页
  //小于一页的栈帧不会导致跳过黄色区域,只有大于的须有后续仔细判断

  Label after_frame_check_pop;
  //读取线程的 stack_overflow_limit_offset
  //_stack_overflow_limit = stack_end()   MAX2(stack_guard_zone_size(), stack_shadow_zone_size());
  //即栈尾 加上 保护区域 或者 阴影区域 的最大值,即有效栈尾地址
  //其实就是当前线程栈容量顶部减去 保护区域 或者 阴影区域 的最大值的地址,即当前线程栈只能增长到这个地址
  const Address stack_limit(thread, JavaThread::stack_overflow_limit_offset());
  //将前面计算的栈帧元素个数大小保存在 rax
  __ mov(rax, rdx);
  //将栈帧的元素个数转换为字节大小,然后加上栈帧的元数据消耗
  __ shlptr(rax, Interpreter::logStackElementSize); 
  __ addptr(rax, overhead_size);

  //加上前面计算的有效栈尾地址
  __ addptr(rax, stack_limit);

  //与当前栈顶地址比较,如果当前栈顶地址大于 rax 当前值,证明没有溢出
  __ cmpptr(rsp, rax);
  __ jcc(Assembler::above, after_frame_check_pop);

  //否则抛出 StackOverflowError 异常
  __ jump(ExternalAddress(StubRoutines::throw_StackOverflowError_entry()));
  __ bind(after_frame_check_pop);
  __ bind(after_frame_check);
}

代码的步骤大概是(plagiarism和洗稿是恶意抄袭他人劳动成果的行为,是对劳动价值的漠视和践踏! ):

  1. 首先判断要分配的栈帧大小,是否大于一页。
  2. 如果小于等于一页,不用检查,直接结束。因为如果小于一页,那么栈帧的元素个数一定小于一页,栈增长不会导致跳过保护区域,如果达到保护区域就会触发 SIGSEGV 抛出 StackOverflowError。因为每个保护区域如前面源代码所示,都是对虚拟机页大小进行对齐的,因此至少一页。
  3. 如果大于一页,则需要检查。检查当前已经使用的空间,加上栈帧占用的空间,加上保护区域与阴影区域的最大值,占用空间是否大于栈空间限制。如果大于,则抛出 StackOverflowError 异常。为什么是保护区域与阴影区域的最大值?阴影区域其实是我们假设的最大帧大小,最后至少要有这么多空间才一定不会导致溢出栈顶污染其他内存(当然,如之前所述,如果你自己实现一个 Native 调用并且栈帧很大,则需要修改阴影区域大小)。如果本身保护区域就比阴影区域大,那么就用保护区域的大小,就也能保证这一点。

可以看出,编译执行,虽然做了一定的优化,但是还是很复杂,就算大部分栈帧应该都小于一页,但是刚开始的判断指令还是有不小的消耗。我们看看 JIT 编译后的代码,还是以 x86 cpu 为例:

https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/share/asm/assembler.cpp

代码语言:javascript复制
void AbstractAssembler::generate_stack_overflow_check(int frame_size_in_bytes) {
  //读取虚拟机页大小,第二章我们分析过
  const int page_size = os::vm_page_size();
  //读取影子区大小
  int bang_end = (int)StackOverflow::stack_shadow_zone_size();
  
  //如果栈帧大小大于一页,那么需要将 bang_end 加上栈帧大小,之后检查每一页是否处于保护区域
  const int bang_end_safe = bang_end;
  if (frame_size_in_bytes > page_size) {
    bang_end  = frame_size_in_bytes;
  }

  //检查每一页是否处于保护区域
  int bang_offset = bang_end_safe;
  while (bang_offset <= bang_end) {
    bang_stack_with_offset(bang_offset);
    bang_offset  = page_size;
  }
}

https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/x86/macroAssembler_x86.cpp

代码语言:javascript复制
//检查是否处于保留区域,其实就是将 rsp - offset 的地址上的值写入 rax 上,
//如果 rsp - offset 保护区域,那么就会触发 SIGSEGV
void bang_stack_with_offset(int offset) {
    movl(Address(rsp, (-offset)), rax);
}

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

  1. 如果栈帧大小小于一页:只需要考虑 Native 调用是否会导致 StackOverflow 即可。检查当前占用位置加上影子区域大小,之后判断是否会进入保护区域即可,不用考虑当前方法栈帧占用大小,因为肯定小于一页。验证是否进入保护区域也和之前讨论过的 NullPointeException 的处理是类似的,就是将 rsp - offset 的地址上的值写入 rax 上,如果 rsp - offset 处于保护区域,那么就会触发 SIGSEGV
  2. 如果栈帧大小大于一页:那么需要将当前占用位置,加上栈帧大小,加上影子区域大小,之后从当前栈帧按页检查,是否处于保护区域。因为大于一页的话,直接验证最后的位置可能会溢出到其他东西占用的内存(比如其他线程占用的内存)。

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

这个和平台是相关的,我们以 linux x86 为例子,假设没有大页分配,一页就是 4K,一个线程至少要保留如下的空间:

  • 保护区域:
    • 黄色区域:默认 2 页
    • 红色区域:默认 1 页
    • 保留区域:默认 1 页
  • 影子区域:默认 20 页

这些加在一起是 24 页,也就是 96K。

同时,在 JVM 代码中也限制了,除了这些空间,每种线程的最小大小:

https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/os_cpu/linux_x86/os_linux_x86.cpp

代码语言:javascript复制
size_t os::_compiler_thread_min_stack_allowed = 48 * K;
size_t os::_java_thread_min_stack_allowed = 40 * K;
size_t os::_vm_internal_thread_min_stack_allowed = 64 * K;

所以,对于 Java 线程,至少需要 40 96 = 136K 的空间。我们试一下:

代码语言:javascript复制
bash-4.2$ java -Xss1k
The Java thread stack size specified is too small. Specify at least 136k
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.

0 人点赞