第13章 JVM运行原理:回到程序幕后
已经写过方法、对象、线程和反射,再看编译、类加载、内存区域与垃圾回收,就有自己的代码可以对照了。遇到 HotSpot 的具体实现时,要把它与所有 JVM 都应遵守的规则分开。
本章路线
- 第1节 Java运行原理:程序在幕后忙什么
1. Java运行原理:程序在幕后忙什么
class 文件是谁加载的,方法调用放在哪里记录,不再使用的对象又由谁回收?接下来沿着这些问题看 JVM 的运行过程。
沿着编译、类加载、执行和回收讲一遍程序怎样运行,也要区分运行时内存结构与并发一章的 JMM。
1. 编译与运行
源码变成字节码,再被加载和执行。编译报错与运行时异常发生在不同阶段,排查时先确定自己走到了哪一步。
完整过程
┌──────────────────────────────────────────────────────────────────────┐ │ 编译阶段 运行阶段 │ │ │ │ Hello.java ──→ [javac] ──→ Hello.class ──→ [java] ──→ 控制台输出 │ │ (源码) (编译器) (字节码) (JVM) │ │ │ │ ┌────────┐ ┌────────┐ │ │ │ javac │ │ java │ │ │ └────────┘ └────────┘ │ │ 将 .java 编译成 .class 启动 JVM,加载并执行 .class │ │ 检查语法错误 解释执行 / JIT 即时编译为机器码 │ │ 内存管理 (GC) │ └──────────────────────────────────────────────────────────────────────┘编译阶段详解
# javac 做了什么javac HelloWorld.java# 1. 词法分析:字符序列 → Token 流# 2. 语法分析:Token 流 → 抽象语法树 (AST)# 3. 语义分析:类型检查、变量检查等# 4. 生成字节码:AST → HelloWorld.class(.class 字节码文件)# 反编译查看字节码javap-cHelloWorld.class运行阶段详解
java HelloWorld 1. 类加载:ClassLoader 加载 HelloWorld.class 到 JVM 2. 字节码校验:验证 .class 文件格式正确性 3. 解释执行:JVM 逐条将字节码翻译为机器码执行 4. JIT 编译:热点代码(频繁执行)直接编译成本地机器码,提升性能 5. GC:自动回收不再使用的内存2. JVM 运行时数据区域
这里讲运行时数据区域。栈帧、堆和方法区描述数据如何组织,不要把它们与并发一章的 Java 内存模型混成同一张图。
JVM 内存分区
┌──────────────────────────────────────────────────────────────┐ │ JVM 内存 │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ 栈 (Stack) │ │ 堆 (Heap) │ │ 方法区 │ │ │ │ │ │ │ │ (Method Area) │ │ │ │ 每个线程独立 │ │ 所有线程共享 │ │ │ │ │ │ │ │ │ │ 所有线程共享 │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ ┌──────┐ ┌──────┐ ┌──────────────────────┐ │ │ │ 程序 │ │ 本地 │ │ 直接内存 │ │ │ │ 计数器 │ │ 方法栈 │ │ (元空间/堆外内存) │ │ │ └──────┘ └──────┘ └──────────────────────┘ │ └──────────────────────────────────────────────────────────────┘各区域详解
| 区域 | 存储内容 | 线程 | 特点 |
|---|---|---|---|
| 栈 (Stack) | 局部变量、方法参数、方法调用链 | 线程私有 | 方法调用进栈,结束出栈;栈大小受 JVM 配置影响;栈帧随方法调用建立、返回后移除 |
| 堆 (Heap) | new出来的对象、数组实例 | 线程共享 | GC 主要区域,容量大,对象不再从 GC Roots 可达时可被回收,时机由收集器决定 |
| 方法区 | 规范层面的类级结构、方法信息和运行时常量池等 | 线程共享 | HotSpot 中元空间承载类元数据;静态字段值、字符串对象等不能一概归入元空间 |
| 程序计数器 | 当前线程执行的字节码行号 | 线程私有 | 极小,记录"执行到哪了" |
| 本地方法栈 | native方法调用 | 线程私有 | C/C++ 写的本地方法 |
方法执行的内存过程
publicclassDemo{publicstaticvoidmain(String[]args){int[]arr=newint[]{10,20,30};// 第 4 行intmax=getMax(arr);// 第 5 行System.out.println(max);}publicstaticintgetMax(int[]array){intmax=array[0];// 第 10 行returnmax;}}执行过程: 1. 类加载:Demo.class 加载到方法区 2. main 方法被调用 → main 栈帧入栈 3. new int[] → 在堆中创建数组对象 {10,20,30} 4. arr 变量存储在 main 栈帧中,指向堆中的数组地址 5. getMax(arr) → getMax 栈帧入栈,压入 main 之上 6. array 变量存储在 getMax 栈帧中,指向同一个堆地址(地址拷贝) 7. getMax 执行完毕 → getMax 栈帧出栈(销毁局部变量) 8. main 继续执行 println → main 栈帧出栈 → 程序结束3. 类加载机制
类加载让字节码进入 JVM;初始化执行静态初始化逻辑。对象构造则发生在创建实例时,这几个时机要分开看。
类加载的三个阶段
加载 (Loading) → 连接 (Linking) → 初始化 (Initialization) │ ┌──────────┼──────────┐ │ │ │ ▼ ▼ ▼ 验证 准备 解析| 阶段 | 做什么 |
|---|---|
| 加载 | 读取.class文件,生成Class对象 |
| 验证 | 检查字节码格式、安全性(防止恶意代码) |
| 准备 | 为静态变量分配内存,赋默认值 |
| 解析 | 符号引用 → 直接引用(如String→ 实际内存地址) |
| 初始化 | 执行静态变量赋值和静态代码块 |
类加载器层次(双亲委派模型)
┌──────────────────────────┐ │ Bootstrap ClassLoader │ ← 加载核心类库 (rt.jar → java.base) │ (启动类加载器,C++ 实现) │ └──────────┬───────────────┘ │ 父 ▼ ┌──────────────────────────┐ │ Extension ClassLoader │ ← 加载扩展类库 (jre/lib/ext) │ (扩展类加载器) │ JDK 9+ 被 Platform ClassLoader 替代 └──────────┬───────────────┘ │ 父 ▼ ┌──────────────────────────┐ │ Application ClassLoader │ ← 加载 classpath 下的自定义类 │ (应用类加载器) │ └──────────────────────────┘双亲委派机制
加载请求 ──→ App ClassLoader → Ext ClassLoader → Bootstrap ClassLoader │ ┌───────────┘ ▼ 能找到?← 是 → 加载 │ ▼ 否 向下委派给子加载器 │ ▼ 都找不到 → ClassNotFoundException为什么要双亲委派?
- 安全性:核心类(如
java.lang.String)只能由 Bootstrap 加载,防止被恶意替换 - 避免重复加载:父加载器已加载的类,子加载器不再加载
4. 垃圾回收 (GC) 简介
GC 解决的是内存可回收性,并不负责及时关闭文件和网络连接。对象离开可达关系后何时回收,也不是程序能随口预约的。
什么是 GC?
GC 回收不再从 GC Roots 可达的堆对象;只互相引用、却整体不可达的一组对象也可能被回收。可回收不等于立即回收。
// C/C++ 手动管理int*p=malloc(sizeof(int)*100);// ... 使用 ...free(p);// 必须手动释放,忘了就内存泄漏// Java 自动管理int[]arr=newint[100];arr=null;// 失去引用// JVM 会在适当的时机自动回收 arr 原来指向的内存堆内存分代
┌───────────────────────────────────────────────┐ │ 堆内存 │ │ ┌─────────────────────┐ │ │ │ 新生代 (Young) │ ← Minor GC 频繁 │ │ │ ┌────┬────┬───────┐ │ │ │ │ │Eden│ S0 │ S1 │ │ 新对象在 Eden 创建 │ │ │ │ │ │ │ │ 存活 → Survivor │ │ │ └────┴────┴───────┘ │ 多次存活 → 老年代 │ │ └─────────────────────┘ │ │ ┌─────────────────────┐ │ │ │ 老年代 (Old) │ ← Major GC 较慢 │ │ │ │ 大对象直接进入老年代 │ │ └─────────────────────┘ │ └───────────────────────────────────────────────┘| 区域 | 说明 |
|---|---|
| Eden | 新对象默认分配区,大部分对象"朝生夕死" |
| Survivor (S0/S1) | Eden 中经过一次 GC 存活的对象进入 Survivor |
| Old | 经过多次 GC 仍存活的对象晋升到老年代 |
| MetaSpace (元空间) | HotSpot 类元数据区域,使用本地内存,不是堆内的新生代或老年代 |
GC 算法简介
| 算法 | 思路 | 特点 |
|---|---|---|
| 标记-清除 | 标记垃圾 → 清除 | 简单但会产生内存碎片 |
| 标记-整理 | 标记垃圾 → 存活对象向一端移动 | 无碎片,但移动成本高 |
| 复制算法 | 内存分两块,存活对象复制到另一块 | 无碎片、效率高,浪费一半空间(新生代使用) |
| 分代收集 | 常见教学模型以新生代复制、老年代整理说明;并非所有收集器都如此 | 常见收集器会采用分代思想;具体分区和算法取决于收集器 |
5. 总结
先拿自己写过的程序解释运行过程。需要分析具体问题时,再深入源码和不同收集器。
| 知识点 | 核心要点 |
|---|---|
| 编译阶段 | javac→ 词法分析 → 语法分析 → 语义分析 → 生成.class |
| 运行阶段 | 类加载 → 字节码校验 → 解释执行 + JIT 编译 → GC 内存管理 |
| 栈 | 线程私有,存局部变量和方法调用,方法结束自动释放 |
| 堆 | 线程共享,存new出来的对象,GC 主要管理区域 |
| 方法区 | 线程共享的类级数据区域;HotSpot 元空间承载类元数据,不能把所有类相关值都画进去 |
| 类加载 | 加载 → 连接(验证+准备+解析) → 初始化 |
| 双亲委派 | 向上查找,向下加载;安全 + 避免重复加载 |
| GC | JVM 管理不再从 GC Roots 可达的堆对象,回收时机不固定,无需手动free |
| 分代回收 | Eden/Survivor/Old 是常见分代教学模型;具体收集器布局与算法需另行核对 |
| JIT 编译 | 热点代码直接编译为机器码,提升执行速度 |
看图:运行时区域与 JMM 分开学
这里展示的是规范层面的概念关系,未按实际物理布局或容量比例绘制。元空间属于特定实现的类元数据区域;并发中的 JMM 是另一套可见性与顺序规则。
小练习:把这一节变成自己的代码
写一个带静态初始化、构造器和普通方法的类,先预测打印顺序再运行。
卡住时再打开这条提示类初始化与每次创建对象不同;不要把静态代码块想成构造器的分身。
这一章怎样算学会了
重新写一个含静态初始化、构造器和普通方法的例子。先猜输出顺序再运行,分别说清类初始化和创建实例发生在什么时机。