个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~ 另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。 今天又是干货满满的一天,这是全网最硬核 JVM 解析系列第四篇,往期精彩:
本篇是关于 JVM 内存的详细分析。网上有很多关于 JVM 内存结构的分析以及图片,但是由于不是一手的资料亦或是人云亦云导致有很错误,造成了很多误解;并且,这里可能最容易混淆的是一边是 JVM Specification 的定义,一边是 Hotspot JVM 的实际实现,有时候人们一些部分说的是 JVM Specification,一部分说的是 Hotspot 实现,给人一种割裂感与误解。本篇主要从 Hotspot 实现出发,以 Linux x86 环境为主,紧密贴合 JVM 源码并且辅以各种 JVM 工具验证帮助大家理解 JVM 内存的结构。但是,本篇仅限于对于这些内存的用途,使用限制,相关参数的分析,有些地方可能比较深入,有些地方可能需要结合本身用这块内存涉及的 JVM 模块去说,会放在另一系列文章详细描述。最后,洗稿抄袭狗不得 house
本篇全篇目录(以及涉及的 JVM 参数):
NativeMemoryTracking
)UseLargePages
,UseHugeTLBFS
,UseSHM
,UseTransparentHugePages
,LargePageSizeInBytes
)MaxHeapSize
,MinHeapSize
,InitialHeapSize
,Xmx
,Xms
)UseCompressedOops
)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始) ObjectAlignmentInBytes
)UseCompressedOops
,UseCompressedClassPointers
)ObjectAlignmentInBytes
,HeapBaseMinAddress
)HeapBaseMinAddress
)HeapBaseMinAddress
,ObjectAlignmentInBytes
,MinHeapSize
,MaxHeapSize
,InitialHeapSize
)32-bit
压缩指针模式Zero based
压缩指针模式Non-zero disjoint
压缩指针模式Non-zero based
压缩指针模式MinHeapFreeRatio
,MaxHeapFreeRatio
,MinHeapDeltaBytes
)(全网最硬核 JVM 内存解析 - 6.其他 Java 堆内存相关的特殊机制开始)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
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
回收CommitLimiter
的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC_capacity_until_GC
jcmd VM.metaspace
元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始) jcmd <pid> VM.metaspace
元空间说明jdk.MetaspaceSummary
元空间定时统计事件jdk.MetaspaceAllocationFailure
元空间分配失败事件jdk.MetaspaceOOM
元空间 OOM 事件jdk.MetaspaceGCThreshold
元空间 GC 阈值变化事件jdk.MetaspaceChunkFreeListSummary
元空间 Chunk FreeList 统计事件ThreadStackSize
,VMThreadStackSize
,CompilerThreadStackSize
,StackYellowPages
,StackRedPages
,StackShadowPages
,StackReservedPages
,RestrictReservedStack
)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 里面的页表项保存的是指向用户数据内存的指针。如何将虚拟内存地址通过页表找到对应用户数据内存从而读取数据,过程是:
39 ~ 47
位(因为用户空间与内核空间可以使用从 0x0000 0000 0000 0000 ~ 0x0000 FFFF FFFF FFFF
的地址,即 47 位以下的地址)作为 offset,在唯一的 PGD 页面根据 offset 定位到 PGD 页表项 pgd_t
pgd_t
定位到具体的 PUD 页面30 ~ 38
位作为 offset,在对应的 PUD 页面根据 offset 定位到 PUD 页表项 pud_t
pud_t
定位到具体的 PMD 页面21 ~ 29
位作为 offset,在对应的 PMD 页面根据 offset 定位到 PMD 页表项 pmd_t
pmd_t
定位到具体的 PTE 页面12 ~ 20
位作为 offset,在对应的 PTE 页面根据 offset 定位到 PTE 页表项 pte_t
pte_t
定位到具体的用户数据物理内存页面0 ~ 11
位作为 offset,对应到用户数据物理内存页面的对应 offset如果每次访问虚拟内存,都需要访问这个页表翻译成实际物理内存的话,性能太差。所以一般 CPU 里面都有一个 TLB(Translation Lookaside Buffer,翻译后备缓冲)存在,一般它属于 CPU 的 MMU 的一部分。TLB 负责缓存虚拟内存与实际物理内存的映射关系,一般 TLB 容量很小。每次访问虚拟内存,先查看 TLB 中是否有缓存,如果没有才会去页表查询。
默认情况下,TLB 缓存的 key 为地址的 12 ~ 47
位,value 是实际的物理内存页面。这样前面从第 1 到第 7 步就可以被替换成访问 TLB 了:
12 ~ 47
位作为 key,访问 TLB,定位到具体的用户数据物理内存页面。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());
第一步,JVM 的每个子系统(例如 Java 堆,元空间,JIT 代码缓存,GC 等等等等),如果需要的话,在初始化的时候首先 Reserve 要分配区域的最大限制大小的内存(这个最大大小,需要按照页大小对齐(即是页大小的整数倍),默认页大小是前面提到的 Linux::page_size()
),例如对于 Java 堆,就是最大堆大小(通过 -Xmx
或者 -XX:MaxHeapSize
限制),还有对于代码缓存,也是最大代码缓存大小(通过 -XX:ReservedCodeCacheSize
限制)。Reserve 的目的是在虚拟内存空间划出一块内存专门给某个区域使用,这样做的好处是:
NullPointerException
异常的优化,全局安全点,抛出 StackOverflowError
的实现,都和这个机制有关。在 Linux 的环境下,Reserve 通过 mmap(2)
系统调用实现,参数传入 prot = PROT_NONE
,PROT_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 里面带的地址处于的范围,就能知道是哪个子系统
前面一节我们知道了,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 的内存)与实际其他系统监控中体现的物理内存使用指标对不上的原因。