个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 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 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
Linux 大页分配方式 - Transparent Huge Pages (THP)
JVM 大页分配相关参数与机制(涉及 JVM 参数:UseLargePages
,UseHugeTLBFS
,UseSHM
,UseTransparentHugePages
,LargePageSizeInBytes
)
JVM commit 的内存与实际占用内存的差异
Linux 下内存管理模型简述
JVM commit 的内存与实际占用内存的差异
大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
验证 32-bit
压缩指针模式
验证 Zero based
压缩指针模式
验证 Non-zero disjoint
压缩指针模式
验证 Non-zero based
压缩指针模式
压缩对象指针存在的意义(涉及 JVM 参数:ObjectAlignmentInBytes
)
压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:UseCompressedOops
,UseCompressedClassPointers
)
压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:ObjectAlignmentInBytes
,HeapBaseMinAddress
)
通用初始化与扩展流程
直接指定三个指标的方式(涉及 JVM 参数:MaxHeapSize
,MinHeapSize
,InitialHeapSize
,Xmx
,Xms
)
不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
压缩对象指针相关机制(涉及 JVM 参数:UseCompressedOops
)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)
为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:HeapBaseMinAddress
)
结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:HeapBaseMinAddress
,ObjectAlignmentInBytes
,MinHeapSize
,MaxHeapSize
,InitialHeapSize
)
使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
堆大小的动态伸缩(涉及 JVM 参数:MinHeapFreeRatio
,MaxHeapFreeRatio
,MinHeapDeltaBytes
)(全网最硬核 JVM 内存解析 - 6.其他 Java 堆内存相关的特殊机制开始)
适用于长期运行并且尽量将所有可用内存被堆使用的 JVM 参数 AggressiveHeap
JVM 参数 AlwaysPreTouch 的作用
JVM 参数 UseContainerSupport - JVM 如何感知到容器内存限制
JVM 参数 SoftMaxHeapSize - 用于平滑迁移更耗内存的 GC 使用
JVM 元空间设计(全网最硬核 JVM 内存解析 - 7.元空间存储的元数据开始)
jcmd <pid> VM.metaspace
元空间说明
元空间相关 JVM 日志
元空间 JFR 事件详解
jdk.MetaspaceSummary
元空间定时统计事件
jdk.MetaspaceAllocationFailure
元空间分配失败事件
jdk.MetaspaceOOM
元空间 OOM 事件
jdk.MetaspaceGCThreshold
元空间 GC 阈值变化事件
jdk.MetaspaceChunkFreeListSummary
元空间 Chunk FreeList 统计事件
CommitLimiter
的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC
每次 GC 之后,也会尝试重新计算 _capacity_until_GC
首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
然后类加载器 1 被 GC 回收掉
然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
元空间的整体配置以及相关参数(涉及 JVM 参数:MetaspaceSize
,MaxMetaspaceSize
,MinMetaspaceExpansion
,MaxMetaspaceExpansion
,MaxMetaspaceFreeRatio
,MinMetaspaceFreeRatio
,UseCompressedClassPointers
,CompressedClassSpaceSize
,CompressedClassSpaceBaseAddress
,MetaspaceReclaimPolicy
)
元空间上下文 MetaspaceContext
虚拟内存空间节点列表 VirtualSpaceList
虚拟内存空间节点 VirtualSpaceNode
与 CompressedClassSpaceSize
MetaChunk
类加载的入口 SystemDictionary
与保留所有 ClassLoaderData
的 ClassLoaderDataGraph
每个类加载器私有的 ClassLoaderData
以及 ClassLoaderMetaspace
管理正在使用的 MetaChunk
的 MetaspaceArena
元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
ClassLoaderData
回收
ChunkHeaderPool
池化 MetaChunk
对象
ChunkManager
管理空闲的 MetaChunk
类加载器到 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
什么时候用到元空间,以及释放时机
元空间保存什么
什么是元数据,为什么需要元数据
什么时候用到元空间,元空间保存什么
元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
jcmd VM.metaspace
元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)
JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
解释执行与编译执行时候的判断(x86为例)
一个 Java 线程 Xss 最小能指定多大
JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:ThreadStackSize
,VMThreadStackSize
,CompilerThreadStackSize
,StackYellowPages
,StackRedPages
,StackShadowPages
,StackReservedPages
,RestrictReservedStack
)
Java 线程栈内存的结构
Java 线程如何抛出的 StackOverflowError
现代机器大部分是 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 压缩指针才会失效.
老版本中, 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
对象指针与压缩对象指针如何转化,我们先来思考一些问题。通过第二章的分析我们知道,每个进程有自己的虚拟地址空间,并且从 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 位的地址之后寻址读取。这个转换公式,如下所示:
64 位地址 = 基址 + (压缩对象指针 << 对象对齐偏移)
压缩对象指针 = (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
的情况,即默认情况):
32-bit
压缩指针模式:最大堆内存 + Java 堆起始位置不大于 4GB(并且 Java 堆起始位置不能太小),64 位地址 = 压缩对象指针
Zero based
压缩指针模式:最大堆内存 + Java 堆起始位置不大于 32GB(并且 Java 堆起始位置不能太小),64 位地址 = (压缩对象指针 << 对象对齐偏移)
Non-zero disjoint
压缩指针模式:最大堆内存不大于 32GB,由于要保证 Java 堆起始位置不能太小,最大堆内存 + Java 堆起始位置大于 32GB,64 位地址 = 基址 |(压缩对象指针 << 对象对齐偏移)
Non-zero based
压缩指针模式:用户通过 HeapBaseMinAddress
自己指定了 Java 堆开始的地址,并且与 32GB 地址相交,并最大堆内存 + Java 堆起始位置大于 32GB,但是最大堆内存没有超过 32GB,64 位地址 = 基址 + (压缩对象指针 << 对象对齐偏移)
前面我们知道,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 判断可能会被擦除。可能擦除的包括:
成员方法对于 this 的访问,可以将 this 的 null 判断擦除。
代码中明确判断了某个变量是否为 null,并且这个变量不是 volatile 的
前面已经有了 a.something()
类似的访问,并且 a
不是 volatile 的,后面 a.somethingElse()
就不用再做 null 检查了
等等等等...
对于无法擦除的,JVM 倾向于做出一个假设,即这个变量大概率不会为 null,JIT 优化先直接将 null 判断擦除。Java 中的 null,对应压缩对象指针的值为 0:
enum class narrowOop : uint32_t { null = 0 };
对于压缩对象指针地址为 0 的地方进行访问,实际上就是针对前面我们讨论的压缩对象指针基址进行访问,在四种模式下:
32-bit
压缩指针模式:就是对于 0x0000 0000 0000 0000
进行访问,但是前面我们知道,0x0000 0000 0000 0000
是保留区域,无法访问,会有 Segment Fault
错误,发出 SIGSEGV
信号
Zero based
压缩指针模式:就是对于 0x0000 0000 0000 0000
进行访问,但是前面我们知道,0x0000 0000 0000 0000
是保留区域,无法访问,会有 Segment Fault
错误,发出 SIGSEGV
信号
Non-zero disjoint
压缩指针模式:就是对于基址进行访问,但是前面我们知道,基址 + JVM 系统页大小为仅 Reserve 但是不会 commit 的预留区域,无法访问,会有 Segment Fault
错误,发出 SIGSEGV
信号
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 种情况:
32-bit
压缩指针模式:就是对于 0x0000 0000 0000 0000
进行访问,地址位于第 0 页,uses_implicit_null_check
返回 true
Zero based
压缩指针模式:就是对于 0x0000 0000 0000 0000
进行访问,地址位于第 0 页,uses_implicit_null_check
返回 true
Non-zero disjoint
压缩指针模式:就是对于基址进行访问,地址位于第 0 页,uses_implicit_null_check
返回 true
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 导致的。不过从前面看,我们其实只是为了不映射基址对应的地址,那为啥要保留一整页呢?这个是处于内存对齐与寻址访问速度的考量,里面映射物理内存都是以页为单位操作的,所以内存需要按页对齐。
前面我们说明了不手动指定三个指标的情况下,这三个指标 (MinHeapSize,MaxHeapSize,InitialHeapSize) 是如何计算的,但是没有涉及压缩对象指针。如果压缩对象指针开启,那么堆内存限制的初始化之后,会根据参数确定压缩对象指针是否开启:
首先,确定 Java 堆的起始位置:
第一步,在不同操作系统不同 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);
将 DefaultHeapBaseMinAddress
设置为 HeapBaseMinAddress
的默认值,即 2GB
如果用户在启动参数中指定了 HeapBaseMinAddress
,如果 HeapBaseMinAddress
小于 DefaultHeapBaseMinAddress
,将 HeapBaseMinAddress
设置为 DefaultHeapBaseMinAddress
计算压缩对象指针堆的最大堆大小:
读取对象对齐大小 ObjectAlignmentInBytes
参数的值,默认为 8
对 ObjectAlignmentInBytes
取 2 的对数,记为 LogMinObjAlignmentInBytes
将 32 位左移 LogMinObjAlignmentInBytes
得到 OopEncodingHeapMax
即不考虑预留区的最大堆大小
如果需要预留区,即 Non-Zero Based Disjoint
以及 Non-Zero Based
这两种模式下,需要刨除掉预留区即第 0 页的大小,即 OopEncodingHeapMax
- 第 0 页的大小
读取当前 JVM 配置的最大堆大小(前面我们分析了最大堆大小如何计算出来的)
如果 JVM 配置的最大堆小于压缩对象指针堆的最大堆大小,并且没有通过 JVM 启动参数明确关闭压缩对象指针,则开启压缩对象指针。否则,关闭压缩对象指针。你洗稿的样子真丑。
如果压缩对象指针关闭,根据前面分析过的是否压缩类指针强依赖压缩对象指针,如果是,关闭压缩类指针
引入 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(); } }
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
模式的压缩类指针的特点一模一样。
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 的时候再关心。
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 解析结果一致。
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 解析结果一致。不要偷取他人的劳动成果
微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:
知乎:https://www.zhihu.com/people/zhxhash
B 站:https://space.bilibili.com/31359187
上一篇:金风科技(02208)拟在境内外发行债券及资产支持证券
下一篇:最后一页
个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,
2022年7月6日,经过长时间蹲点抓捕,民警共抓获犯罪嫌疑人46人,捣毁生产窝点2处、仓储2处,外贸销售窝点2
近日,“建行惠懂你”APP认证企业突破1000万大关,标志着建设银行服务中小微企业数字化平台建设再次迈上...
燃动太原城!易生支付以支付科技助力“大美西山·山水晋源”稻田音乐节
2023年度社会保险缴费工资开始申报---参保单位应将2023年度社会保险缴费工资申报情况向本单位职工代表大会
1、不少人在学追女孩子的时候都找不到正确的切入方式,其实聊天是一个很好的开始,追女孩子的聊天技巧是可
截至2023年4月26日收盘,可立克(002782)报收于15 97元,上涨5 55%,换手率1 77%,成交量8 4万手,成交额1 33亿元。
(杨英琦)记者26日从陕西官方举办的新闻发布会上获悉,该省全力推动稳经济政策措施发力见效,大力发展县域经
蓝田县气象台2023年04月26日11时50分发布大风蓝色预警信号:目前葛牌、玉山出现6-7级短时阵风,预计未来24
1、宋琉璃厂窑青釉绿彩双系瓷执壶是重庆中国三峡博物馆收藏的瓷器。2、。本文到此分享完毕,希望对大家有所
南方财经4月26日电,企查查APP显示,近日,北京智同精密传动科技有限责任公司发生工商变更,新增湖北小米长
来源:CFC农产品(行情000061,诊股)研究过去十天涨超1元,引发市场乐观情绪注入长跌思涨,尤其是养殖端经历
小米13Ultra的强大拍照性能吸引了众多玩家的关注,不过今年的性能机皇应该还是小米14。近日小米14迎来新的
龙软科技:拟1080万元受让波义尔51%股权,矿山智能化业务将拓展至矿山智能安全监控领域
厦门网讯(海西晨报记者叶子申)昨天,“2023厦门国际友城读书会”系列活动在外图厦门书城正式启动。本...
据Wind数据统计,截至4月25日,年内新增专项债发行464只,规模达15402 21亿元,从发行进度看,已发行新增专
格隆汇4月25日丨东方电气发布公告,公司于2023年4月24日收到上海证券交易所出具的《关于受理东方电气股份有
投行Stifel的分析师JuergenWagner将其对阿斯麦(ASML US)的股票评级从“买入”下调至“持有”,目标价...
自3月15日发布公告至3月31日截止,奉贤区第五届运动会会徽,吉祥物,口号征集活动得到了广大网友的大力支持
把握正确舆论导向,关注百姓生活,侧重报道社会生活中的知识性、趣味性、服务性新闻;突出新闻性、可读性、
4月25日消息,2023慧保天下保险大会于4月25-26日举行,主题为“从新出发|保险业高质量发展的逻辑重构与价值
上海车展4大国产MPV盘点:魏牌高山很霸气,江淮瑞风RF-M造型科幻
1、将生菜最下段根部切掉,根部部分可多保留;2、把根部放在一个小容器中,优选透明状小罐,或剪矿泉水瓶,
实际上,她之所以能够接纳美杜莎,也是因为美杜莎意外怀上了萧炎的孩子,并且还为她生下了一个女儿,这已经
一季度,湖北高新技术产业实现增加值2602 11亿元,增长6 5%,高于GDP增速1 4个百分点。
X 关闭
X 关闭