本文主要是个人在学习中,对 JVM 结构组织的梳理。通过梳理各个部分的由来,逐步描绘出 JVM 整体的框架,并串联相关的知识点,让对 JVM 的理解整体更加有条理、丰满。
Java 作为一个具有跨平台特性的语言,JVM 运行时数据区自然就要处理不同平台间程序的内存结构的不同问题。
JVM 运行时数据区就是 Java 程序的内存结构,而当我们讨论其对 OS 原生程序的内存抽象封装,对 Java 程序管理和操作内存提供统一的接口,OS 原生程序的内存结构是怎么样的自然是绕不开的话题。
Java 程序是通过编译为 .class 字节码文件在 JVM 中执行,而在我们的原生计算机系统中,这个过程就是 C 语言被编译为机器码(0101的那个机器码,汇编语言只是类似机器码另一只符号表达方式)然后在 CPU 中执行指令。所以 C 语言程序的内存结构自然就是 OS 原生程序内存结构展现了。
对于不同 OS 我们用下面两个表格来对比展现差异:

| 区域 | 作用 |
|---|---|
| 代码段(Text) | 存放机器指令 |
| 数据段(Data) | 存放已初始化的全局/静态变量 |
| BSS 段 | 存放未初始化的全局/静态变量 |
| 堆(Heap) | 动态分配内存(malloc) |
| 栈(Stack) | 函数调用、局部变量 |
这个结构在 Windows、Linux、macOS 等系统中基本一致,因为它们都遵循冯·诺伊曼模型。
| 差异点 | Linux | Windows |
|---|---|---|
| 内存分配系统调用 | brk() / sbrk() / mmap() | VirtualAlloc() |
| 可执行文件格式 | ELF(Executable and Linkable Format) | PE(Portable Executable) |
| 栈的默认大小 | 通常 8MB | 通常 1MB |
| 地址空间布局 | ASLR(地址空间布局随机化)实现方式不同 | |
| 共享库机制 | .so 文件 | .dll 文件 |
从刚才的表格中,我们可以知道C 程序的内存“逻辑结构”相似,但“物理实现”依赖操作系统。而这也正是 JVM 的一个重要使命——“Write Once, Run Anywhere”(一次编写,到处报错)
为了实现这个使命,JVM 就对操作系统内存进行抽象,为提供开发者提供统一的内存接口调用(准确来说是统一的内存组织方式,Java中默认不允许直接操作内存), 也就是 JVM 运行时数据区。
而 JVM 运行时数据区作为对原生计算机程序内存结构的抽象,自然其内存区域划分也是类似的,堆、栈和静态代码段(方法区)。
有趣的是,我们原生计算机程序的内存结构来源于 “操作系统 + 可执行文件格式 + 编译器/链接器” 共同决定。JVM 本质上是一个用C/C++编写的程序,所以 JVM 内部也是遵循原生计算机程序的内存结构,
.text段存放其实现代码,堆、栈中存储变量实现虚拟机功能
在有了前面那么多的铺垫后,JVM 运行时数据区终于是千呼万唤始出来了。作为 Java 程序的内存结构,并不完全与计算机原生程序相同,但是在堆、栈和静态代码段这三个概念上是相似的。这里我们就从这三个概念上,对 JVM 运行时数据区域结构进行梳理 ![[assets/JVM/file-20250721162725868.png]]
栈内存的由来是计算机运行程序过程中经常进入一个函数后,然后返回一个结果跳回原来的函数中,这样层层嵌套的访问特点自然地出现了栈内存,同时也是数据结构中栈的由来
在 Java 中栈内存是通过虚拟机栈来实现的,虚拟机栈里面的每个数据叫做栈帧,而一个栈帧由多个部分组成,内容和功能如下:

栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出
StackOverFlowError错误。Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
除了
StackOverFlowError错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 那么当虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。
PC 程序计数器是 JVM 实现其多线程执行模型和字节码指令流控制所必需的基础设施。它让 JVM 能够知道“这个线程现在执行到哪了”,从而实现线程的暂停、恢复和正确执行。
⚠️ 注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
在计算机程序的内存中,除了具有栈特点的函数进出所出现的栈内存数据,在程序中还经常会出现生命周期不确定、大小动态变化的数据,这部分数据较为复杂需要人为操控,我们就将这部分内存分配模式叫做堆(Heap)内存。
堆内存的分配方式并不是类似数据结构中的最大/小堆一样,它名字的由来是英文中的Heap,具有无序混乱的意思。堆内存中的内存块大小不一致比较混乱,因此得名堆内存。
堆内存个人认为也是 JVM 内存中的核心,因为 Java 中采用了完全的面向对象设计——一切皆对象。因此几乎所有的类都是继承于Object类,也就是说我们绝大多数使用的变量都是一种引用对象,操作时大多数都是操作其指向的内存地址,作为一个指针使用,而不同于int等类型存储于栈中。
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
HotSpot 虚拟机中字符串常量池的实现是StringTable可以简单理解为一个固定大小的HashTable ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置),保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象。
JDK1.7 之前,字符串常量池存放在方法区。JDK1.7 字符串常量池和静态变量从方法区移动到了 Java 堆中。主要是因为方法区GC回收效率太低,字符串增多容易导致OOM
堆内存作为 JVM 运行时内存区的主要部分,承担了 Java 对象的管理责任。自然而然,GC 便是不可避免的话题。对于这部分内容篇幅较大,就不在这里进行过多的探讨了,主要讨论根据 GC 所对对内存空间的划分说一下。
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。

关于这部分推荐的阅读资料:
方法去我们可以借助原生计算机程序中静态数据的代码段和数据段来理解。在原生计算机程序中,我们运行的程序代码是先要被从磁盘中读取到内存中,然后 CPU 再用程序计数器从对应的内存地址中读取要指向的机器指令后,交由给 CPU 执行。
数据段