ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JVM 核心原理精讲:内存划分、双亲委派与垃圾回收一文搞懂

JVM 核心原理精讲:内存划分、双亲委派与垃圾回收一文搞懂

JVM 核心原理入门:内存区域、类加载与垃圾回收

引言:JVM(Java 虚拟机)是 Java 能够"一次编写、到处运行"的基石,也是 Java 面试中绕不开的高频考点。本文系统梳理 JVM 的内存区域划分、类加载的双亲委派机制,以及垃圾回收的核心算法与主流回收器,帮助你建立起完整且扎实的 JVM 知识框架。

目录

    1. 为什么需要引入 JVM
    1. JVM 内存区域划分(程序计数器 / 栈 / 堆 / 元数据区)
    1. JVM 类加载机制(双亲委派)
    1. JVM 的垃圾回收(GC)(引用计数 / 可达性分析 / 分代 / 回收器)

Java 虚拟机(JVM)是 Java 平台的核心。它们三者之间的包含关系为:JDK 包含 JRE,JRE 包含 JVM

如果你只运行 Java 程序,安装 JRE 即可;如果还要开发,才需要完整的 JDK。

JDK
Java开发工具包

JRE
Java运行时环境

JVM
Java虚拟机

开发工具
如: javac编译器

核心类库
如: rt.jar

本文内容偏"八股"型,主要考察概念理解与记忆。

0. 为什么需要引入 JVM

引入 JVM 的核心目的是实现跨平台:Java 程序编译后生成字节码,由不同操作系统上的 JVM 各自解释执行,从而屏蔽底层操作系统的差异,达到"一次编写、到处运行"。

1. JVM 内存区域划分

Java 程序运行过程中使用的内存,本质上都是 JVM 管理的内存。JVM 启动时向操作系统申请一大块内存,随后由 JVM 自行对这些内存进行区域划分与分类管理。

线程私有区域 (Thread Private)

本地方法栈
(Native Method Stacks)
执行 Native 方法

程序计数器
(Program Counter Register)
记录当前线程执行的字节码行号

虚拟机栈
(JVM Stacks)
─────────────────────
栈帧-1 (方法A): 局部变量表 | 操作数栈 | 动态链接 | 返回地址
栈帧-2 (方法B): 局部变量表 | 操作数栈 | 动态链接 | 返回地址
栈帧-3 (方法C): 局部变量表 | 操作数栈 | 动态链接 | 返回地址

线程共享区域 (Thread Shared)

堆区 (Heap)
─────────────────────
Young区: [Eden] [S0] [S1]
Old区: [Old 区]

元数据区 (Metaspace)
─────────────────────
1. [Klass] 类元信息
类的结构信息(类名、父类、接口、字段等)

2. [方法元信息]
方法的字节码、异常表、访问标志等

3. [运行时常量池]
编译期生成的字面量、符号引用(类/字段/方法)

4. [注解/其他]
类、方法、字段上的注解元数据

程序计数器

程序计数器仅保存一个数字,指向下一条将要执行的 Java 字节码指令的地址,这块内存由 JVM 通过软件方式维护。

栈、堆与元数据区

内存区域可进一步细分为:

  1. 虚拟机栈:供 Java 程序使用的栈,维护方法之间的调用关系。方法调用时,会创建一个"栈帧",其中保存了调用该方法时传入的实参、方法内部的局部变量、方法结束后返回上层方法的地址,以及返回值。
  2. 本地方法栈:供 C/C++ 代码使用。JVM 底层多由 C++ 实现,Java 代码向下调用的底层逻辑最终会进入本地方法栈。
  3. 堆(内存占用最大):存放我们new出来的对象及其普通成员变量。
  4. 元数据区(旧版本称为"方法区"):存放类对象,以及被static修饰的属性(类变量)。

这些区域都可能发生内存溢出:

  • 栈溢出:通常是栈帧过多(如递归层级太深),或创建了过大的局部变量。
  • 堆溢出:通常是new得太多(如向集合类无限添加元素),需要定位是哪个对象被过度创建。

需要特别注意的是:堆和元数据区在整个 JVM 中是唯一的(线程共享),而程序计数器、虚拟机栈、本地方法栈都是"一个线程一份"(线程私有)。

2. JVM 类加载机制

类加载,是指把.class文件读取到内存中,并构建出对应类对象的过程。

2.1 类加载的流程

  1. 加载(Loading):根据代码中书写的全限定类名,在文件系统中找到对应的.class文件。
  2. 验证(Verification):检查读到的二进制内容是否为合法的 Class 格式(确认它确实是一个.class文件)。
    • 二进制文件通常会把开头四个字节固定为特定的"魔数"(由规范制定者约定)。
    • 注意版本兼容:用 Java 17 编译的.class文件无法在 Java 8 上运行,但 Java 8 编译出来的可以在 Java 17 上运行(向上兼容)。
  3. 准备(Preparation):为将要创建的类对象分配内存空间(在元数据区中申请),并将未初始化的字段统一置为 0。
  4. 字符串常量处理:把当前类用到的字符串常量放入运行时常量池,此时常量在池中就有了真实的存放地址。
  5. 解析与初始化:完成符号引用到直接引用的转换,并执行类的初始化逻辑。

类加载整体是**“懒加载”(Lazy Loading)**逻辑:

  1. 用到该类时才加载;
  2. 调用该类的静态方法或访问静态成员时触发加载;
  3. 加载子类时,会优先触发其父类的加载。

2.2 双亲委派模型

严格来说,称为"双亲委派"并不十分准确,更准确的描述应是单条父加载器链上的委派(有时也被戏称为"单亲委派")。

双亲委派出现在类加载的第一步("加载"阶段),其目的是找到对应的.class文件。这涉及到一个关键模块——类加载器(ClassLoader)。

  1. BootstrapClassLoader(启动类加载器):加载 Java 标准库中的类(如java.lang.*)。
  2. ExtensionClassLoader(扩展类加载器,父为 1):加载 Java 扩展库中的类(如javax.*扩展包)。
  3. 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,即可释放。

该方法有两个明显缺点:

  1. 额外消耗内存空间:每个对象都要额外保存一个计数。若对象本身很小(如仅一个int字段),引用计数的开销可能占到对象总大小的很大比例。
  2. 无法解决循环引用问题
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字段互相引用,t1t2的计数都仍为 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();}

t

a

b

c

d

e

f

g

可达性分析从**根节点(GC Roots)**出发,尝试遍历整棵对象引用树;遍历过程中经过的对象都被标记为"可达",其余未被标记的对象即为"不可达",可以作为垃圾回收。

可作为GC Roots的对象包括:

  1. 栈上的局部变量;
  2. 常量池引用所指向的对象;
  3. 所有引用类型的静态成员。

一轮 GC 就是把所有 GC Roots 尽可能遍历完,从而识别出哪些对象是垃圾。

3.3.3 识别垃圾后如何释放
  1. 标记-清除(Mark-Sweep):直接把标记出的垃圾释放掉,但会产生内存碎片。其弊端在于:总内存看似充足,却可能因碎片过多而申请不到连续的大块内存。
  2. 复制算法(Copying):用以解决内存碎片问题。把内存一分为二,每次只使用其中一半(如 A、B 两部分,A 中有对象 1~5),其中 1、3 是垃圾,就把存活的 2、4、5 复制到 B,然后清空 A。缺点是空间利用率低,且存活对象多时复制开销大。
  3. 标记-整理(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)

分代回收的流程如下:

  1. new出来的对象先放入伊甸区(Eden);
  2. 第一轮 GC 会淘汰掉绝大部分对象,存活的进入幸存区;
  3. 熬过一轮 GC 的对象还会继续接受筛选,未淘汰的通过复制算法转移到另一个幸存区(因该区域较小,复制带来的内存消耗也少);
  4. 每熬过一轮 GC,对象"年龄"+1;年龄达到一定阈值后,被拷贝到老年代;
  5. 进入老年代后,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 的三大核心主题展开:

  1. 内存区域划分:JVM 从操作系统申请大块内存并自行管理,分为线程共享的堆、元数据区,以及线程私有的程序计数器、虚拟机栈、本地方法栈;其中堆最容易发生溢出,也是 GC 的主战场。
  2. 类加载机制:类加载包含加载、验证、准备、解析、初始化等阶段,并遵循"懒加载"原则;双亲委派模型通过逐级向上委派、再向下回退的递归(DFS)逻辑查找并加载.class文件,保障了类加载的安全与有序。
  3. 垃圾回收(GC):Java 采用可达性分析(而非引用计数)判断垃圾,结合标记-清除、复制、标记-整理三种思路,并进一步演化为分代回收(新生代 Minor GC + 老年代 Major/Full GC);Serial、Parallel、CMS、G1、ZGC 等回收器则在吞吐量与停顿时间之间不断权衡演进。

掌握这些基础概念,既是理解 Java 程序运行机制的关键,也是应对技术面试的重要基石。

返回列表