个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~ 另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。 今天又是干货满满的一天,这是全网最硬核 JVM 解析系列第四篇,往期精彩:
- 全网最硬核 TLAB 解析
- 全网最硬核 Java 随机数解析
- 全网最硬核 Java 新内存模型解析
本篇是关于 JVM 内存的详细分析。网上有很多关于 JVM 内存结构的分析以及图片,但是由于不是一手的资料亦或是人云亦云导致有很错误,造成了很多误解;并且,这里可能最容易混淆的是一边是 JVM Specification 的定义,一边是 Hotspot JVM 的实际实现,有时候人们一些部分说的是 JVM Specification,一部分说的是 Hotspot 实现,给人一种割裂感与误解。本篇主要从 Hotspot 实现出发,以 Linux x86 环境为主,紧密贴合 JVM 源码并且辅以各种 JVM 工具验证帮助大家理解 JVM 内存的结构。但是,本篇仅限于对于这些内存的用途,使用限制,相关参数的分析,有些地方可能比较深入,有些地方可能需要结合本身用这块内存涉及的 JVM 模块去说,会放在另一系列文章详细描述。最后,洗稿抄袭狗不得 house
本篇全篇目录(以及涉及的 JVM 参数):
- 从 Native Memory Tracking 说起(全网最硬核 JVM 内存解析 - 1.从 Native Memory Tracking 说起开始)
- Native Memory Tracking 的开启
- Native Memory Tracking 的使用(涉及 JVM 参数:
NativeMemoryTracking
) - Native Memory Tracking 的 summary 信息每部分含义
- Native Memory Tracking 的 summary 信息的持续监控
- 为何 Native Memory Tracking 中申请的内存分为 reserved 和 committed
- JVM 内存申请与使用流程(全网最硬核 JVM 内存解析 - 2.JVM 内存申请与使用流程开始)
- Linux 下内存管理模型简述
- JVM commit 的内存与实际占用内存的差异
- JVM commit 的内存与实际占用内存的差异
- 大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
- Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
- Linux 大页分配方式 - Transparent Huge Pages (THP)
- JVM 大页分配相关参数与机制(涉及 JVM 参数:
UseLargePages
,UseHugeTLBFS
,UseSHM
,UseTransparentHugePages
,LargePageSizeInBytes
)
- Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
- 通用初始化与扩展流程
- 直接指定三个指标的方式(涉及 JVM 参数:
MaxHeapSize
,MinHeapSize
,InitialHeapSize
,Xmx
,Xms
) - 不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
- 压缩对象指针相关机制(涉及 JVM 参数:
UseCompressedOops
)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)- 压缩对象指针存在的意义(涉及 JVM 参数:
ObjectAlignmentInBytes
) - 压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:
UseCompressedOops
,UseCompressedClassPointers
) - 压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:
ObjectAlignmentInBytes
,HeapBaseMinAddress
)
- 压缩对象指针存在的意义(涉及 JVM 参数:
- 为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:
HeapBaseMinAddress
) - 结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:
HeapBaseMinAddress
,ObjectAlignmentInBytes
,MinHeapSize
,MaxHeapSize
,InitialHeapSize
) - 使用 jol jhsdb JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
- 验证
32-bit
压缩指针模式 - 验证
Zero based
压缩指针模式 - 验证
Non-zero disjoint
压缩指针模式 - 验证
Non-zero based
压缩指针模式
- 验证
- 堆大小的动态伸缩(涉及 JVM 参数:
MinHeapFreeRatio
,MaxHeapFreeRatio
,MinHeapDeltaBytes
)(全网最硬核 JVM 内存解析 - 6.其他 Java 堆内存相关的特殊机制开始) - 适用于长期运行并且尽量将所有可用内存被堆使用的 JVM 参数 AggressiveHeap
- JVM 参数 AlwaysPreTouch 的作用
- JVM 参数 UseContainerSupport - JVM 如何感知到容器内存限制
- JVM 参数 SoftMaxHeapSize - 用于平滑迁移更耗内存的 GC 使用
- JVM 元空间设计(全网最硬核 JVM 内存解析 - 7.元空间存储的元数据开始)
- 什么是元数据,为什么需要元数据
- 什么时候用到元空间,元空间保存什么
- 什么时候用到元空间,以及释放时机
- 元空间保存什么
- 元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
- 元空间的整体配置以及相关参数(涉及 JVM 参数:
MetaspaceSize
,MaxMetaspaceSize
,MinMetaspaceExpansion
,MaxMetaspaceExpansion
,MaxMetaspaceFreeRatio
,MinMetaspaceFreeRatio
,UseCompressedClassPointers
,CompressedClassSpaceSize
,CompressedClassSpaceBaseAddress
,MetaspaceReclaimPolicy
) - 元空间上下文
MetaspaceContext
- 虚拟内存空间节点列表
VirtualSpaceList
- 虚拟内存空间节点
VirtualSpaceNode
与CompressedClassSpaceSize
MetaChunk
ChunkHeaderPool
池化MetaChunk
对象ChunkManager
管理空闲的MetaChunk
- 类加载的入口
SystemDictionary
与保留所有ClassLoaderData
的ClassLoaderDataGraph
- 每个类加载器私有的
ClassLoaderData
以及ClassLoaderMetaspace
- 管理正在使用的
MetaChunk
的MetaspaceArena
- 元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
- 类加载器到
MetaSpaceArena
的流程 - 从
MetaChunkArena
普通分配 - 整体流程 - 从
MetaChunkArena
普通分配 -FreeBlocks
回收老的current chunk
与用于后续分配的流程 - 从
MetaChunkArena
普通分配 - 尝试从FreeBlocks
分配 - 从
MetaChunkArena
普通分配 - 尝试扩容current chunk
- 从
MetaChunkArena
普通分配 - 从ChunkManager
分配新的MetaChunk
- 从
MetaChunkArena
普通分配 - 从ChunkManager
分配新的MetaChunk
- 从VirtualSpaceList
申请新的RootMetaChunk
- 从
MetaChunkArena
普通分配 - 从ChunkManager
分配新的MetaChunk
- 将RootMetaChunk
切割成为需要的MetaChunk
MetaChunk
回收 - 不同情况下,MetaChunk
如何放入FreeChunkListVector
- 类加载器到
ClassLoaderData
回收
- 元空间的整体配置以及相关参数(涉及 JVM 参数:
- 元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
- 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
- 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
- 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
- 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
- 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
- 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
- 然后类加载器 1 被 GC 回收掉
- 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
- 元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
CommitLimiter
的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC- 每次 GC 之后,也会尝试重新计算
_capacity_until_GC
jcmd VM.metaspace
元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)jcmd <pid> VM.metaspace
元空间说明- 元空间相关 JVM 日志
- 元空间 JFR 事件详解
jdk.MetaspaceSummary
元空间定时统计事件jdk.MetaspaceAllocationFailure
元空间分配失败事件jdk.MetaspaceOOM
元空间 OOM 事件jdk.MetaspaceGCThreshold
元空间 GC 阈值变化事件jdk.MetaspaceChunkFreeListSummary
元空间 Chunk FreeList 统计事件
- JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
- JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:
ThreadStackSize
,VMThreadStackSize
,CompilerThreadStackSize
,StackYellowPages
,StackRedPages
,StackShadowPages
,StackReservedPages
,RestrictReservedStack
) - Java 线程栈内存的结构
- Java 线程如何抛出的 StackOverflowError
- 解释执行与编译执行时候的判断(x86为例)
- 一个 Java 线程 Xss 最小能指定多大
- JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:
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=
指定异步日志缓冲大小,这个大小默认是2097152
即2MB
- JFR 采样线程:全局唯一的线程,负责采集 JFR 中的两种采样事件,一个是
jdk.ExecutionSample
,另一个是jdk.NativeMethodSample
,都是采样当前正在RUNNING
的线程,如果线程在执行 Java 代码,就属于jdk.ExecutionSample
,如果执行 native 方法,就属于jdk.NativeMethodSample
。
相关的参数有:
ThreadStackSize
:每个 Java 线程的栈大小,这个参数通过-Xss
也可以指定,各种平台的默认值为:- linux 平台,x86 CPU,默认为 1024 KB,参考:
https://github.com/openjdk/jdk/blob/jdk-21+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
- linux 平台,x86 CPU,默认为 1024 KB,参考:
VMThreadStackSize
:VM 线程,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
- linux 平台,x86 CPU,默认为 1024 KB,参考:
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
- linux 平台,x86 CPU,默认为 1024 KB,参考:
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
- linux 平台,x86 CPU,默认为 2 页,参考:
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
- linux 平台,x86 CPU,默认为 1 页,参考:
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
- linux 平台,x86 CPU,默认为 20 页,参考:
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
- linux 平台,x86 CPU,默认为 1 页,参考:
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
的限制,这样你就可以在自己的代码中使用这个注解了。
- 黄色区域(Yellow Zone):大小由前面提到的
- 影子区域(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
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
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和洗稿是恶意抄袭他人劳动成果的行为,是对劳动价值的漠视和践踏! ):
- 首先判断要分配的栈帧大小,是否大于一页。
- 如果小于等于一页,不用检查,直接结束。因为如果小于一页,那么栈帧的元素个数一定小于一页,栈增长不会导致跳过保护区域,如果达到保护区域就会触发
SIGSEGV
抛出StackOverflowError
。因为每个保护区域如前面源代码所示,都是对虚拟机页大小进行对齐的,因此至少一页。 - 如果大于一页,则需要检查。检查当前已经使用的空间,加上栈帧占用的空间,加上保护区域与阴影区域的最大值,占用空间是否大于栈空间限制。如果大于,则抛出
StackOverflowError
异常。为什么是保护区域与阴影区域的最大值?阴影区域其实是我们假设的最大帧大小,最后至少要有这么多空间才一定不会导致溢出栈顶污染其他内存(当然,如之前所述,如果你自己实现一个 Native 调用并且栈帧很大,则需要修改阴影区域大小)。如果本身保护区域就比阴影区域大,那么就用保护区域的大小,就也能保证这一点。
可以看出,编译执行,虽然做了一定的优化,但是还是很复杂,就算大部分栈帧应该都小于一页,但是刚开始的判断指令还是有不小的消耗。我们看看 JIT 编译后的代码,还是以 x86 cpu 为例:
https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/share/asm/assembler.cpp
void AbstractAssembler::generate_stack_overflow_check(int frame_size_in_bytes) {
//读取虚拟机页大小,第二章我们分析过
const int page_size = os::vm_page_size();
//读取影子区大小
int bang_end = (int)StackOverflow::stack_shadow_zone_size();
//如果栈帧大小大于一页,那么需要将 bang_end 加上栈帧大小,之后检查每一页是否处于保护区域
const int bang_end_safe = bang_end;
if (frame_size_in_bytes > page_size) {
bang_end = frame_size_in_bytes;
}
//检查每一页是否处于保护区域
int bang_offset = bang_end_safe;
while (bang_offset <= bang_end) {
bang_stack_with_offset(bang_offset);
bang_offset = page_size;
}
}
https://github.com/openjdk/jdk/blob/jdk-21+19/src/hotspot/cpu/x86/macroAssembler_x86.cpp
//检查是否处于保留区域,其实就是将 rsp - offset 的地址上的值写入 rax 上,
//如果 rsp - offset 保护区域,那么就会触发 SIGSEGV
void bang_stack_with_offset(int offset) {
movl(Address(rsp, (-offset)), rax);
}
编译后执行的指令就简单多了:
- 如果栈帧大小小于一页:只需要考虑 Native 调用是否会导致
StackOverflow
即可。检查当前占用位置加上影子区域大小,之后判断是否会进入保护区域即可,不用考虑当前方法栈帧占用大小,因为肯定小于一页。验证是否进入保护区域也和之前讨论过的NullPointeException
的处理是类似的,就是将 rsp - offset 的地址上的值写入 rax 上,如果 rsp - offset 处于保护区域,那么就会触发SIGSEGV
。 - 如果栈帧大小大于一页:那么需要将当前占用位置,加上栈帧大小,加上影子区域大小,之后从当前栈帧按页检查,是否处于保护区域。因为大于一页的话,直接验证最后的位置可能会溢出到其他东西占用的内存(比如其他线程占用的内存)。
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
size_t os::_compiler_thread_min_stack_allowed = 48 * K;
size_t os::_java_thread_min_stack_allowed = 40 * K;
size_t os::_vm_internal_thread_min_stack_allowed = 64 * K;
所以,对于 Java 线程,至少需要 40 96 = 136K
的空间。我们试一下:
bash-4.2$ java -Xss1k
The Java thread stack size specified is too small. Specify at least 136k
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.