JVM 核心原理入门:内存区域、类加载与垃圾回收
引言:JVM(Java 虚拟机)是 Java 能够"一次编写、到处运行"的基石,也是 Java 面试中绕不开的高频考点。本文系统梳理 JVM 的内存区域划分、类加载的双亲委派机制,以及垃圾回收的核心算法与主流回收器,帮助你建立起完整且扎实的 JVM 知识框架。
目录
- 为什么需要引入 JVM
- JVM 内存区域划分(程序计数器 / 栈 / 堆 / 元数据区)
- JVM 类加载机制(双亲委派)
- JVM 的垃圾回收(GC)(引用计数 / 可达性分析 / 分代 / 回收器)
Java 虚拟机(JVM)是 Java 平台的核心。它们三者之间的包含关系为:JDK 包含 JRE,JRE 包含 JVM。
如果你只运行 Java 程序,安装 JRE 即可;如果还要开发,才需要完整的 JDK。
本文内容偏"八股"型,主要考察概念理解与记忆。
0. 为什么需要引入 JVM
引入 JVM 的核心目的是实现跨平台:Java 程序编译后生成字节码,由不同操作系统上的 JVM 各自解释执行,从而屏蔽底层操作系统的差异,达到"一次编写、到处运行"。
1. JVM 内存区域划分
Java 程序运行过程中使用的内存,本质上都是 JVM 管理的内存。JVM 启动时向操作系统申请一大块内存,随后由 JVM 自行对这些内存进行区域划分与分类管理。
程序计数器
程序计数器仅保存一个数字,指向下一条将要执行的 Java 字节码指令的地址,这块内存由 JVM 通过软件方式维护。
栈、堆与元数据区
内存区域可进一步细分为:
- 虚拟机栈:供 Java 程序使用的栈,维护方法之间的调用关系。方法调用时,会创建一个"栈帧",其中保存了调用该方法时传入的实参、方法内部的局部变量、方法结束后返回上层方法的地址,以及返回值。
- 本地方法栈:供 C/C++ 代码使用。JVM 底层多由 C++ 实现,Java 代码向下调用的底层逻辑最终会进入本地方法栈。
- 堆(内存占用最大):存放我们
new出来的对象及其普通成员变量。 - 元数据区(旧版本称为"方法区"):存放类对象,以及被
static修饰的属性(类变量)。
这些区域都可能发生内存溢出:
- 栈溢出:通常是栈帧过多(如递归层级太深),或创建了过大的局部变量。
- 堆溢出:通常是
new得太多(如向集合类无限添加元素),需要定位是哪个对象被过度创建。
需要特别注意的是:堆和元数据区在整个 JVM 中是唯一的(线程共享),而程序计数器、虚拟机栈、本地方法栈都是"一个线程一份"(线程私有)。
2. JVM 类加载机制
类加载,是指把.class文件读取到内存中,并构建出对应类对象的过程。
2.1 类加载的流程
- 加载(Loading):根据代码中书写的全限定类名,在文件系统中找到对应的
.class文件。 - 验证(Verification):检查读到的二进制内容是否为合法的 Class 格式(确认它确实是一个
.class文件)。- 二进制文件通常会把开头四个字节固定为特定的"魔数"(由规范制定者约定)。
- 注意版本兼容:用 Java 17 编译的
.class文件无法在 Java 8 上运行,但 Java 8 编译出来的可以在 Java 17 上运行(向上兼容)。
- 准备(Preparation):为将要创建的类对象分配内存空间(在元数据区中申请),并将未初始化的字段统一置为 0。
- 字符串常量处理:把当前类用到的字符串常量放入运行时常量池,此时常量在池中就有了真实的存放地址。
- 解析与初始化:完成符号引用到直接引用的转换,并执行类的初始化逻辑。
类加载整体是**“懒加载”(Lazy Loading)**逻辑:
- 用到该类时才加载;
- 调用该类的静态方法或访问静态成员时触发加载;
- 加载子类时,会优先触发其父类的加载。
2.2 双亲委派模型
严格来说,称为"双亲委派"并不十分准确,更准确的描述应是单条父加载器链上的委派(有时也被戏称为"单亲委派")。
双亲委派出现在类加载的第一步("加载"阶段),其目的是找到对应的.class文件。这涉及到一个关键模块——类加载器(ClassLoader)。
- BootstrapClassLoader(启动类加载器):加载 Java 标准库中的类(如
java.lang.*)。 - ExtensionClassLoader(扩展类加载器,父为 1):加载 Java 扩展库中的类(如
javax.*扩展包)。 - ApplicationClassLoader(应用程序类加载器,父为 2):加载第三方 Jar 包以及当前项目的类。
类加载器层级结构
1) BootstrapClassLoader (启动类加载器) (负责加载 Java 标准库的类,如 java.lang.*) ↓ 父加载器 2) ExtensionClassLoader (扩展类加载器) (负责加载 Java 拓展库的类,如 javax.* 扩展包) ↓ 父加载器 3) ApplicationClassLoader (应用程序类加载器) (负责加载 第三方 Jar 包 和 你当前项目的 Class 文件)双亲委派加载流程
[3) ApplicationClassLoader] 收到全限定类名的加载请求 ⬇ 向上委托 [2) ExtensionClassLoader] 收到请求 ⬇ 向上委托 [1) BootstrapClassLoader] 收到请求 [1] 扫描自己负责的 Java 标准库目录寻找该类 ═══> 找到了? [是] 结束加载,返回 Class 对象。 ═══> 找不到? 将控制权交还给 [2]。 [2] 扫描自己负责的 Java 扩展库目录寻找该类 ═══> 找到了? [是] 结束加载,返回 Class 对象。 ═══> 找不到? 将控制权交还给 [3]。 [3] 扫描自己负责的项目 Classpath 路径(第三方库及当前项目代码)寻找该类 ═══> 找到了? [是] 结束加载,返回 Class 对象。 ═══> 找不到? 抛出异常:java.lang.ClassNotFoundException!之所以这样设计,是因为 Java 的类加载器之间没有"子类"标识——父加载器并不认识自己的子加载器,因此委派只能自下而上,而非自上而下。其本质是一段递归调用的逻辑。
下面是 JDK 源码中简化后的核心逻辑:
protectedClass<?>loadClass(Stringname,booleanresolve){// 1. 检查缓存,已加载则直接返回// 2. 向上委托:先交由父加载器尝试加载Class<?>c=parent.loadClass(name,resolve);// 3. 父加载器加载失败时,自己再去查找并加载if(c==null){c=findClass(name);// 这才是最真正的"加载"动作}returnc;}整个调用过程类似a → b → c → b → a的回溯,本质上是一段DFS(深度优先搜索)逻辑。
3. JVM 的垃圾回收(GC)
3.1 为什么要 GC
GC 主要解决的是内存泄漏问题。
- 在 C 语言中:局部变量随栈帧自动释放;全局变量不主动释放,进程结束时由系统回收;通过
malloc申请的内存属于堆区,需要程序员手动free释放。 - 如果只申请不释放,最终会出现没有空闲内存可分配的情况,导致内存申请失败,引发 Bug。
- C++ 引入了智能指针机制,在一定程度上缓解了内存泄漏问题。
而 JVM 的做法是:专门指派一些线程,周期性地扫描new出来的对象,自动判断哪些对象已经不再使用,并自行将其释放。这种方式自然会带来更大的运行时开销。
3.2 回收哪些区域
- 程序计数器:不需要回收,它有自己的固定位置。
- 栈:不需要回收,随方法调用/返回自动管理。
- 堆:是 GC 的主要战场。
- 元数据区:类对象一般只加载、很少卸载。
堆上存放的主要是对象。需要强调:GC 回收以"对象"为最小单位(具有原子性),只会整体回收一个对象,而不会只回收对象的一部分。
3.3 如何判断对象是否为垃圾
GC 从"引用"入手来判断对象是否可回收。例如,一个对象被引用变量 s 指向,而 s 又关联着 a、b、c 三个引用。
3.3.1 引用计数法(Java 未采用,Python、PHP 使用)
每个对象内部持有一个"隐式"的计数器成员,每当发生一次引用赋值,该计数就加 1;引用失效时减 1。当计数降为 0,即可释放。
该方法有两个明显缺点:
- 额外消耗内存空间:每个对象都要额外保存一个计数。若对象本身很小(如仅一个
int字段),引用计数的开销可能占到对象总大小的很大比例。 - 无法解决循环引用问题。
classTest{Testt;}Testa=newTest();// a 的引用计数 t1 = 1Testb=newTest();// b 的引用计数 t2 = 1a.t=b;// t2++b.t=a;// t1++a=null;// t1--b=null;// t2--此时 a、b 本应被回收,但由于彼此通过t字段互相引用,t1、t2的计数都仍为 1,导致无法被回收。
3.3.2 可达性分析(Java 采用)
在一段 Java 代码中,一系列对象之间通过引用形成关联,整体构成类似树形的结构。
classTest{Aa=newA();Bb=newB();Cc=newC();}Testt=newTest();classA{Dd=newD();Ee=newE();}classB{Ff=newF();Gg=newG();}可达性分析从**根节点(GC Roots)**出发,尝试遍历整棵对象引用树;遍历过程中经过的对象都被标记为"可达",其余未被标记的对象即为"不可达",可以作为垃圾回收。
可作为GC Roots的对象包括:
- 栈上的局部变量;
- 常量池引用所指向的对象;
- 所有引用类型的静态成员。
一轮 GC 就是把所有 GC Roots 尽可能遍历完,从而识别出哪些对象是垃圾。
3.3.3 识别垃圾后如何释放
- 标记-清除(Mark-Sweep):直接把标记出的垃圾释放掉,但会产生内存碎片。其弊端在于:总内存看似充足,却可能因碎片过多而申请不到连续的大块内存。
- 复制算法(Copying):用以解决内存碎片问题。把内存一分为二,每次只使用其中一半(如 A、B 两部分,A 中有对象 1~5),其中 1、3 是垃圾,就把存活的 2、4、5 复制到 B,然后清空 A。缺点是空间利用率低,且存活对象多时复制开销大。
- 标记-整理(Mark-Compact):类似顺序表删除中间元素,将存活对象向一端靠拢、压缩,从而减少碎片。
JVM 最终将以上三种思路合三为一,构成了一套更复杂的综合方案。
3.3.4 分代回收(Generational Collection)
分代回收根据对象的生存特点采取不同的回收策略。其核心经验规律是:对象的"年龄"越大,继续存活下去的概率也越大。
JVM 把整个堆分为两大部分——新生代(Young Generation)和老年代(Old Generation):
- 针对老年代的 GC 称为Major GC / Old GC(开销大、频率低);
- 针对新生代的 GC 称为Minor GC(开销小、频率高,因为 Eden 区很快被填满就会触发);
- 两者合在一起称为Full GC。
新生代进一步细分为:伊甸区(Eden,占 8)、幸存区(Survivor,占 1)、幸存区(Survivor,占 1)。
分代回收的流程如下:
- 新
new出来的对象先放入伊甸区(Eden); - 第一轮 GC 会淘汰掉绝大部分对象,存活的进入幸存区;
- 熬过一轮 GC 的对象还会继续接受筛选,未淘汰的通过复制算法转移到另一个幸存区(因该区域较小,复制带来的内存消耗也少);
- 每熬过一轮 GC,对象"年龄"+1;年龄达到一定阈值后,被拷贝到老年代;
- 进入老年代后,GC 频率显著降低,主要通过标记-整理算法来回收。
此外,如果一个对象特别大,会直接进入老年代。
以上只是一个简化版模型,实际实现更为复杂。JVM 提供了多种垃圾回收器:
- CMS:尽可能多线程并发标记,尽量减少对业务线程的影响;
- G1:能处理内存空间特别大的场景,把堆划分为多个 Region,每次只回收其中一部分;
- ZGC:尽可能让 GC 对业务逻辑的停顿时间极短。
3.4 各垃圾回收器对比(面试常考)
| 回收器 | 核心算法 / 特点 | 适用场景 | STW(停顿)特点 |
|---|---|---|---|
| Serial | 单线程回收(新生代复制 + 老年代标记-整理) | 客户端程序、单核、小堆 | 全程 STW,停顿长但实现简单 |
| Parallel(ParNew / Parallel Old) | 多线程并行回收,追求吞吐 | 多核、后台计算,吞吐优先 | 仍 STW,关注吞吐量而非延迟 |
| CMS(Concurrent Mark Sweep) | 与业务线程并发标记清除 | 响应时间敏感的老年代 | 仅初始标记 / 重新标记短暂停顿;易产生内存碎片 |
| G1(Garbage First) | 把堆切成一个个 Region,可预测停顿 | 大堆(数 GB ~ 数十 GB) | 可设停顿目标(MaxGCPauseMillis),做 Mixed GC |
| ZGC | 着色指针 + 读屏障,几乎全阶段并发 | 超大堆、超低延迟 | 停顿< 10ms,且几乎不随堆增大而增长 |
记忆顺序:Serial(单线程)→ Parallel(多线程吞吐)→ CMS(并发低延迟但碎片)→ G1(可预测停顿,主流)→ ZGC(极致低延迟)。
新生代用 Minor GC(高频、复制算法),老年代用 Major/Full GC(低频、标记-整理),这是分代回收的默认节奏。
小结
本文围绕 JVM 的三大核心主题展开:
- 内存区域划分:JVM 从操作系统申请大块内存并自行管理,分为线程共享的堆、元数据区,以及线程私有的程序计数器、虚拟机栈、本地方法栈;其中堆最容易发生溢出,也是 GC 的主战场。
- 类加载机制:类加载包含加载、验证、准备、解析、初始化等阶段,并遵循"懒加载"原则;双亲委派模型通过逐级向上委派、再向下回退的递归(DFS)逻辑查找并加载
.class文件,保障了类加载的安全与有序。 - 垃圾回收(GC):Java 采用可达性分析(而非引用计数)判断垃圾,结合标记-清除、复制、标记-整理三种思路,并进一步演化为分代回收(新生代 Minor GC + 老年代 Major/Full GC);Serial、Parallel、CMS、G1、ZGC 等回收器则在吞吐量与停顿时间之间不断权衡演进。
掌握这些基础概念,既是理解 Java 程序运行机制的关键,也是应对技术面试的重要基石。