type
status
date
slug
summary
tags
category
icon
password
JVM的内存结构
JVM 的内存结构分为 程序计数器、虚拟机栈、本地方法栈、元空间(方法区)、堆。
前三个是 线程隔离 的,后两个是 线程共享 的。
0 字节码
在介绍这五个区域之前,先得明白「字节码」是什么。
当 Java 项目被编译或启动时,
.java 文件会被编译成 .class 文件,也就是 二进制格式的字节码文件,其中包含一条条字节码指令,例如:对应的字节码指令如下:
^2cd2b7
为什么叫字节码文件?因为 JVM 以字节为单位来读取和解释这些指令
这些字节码是一种 中间语言(中间指令集),是 JVM 的 " 母语 ",而非 CPU 的机器码 (常有新手搞错这一点)。
JVM 在执行时会把字节码 逐条解释或编译成机器码。
逐条翻译?这样岂不是会慢很多?没错,这确实会带来性能损耗,但正是因为有了 JVM 的这层抽象,Java 才能实现那句著名的口号——" 一次编写,到处运行"。
当然不止如此,JVM 重要的是能统一管理内存(GC)、提供安全沙箱、线程调度、异常处理、运行时监控接口,甚至能在不改源码的情况下自动优化(比如替换 GC 或 JIT 策略)。它本身就是一个专门为程序而生的系统。
如果直接编译成机器码,这些机制都无法实现。
其他语言为什么不选择 JVM 呢:
C/C++注重极致性能和底层控制;
Python追求简洁易用;
.NET有自己的 CLR;
Go、Rust追求更快的启动和更小的部署体积。
JVM 的生态系统则更适合构建大型复杂的企业系统、大数据处理和移动端应用。
值得一提的是,现代 JVM(如 HotSpot、GraalVM)已经非常智能。
当某段代码被频繁执行时(即 热点代码),JVM 会触发 JIT 编译器(C1/C2),把这部分字节码编译成本地机器码,从而让 Java 实际运行速度非常接近甚至超过 C++。
以上,简单介绍了字节码指令。
1 元空间(方法区)
由 JVM 加载的 类信息、字节码指令、运行时常量池 等内容都存在于元空间中。(元空间并不在 JVM 中,而是使用了本地内存)。
既然在元空间中,那么元空间肯定是线程共享了。很好理解吧,毕竟代码可不能给每个线程复制一份,那空间全被代码浪费了,所以代码肯定是线程共享一份的,哪个线程需要什么功能就拿着输入在里面执行一遍,最后拿着结果走就行了。
1.1 类信息
字节码都懂了,但是这里的类信息是什么?有些教程中会把这两个分开,我这里也是便于解释,但其实它们都在
.class 文件里。说白了就是字面意思,它包含了类的结构信息,如类名、父类、接口、字段、方法、修饰符等。看上面那段 代码,大家有没有发现字节码指令其实就是一个表格,按照顺序排列的表格实际上就是个数组,因此也有人称字节码指令为 字节码数组。那堆字节码,只是 " 代码实现 ",除此之外的类结构描述,就是类信息(元数据)。
还是上面的代码,大家应该知道没写全,写全的话应该是下面这样。
将这个文件真正转为
.class 文件,因为 .class 文件是二进制格式,所以真实样子是这样的。反编译一下大概如下所示。
这样大家就明白了吧,其实只有最后这一块才是
System.out.println("Hi, I'm " + name) 真实的样子,除此之外,其他部分就是类的结构,也叫做类信息。1.2 运行时常量池
看上面的字节码。虽然
.class 文件(直接称元空间即可)中也有 Constant pool ,但这个运行时常量池可不是大家常说的那个堆中的字符串常量池,运行时常量池中存放的是 字面量 和 符号引用。类型 | 示例 | 含义 |
字面量 | 42、3.14 | 源码中的常量值 |
类符号引用 | java/lang/StringBuilder | 类的全限定名 |
字段符号引用 | System.out | 字段名及其描述符 |
方法符号引用 | println:(Ljava/lang/String;)V | 方法名与方法签名 |
字符串描述符 (Utf8) | "name" | 标识符的名字(类名、字段名等) |
为什么这些字面量放在元空间?因为这些常量永远不会变,它相当于属于这个代码的一部分,所以编译的时候直接嵌进去了 (除了字符串,存在于运行时常量池中的字符串在程序启动时都加载到堆中的字符串常量池)。举例如下。
类型 | 存放位置 |
final int COUNT = 10 | 元空间(常量内联) |
final static int COUNT = 10 | 元空间(常量内联) |
System.out.println("Hi, I'm") 中的 Hi, I'm | 堆(字符串常量池) |
final static String NAME = "Alice" | 堆(字符串常量池) |
static int age = 18 | 堆(类静态变量区) |
static String msg = "Hi" | 堆(类静态变量区 + 字符串常量池) |
int id = 1(实例变量) | 堆(对象实例中) |
String name = "Tom"(实例变量) | 堆(对象实例中 + 字符串常量池) |
局部变量 int x = 5 | 栈帧 |
局部变量 String s = "Hi" | 栈帧(引用)+ 堆(字符串) |
等等?好像有点问题,第 5 行的静态修饰的基本类型也在堆中?是的,请看下一个小标题的内容。
1.2 静态变量(请看完再说)
有些人搞不懂静态变量到底在堆里还是元空间里,这个很好理解,静态变量是类的一部分,因此对于静态变量来说,这个引用变量、字段描述是很重要的,这些描述信息确实放在元空间里,但而创建的实例对象则在堆中(无论对象还是基本类型),随着类的加载而加载 (当然,堆会有一块区域专门属于类级别的存储区)。
记住一句话——元空间存放的是 " 关于类本身 " 的信息,不存放任何有关 " 值 " 这个概念的东西 (基本类型的常量算不算值?我觉得应该直接算作代码的一部分了)。
从 ChatGPT 那偷来一句话:
- 元空间保存 " 字段定义 "(即名字、类型、修饰符等元信息)。
- 堆保存 " 字段的值 "(即实际的数据或引用)。
2 堆
介绍完元空间就顺带介绍一下堆,这玩意大家应该最不陌生了,它是真正存放 " 值 " 这个概念的地方。
堆是 JVM 中最大的一块内存区域,几乎所有对象都在这里分配。
它存放以下内容:
- 所有的实例对象(包括数组、对象实例);
- 字符串常量池(JDK 7 以后迁移到堆);
- 静态变量的值(而非定义);
- 运行时常量池中引用到的动态内容。
堆是 线程共享 的,因此需要通过 GC(垃圾收集器)统一管理生命周期。
3 程序计数器
程序计数器是 每个线程私有的寄存器,用来记录当前执行的字节码指令的地址。它指向 " 下一条要执行的字节码 ",以便在方法切换、异常跳转时恢复执行。由于每个线程都有自己的执行路径,所以计数器必须线程隔离。
4 本地方法栈
本地方法栈用于支持 JVM 执行 Native 方法(本地方法),也就是那些用 C/C++ 等语言实现、通过
native 关键字声明的方法,比如 Object.hashCode()、System.arraycopy()。它和虚拟机栈类似,也会为每个线程分配独立的栈空间,用于存储本地方法调用时的局部变量、返回地址和操作数栈等信息。
区别在于,虚拟机栈服务于 Java 方法执行,而本地方法栈服务于本地(Native)方法执行。
当 Java 调用到本地方法时,JVM 会通过 JNI(Java Native Interface) 进入本地方法栈,由本地语言代码执行,执行完毕后再返回 JVM 世界。在这个过程中,程序计数器也会记录对应的本地方法调用位置,以便能够正确恢复。
5 虚拟机栈
虚拟机栈,众所周知,栈和堆可谓是异父异母的非双胞胎结拜兄弟。
虚拟机栈用于描述 Java 方法的调用与执行过程,每个线程都有自己的虚拟机栈。
栈中每个方法的调用对应一个 栈帧(Stack Frame)。所有的字节码指令都只会对当前栈帧进行操作,如果这个方法内部调用了多个其他方法,就会出现多个其他栈帧。
JVM 直接对栈的操作只有两个:入栈和出栈,遵循先进后出原理。如:首先执行
Method01,则 Method01 会入栈进入虚拟机栈,而后调用 Method02,则 Method02 入栈进入虚拟机栈,等到 Method02 执行完 return 后就会出栈,接着回到 Method01 执行完 return 后也出栈。了解了栈帧的概念,那就开始深入栈帧中的局部变量表、操作数栈、动态链接、方法出口、异常表这五个部分。
5.1 局部变量表
局部变量表存放的就是方法上的参数和方法内局部变量的值,方法中的这些值只有在这个方法执行的时候才会被加载到局部变量表中,需要注意的是,只有基本数据类型的值会被加载到局部变量表中,而字符串或者引用类型则是将地址加载到局部变量表中,通过地址在堆中就能找到值。
这很容易想通,Java 中的基本类型(
int、double、boolean 等)在 JVM 中的大小是固定的,它们不包含复杂的内部结构,也没有引用关系,所以 JVM 知道这些变量在栈帧中应该分配多少空间,非常方便。并且每一次对其变量的值的修改也不会重新分配空间,而是直接覆盖这个槽里的值,这也是为什么基本类型会出现数据溢出的问题,JVM 定死了空间大小,栈也只会给出这么大的空间,想要扩展?想要更多空间?那就去堆吧。5.2 操作数栈
操作数栈在方法执行过程中,根据字节码指令往操作数栈中写入数据或提取数据,如下所示。
这是一个方法:
这是 Method02 方法的字节码指令:
通过上面的例子我们可以看到,这些值会临时写入到操作数栈中,又把数据提取到局部变量表中,所以操作数栈的作用就是写入数据和提取数据。可以把操作数栈理解为草稿纸,程序执行时需要把这个值写道草稿纸上,计算后再将值写入到局部变量表中。
5.3 动态链接
动态链接保存了一个 " 符号引用 " 的编号,到运行时常量池 " 直接引用 "(全限定名)的内存地址的映射关系。直接引用就是元空间中运行时常量池存储的类和方法的全限定名。当在
Method01 中调用 Method02 时就会创建这样一条字节码指令:因为动态链接保存了符号引用和直接引用的内存地址的映射关系,所以通过符号引用
#9 这个编号就能找到 Method02 这个方法的字节码指令的地址,并修改程序计数器保存的字节码指令的地址。之前在说程序计数器的时候说过,它会记录下一条字节码指令的地址,所以原本要保存的
Method01 的字节码指令的地址,就会变成 Method02 开始的字节码指令地址。那么问题来了,怎么返回上一个方法
Method01 呢?往下看。5.4 方法出口
方法出口,存放调用该方法的指令的下一条指令的地址,这话听着有点抽象。
举个例子:
Method01 调用 Method02 ,Method02 执行完最后一行代码后,应回到 Method01 继续执行。在调用 Method02 时,Method02 的栈帧的方法出口就记录了返回 Method01 后应继续执行的下一条字节码指令的地址,因此程序可以继续执行,或者可以说每个栈帧在结束后都会从方法出口出去。**(打个比方,字节码指令就如同一个个链表一样,程序计数器会记录下一个指令的地址,如果调用了其他方法就进入了新的链表,新的链表也走到头了,此时程序计数器就没有下一个指令的地址了,所以需要方法出口来作为新的链表的头节点)*5.5 异常表
异常表——存放代码中异常的处理信息,有起始指令地址、结束指令地址、跳转指令地址。
比如此下面这段
try……catch。进入这个方法时,这个方法的栈帧中就会存在类似这样的一张异常表。
Nr. | 起始 PC | 结束 PC | 跳转 PC | 捕获类型 |
0 | 5 | 8 | 11 | cp_info #4 java/lang/Exception |
起始 PC,即起始字节码指令地址,指开始捕获异常的字节码指令地址,也就是
try 下的第一个字节码指令地址,如:5 bipush 100;结束 PC,即结束字节码指令地址,如果没有发生异常,
try 与 catch 中间的最后一条字节码指令是一个跳转指令,如:8 goto 20 ,跳转到捕获异常结束的字节码指令地址,也就是 catch 结束的下一条字节码指令地址,在这个例子中,20 即 return,直接退出了方法体;跳转 PC,即跳转字节码指令地址,如果
try 中出现了异常,就会根据异常表跳到 catch 开始的第一条字节码指令,如:11 astore_2,将 Exception 类型的局部变量 e 添加到局部变量表中的 2 位置。- 作者:林明菁
- 链接:https://blog.lxuan.fun/article/jvmdncjg
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。








