你这张图和总结整体是对的,它讲的是 Java 程序从源码到运行时,JVM 内部各个区域怎么配合工作。我帮你按"流程 + 内存区域 + 易混点"解释一下。
1. JVM 运行流程先看这条线
text
Java 源代码 .java
↓ javac 编译
Java 字节码 .class
↓ 类加载器加载
运行时数据区
↓ 执行引擎执行
机器指令 / 操作系统指令
也就是说:
你写的是:
java
public class User {
public static void main(String[] args) {
User u = new User();
}
}
先被编译成 .class 字节码文件。
然后 JVM 启动后,类加载器 会把 .class 文件加载进内存,也就是加载到 JVM 的 运行时数据区。
接着 执行引擎负责执行字节码。执行方式主要有两种:
text
解释器:一行一行解释字节码,马上执行,启动快
JIT 编译器:把热点代码编译成本地机器码,后面执行更快
2. 运行时数据区是什么?
你说得对:
text
JVM 内存结构 ≈ 运行时数据区
运行时数据区主要分为 5 块:
text
线程共享:
1. 堆
2. 方法区
线程私有:
3. 虚拟机栈
4. 本地方法栈
5. 程序计数器
可以这样记:
text
对象放堆里
类信息放方法区
方法调用靠虚拟机栈
native 方法靠本地方法栈
当前执行到哪靠程序计数器
3. 程序计数器 PC Register
程序计数器的作用是:
text
记录当前线程下一条要执行的字节码指令地址
比如一个线程正在执行:
java
int a = 1;
int b = 2;
int c = a + b;
这些 Java 代码会被编译成一条条字节码指令。
程序计数器就像一个"小书签",记录当前线程执行到哪一条指令了。
为什么它是线程私有的?
因为 CPU 会在线程之间切换。
比如:
text
线程 A 执行到第 10 条指令,被切走
线程 B 开始执行
过一会儿线程 A 又回来继续执行
线程 A 必须知道自己之前执行到哪里了,所以每个线程都要有自己的程序计数器。
这里注意一个小点:
你写的:
物理上程序计数器是使用"寄存器"完成的。
这个说法可以作为理解,但严格来说,JVM 里的 PC Register 是一个 逻辑概念,不一定真的对应 CPU 的某一个物理寄存器。
4. 虚拟机栈 JVM Stack
虚拟机栈也叫线程栈。
每个线程创建时,都会有自己的虚拟机栈。
它的核心作用是:
text
管理 Java 方法的调用过程
比如代码:
java
public static void main(String[] args) {
a();
}
public static void a() {
b();
}
public static void b() {
System.out.println("hello");
}
执行过程大概是:
text
main() 入栈
a() 入栈
b() 入栈
b() 执行完,出栈
a() 执行完,出栈
main() 执行完,出栈
栈里面的基本单位叫 栈帧。
一个方法调用,对应一个栈帧。
栈帧里面主要存:
text
局部变量表:方法参数、局部变量
操作数栈:字节码执行过程中的临时计算空间
动态链接:指向方法区中方法信息的引用
方法返回地址:方法执行完后回到哪里
你总结里写:
栈帧存储方法参数、方法内局部变量、方法返回地址
这是对的,但面试里可以再补充一句:
text
栈帧还包括操作数栈和动态链接。
虚拟机栈为什么会 StackOverflowError?
比如递归没有出口:
java
public void test() {
test();
}
每调用一次 test(),都会创建一个新的栈帧。
栈帧越来越多:
text
test()
test()
test()
test()
...
虚拟机栈空间被撑爆,就会报:
text
java.lang.StackOverflowError
所以你写的:
方法递归过多会导致 StackOverflowError
是正确的。
5. 本地方法栈 Native Method Stack
本地方法栈服务的是 native 方法。
比如:
java
public native int hashCode();
或者 Thread 类底层的一些方法:
java
private native void start0();
native 方法不是用 Java 实现的,而是用 C/C++ 等语言实现的。
为什么需要 native?
因为 Java 有些操作没法直接做,比如:
text
操作系统线程创建
底层文件操作
网络 I/O
内存管理
硬件相关操作
所以 Java 通过 JNI,也就是 Java Native Interface,本地方法接口,去调用 C/C++ 写好的本地库。
关系可以理解成:
text
Java 代码
↓
native 方法声明
↓
JNI 本地方法接口
↓
C/C++ 本地库
↓
操作系统底层 API
本地方法栈就是给这些 native 方法运行时使用的栈空间。
6. 堆 Heap
堆是 JVM 中最大的一块内存区域,主要用来存放:
text
对象实例
数组
比如:
java
User user = new User();
这里要区分:
text
user 这个引用变量:在栈中
new User() 这个对象:在堆中
可以理解成:
text
栈中 user 变量保存的是地址
堆中保存的是真正的 User 对象
例如:
java
public void test() {
User user = new User();
}
大概是:
text
虚拟机栈:
user = 0x001
堆:
0x001 -> User 对象
堆为什么需要 GC?
因为对象都在堆里,很多对象用完之后就没用了。
比如:
java
public void test() {
User user = new User();
}
方法执行完后,user 这个局部变量出栈了。
如果这个 User 对象没有其他引用指向它,那么它就变成垃圾对象,后续会被 GC 回收。
所以:
text
GC 主要回收堆中的对象
年轻代和老年代
堆从逻辑上可以分为:
text
年轻代:新创建的对象大多在这里
老年代:长期存活的对象会晋升到这里
年轻代又可以分为:
text
Eden 区
Survivor From 区
Survivor To 区
大致过程:
text
新对象先进入 Eden
Minor GC 后还活着,进入 Survivor
多次 GC 后还活着,进入老年代
这就是你写的:
年轻代存生命周期短的对象,老年代存生命周期长的对象
这个理解是对的。
7. 方法区 Method Area
方法区是线程共享的,主要存放 类相关信息。
比如一个类:
java
public class User {
private String name;
private static int count;
public void sayHello() {
System.out.println("hello");
}
}
类被加载后,方法区里会保存:
text
类名
父类信息
接口信息
字段信息
方法信息
运行时常量池
JIT 编译后的代码
你写的:
方法区存储类信息、静态变量、常量、编译后的代码、运行时常量池
大方向是对的。
但这里有一个容易被问的细节:
JDK 7 和 JDK 8 方法区变化
方法区是 JVM 规范里的概念。
它的具体实现,在不同 JDK 版本中不一样。
text
JDK 7 及以前:永久代 PermGen
JDK 8 以后:元空间 Metaspace
区别是:
text
永久代:主要使用 JVM 堆内存
元空间:使用本地内存,也就是操作系统内存
所以 JDK 8 后,如果类加载太多,可能报:
text
java.lang.OutOfMemoryError: Metaspace
方法区、永久代、元空间的关系
这个地方最容易混:
text
方法区:规范,是 JVM 规定应该有这么一块区域
永久代:JDK 7 及以前 HotSpot 对方法区的实现
元空间:JDK 8 以后 HotSpot 对方法区的实现
就像:
text
接口:方法区
实现类1:永久代
实现类2:元空间
所以你可以这样记:
text
方法区是抽象概念
永久代和元空间是具体实现
8. 直接内存 Direct Memory
直接内存不是 JVM 运行时数据区的一部分。
它属于:
text
操作系统内存 / 本地内存
常见于 NIO。
比如:
java
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
它分配的就是直接内存。
直接内存的好处是:
text
减少 Java 堆和操作系统内核之间的数据拷贝
I/O 性能更高
普通 I/O 大概是:
text
磁盘/网络
↓
操作系统内核缓冲区
↓ 拷贝
Java 堆内存
直接内存大概是:
text
磁盘/网络
↓
直接内存
Java 和本地代码都可以访问这块区域,所以读写性能更高。
但缺点是:
text
分配和释放成本更高
不受 JVM 堆大小直接限制
使用不当也可能 OOM
可能报:
text
java.lang.OutOfMemoryError: Direct buffer memory
9. 线程私有和线程共享怎么理解?
线程私有
text
程序计数器
虚拟机栈
本地方法栈
为什么私有?
因为每个线程执行的方法、执行到的位置都不一样。
比如:
text
线程 A 正在执行 login()
线程 B 正在执行 pay()
它们的方法调用链不同,局部变量不同,执行位置也不同,所以这些东西不能共享。
线程共享
text
堆
方法区
为什么共享?
因为所有线程都可能访问同一个对象、同一个类信息。
比如:
java
User user = new User();
线程 A 可以访问这个对象,线程 B 也可以访问这个对象。
所以堆是共享的。
又比如:
java
User.class
类信息只需要加载一份,所有线程共用即可。
所以方法区也是共享的。
10. 一个完整例子串起来
看这段代码:
java
public class Demo {
private static int count = 0;
public static void main(String[] args) {
User user = new User("Tom");
user.sayHello();
}
}
执行时大概是:
第一步:类加载
Demo.class、User.class 被类加载器加载。
类信息进入方法区:
text
方法区:
Demo 类信息
User 类信息
main 方法信息
sayHello 方法信息
static count
运行时常量池
第二步:main 线程启动
JVM 创建 main 线程。
main 线程拥有自己的:
text
程序计数器
虚拟机栈
本地方法栈
第三步:调用 main 方法
main() 方法入栈。
text
虚拟机栈:
main 栈帧
main 栈帧里有局部变量:
text
args
user
第四步:创建对象
执行:
java
User user = new User("Tom");
堆中创建对象:
text
堆:
User 对象 { name = "Tom" }
栈中的 user 保存对象地址:
text
虚拟机栈:
user -> 堆中的 User 对象
第五步:调用 sayHello
执行:
java
user.sayHello();
sayHello() 方法入栈:
text
虚拟机栈:
sayHello 栈帧
main 栈帧
执行完后,sayHello 出栈:
text
虚拟机栈:
main 栈帧
main 执行完后,main 栈帧也出栈。
11. 你这份笔记里需要稍微修正的地方
主要有几个小点:
1. "运行时数据区把字节码加载到内存"
更准确说法是:
text
类加载系统把 class 字节码加载到运行时数据区。
运行时数据区本身不是主动加载者,它是 JVM 的内存区域。
2. "程序计数器物理上使用寄存器完成"
可以改成:
text
程序计数器是 JVM 中的逻辑概念,用来记录当前线程正在执行的字节码指令地址。
不要强调一定是物理寄存器。
3. "JDK1.7 中堆中存在方法区的实现:永久代"
这个说法容易引起误解。
建议改成:
text
JDK 7 及以前,HotSpot 虚拟机使用永久代实现方法区,永久代属于 JVM 管理的内存区域。
JDK 8 以后,使用元空间实现方法区,元空间使用本地内存。
4. "本地方法栈是本地方法接口运行时所需要的内存空间"
可以改成:
text
本地方法栈是 native 方法执行时所使用的栈空间。
JNI 是 Java 调用 native 方法的接口机制。
JNI 是接口,本地方法栈是运行时内存区域,两个不要混成一个东西。
12. 面试版总结
你可以这样背:
text
JVM 运行时数据区分为五部分:堆、方法区、虚拟机栈、本地方法栈、程序计数器。
堆和方法区是线程共享的。堆主要存放对象实例,是 GC 管理的主要区域;方法区主要存放类信息、常量、静态变量、运行时常量池和 JIT 编译后的代码。
虚拟机栈、本地方法栈和程序计数器是线程私有的。虚拟机栈用于 Java 方法调用,每次方法调用都会创建一个栈帧,栈帧中保存局部变量表、操作数栈、动态链接和返回地址。本地方法栈服务于 native 方法。程序计数器记录当前线程正在执行的字节码指令地址,用于线程切换后恢复执行位置。
JDK 7 及以前方法区的实现是永久代,JDK 8 以后改为元空间,元空间使用本地内存。
一句话记忆:
text
堆放对象,方法区放类,栈跑方法,PC 记位置,本地栈跑 native。