很多 Java 初学者会写代码、会跑程序,但一问到对象到底放在哪、类什么时候完成初始化、GC 为什么能自动回收内存,往往就容易混在一起。理解 JVM,不是为了背概念,而是为了在排查内存问题、理解类加载行为和阅读框架源码时,知道每一步到底发生了什么。
这篇文章按 JVM 的三个核心视角展开:先看内存区域的职责划分,再看类从 .class 变成可用类型的过程,最后梳理 GC 如何识别垃圾、采用什么回收策略。读完之后,你至少能对“对象放哪、类怎么来、垃圾怎么清”这三件事形成一套清晰判断。
JVM 内存区域是怎么划分的
JVM 的整体设计,和操作系统对进程地址空间的划分类似。它不是把所有运行时数据都堆在一起,而是按用途拆成不同区域,各自承担不同职责。理解这部分,最直接的价值就是判断“某个数据究竟放在哪里”。
程序计数器:记录当前执行位置
程序计数器是一个很小的区域,作用很单一:记录当前线程正在执行到哪一条指令。它不负责存对象,也不参与方法调用管理,本质上就是一个位置记录器。
元数据区:保存类的结构信息
元数据区用于保存类被加载后的数据,也就是类相关的元信息。文中将其称为“类对象 .class 保存的位置”。需要注意的是,Java 8 之前它通常被称为方法区,之后命名发生了变化,这是学习资料里常见的一个版本差异。
栈:保存方法调用过程
栈主要用来保存方法调用关系。每次方法被调用,JVM 都会压入一个对应的栈帧;方法执行结束后,这个栈帧再弹出。调用链越深,栈里的内容就越多。
堆:保存通过 new 创建的对象
堆保存所有通过 new 创建的对象。这一点是理解 Java 内存分布的基础。
例如下面这句代码:
Test t = new Test()这里的 new Test() 创建出来的对象一定在堆里,但变量 t 放哪,要看它的身份:
- 如果
t是局部变量,它在栈中; - 如果
t是成员变量,它跟随对象一起位于堆中; - 如果
t是静态成员变量,它位于元数据区。
这也是很多人第一次接触 JVM 时最容易混淆的地方:对象本体和引用变量不一定在同一个区域。
类加载机制:类是怎样进入 JVM 的
Java 程序运行前,类并不是天然就在内存里。一个类需要从磁盘上的 .class 文件,转成 JVM 内部可以使用的结构,才能真正参与运行。这个过程通常概括为“加载、连接、初始化”三步。
类加载的三个阶段
- 加载:根据类的全限定名,也就是“包名 + 类名”,找到对应的
.class文件,把文件内容读取到内存中。这一步主要是把原始字节码拿进来。 - 连接:连接阶段还会继续拆成三个子步骤:
- 验证:检查
.class文件内容是否合法,并把合法内容转成结构化数据。 - 准备:为类对象申请内存空间。这里申请到的空间初始值是全 0,可以理解为先拿到一块空白区域。
- 解析:针对字符串常量进行初始化,并把解析后的字符串常量放入元数据区的常量池。
- 验证:检查
- 初始化:到了这一步,类才算真正完成初始化。静态变量会被赋予初始值,静态代码块也会开始执行。
如果只记一句话,可以记成:加载负责把字节码拿进来,连接负责校验和整理,初始化才开始真正执行类级别的初始化逻辑。
双亲委派模型为什么重要
类加载不只是“读文件”这么简单,它还涉及“谁来加载这个类”。JVM 默认提供三种类加载器,并通过父子关系组成一条委派链路:
BootstrapClassLoader:负责查找 Java 标准库目录。ExtensionClassLoader:负责查找 Java 扩展库目录。ApplicationClassLoader:负责查找第三方库和当前项目目录。
它的工作方式是这样的:当一个类需要被加载时,入口通常是 ApplicationClassLoader,但它不会立刻自己处理,而是先把任务向上委托给父加载器;父加载器再继续向上交给更高一级。
也就是说,会先由 BootstrapClassLoader 在自己的范围内查找;如果找不到,再逐层往下返回,由下一级继续查找。最终如果三层都找不到,才会抛出异常。
这套机制的意义很明确:避免同一个类被重复加载,也减少核心类库被业务代码“顶替”带来的冲突风险。理解双亲委派后,很多“为什么我自己写的类没有覆盖掉 JDK 同名类”的问题,就不难解释了。
垃圾回收先解决两个问题
Java 的垃圾回收(GC),核心就是处理堆上的对象内存。和 C 语言需要手动释放内存不同,Java 把“哪些对象已经没用了、哪些内存该释放”交给 JVM 自动完成。
从逻辑上看,GC 主要解决两件事:
- 先找到哪些对象是垃圾;
- 再决定用什么方式回收这些垃圾。
JVM 如何判断对象是不是垃圾
判断垃圾对象,文中提到两种思路:引用计数和可达性分析。Java 最终采用的是可达性分析。
为什么引用计数没有被 Java 采用
引用计数的做法很好理解:对象在 new 出来时,同时维护一个计数值,表示当前有多少引用指向它。每次引用发生赋值,计数就跟着变化;当计数变成 0,对象就可以视为垃圾。
但它有两个明显问题:
- 需要额外内存维护计数,带来额外开销;
- 无法很好处理循环引用。
典型场景是两个对象互相引用,但外部已经没有任何引用指向它们。此时它们的计数都不为 0,却实际上已经无法再被程序访问,也就无法被正确回收。
可达性分析的判断逻辑
可达性分析采用的是“从根出发做遍历”的方式。JVM 会从一些特定对象作为起点开始搜索,例如:
- 栈上的局部变量;
- 常量池引用的对象;
- 静态成员引用的对象。
从这些起点出发,只要某个对象还能被访问到,就会被标记为“可达”。等遍历结束后,所有没有被标记到的对象,就会被视为垃圾。
这种方式本质上是用遍历成本换取更准确的判断能力,因此也常被概括为“用时间换空间”。
GC 回收算法各自解决什么问题
找到垃圾之后,下一步才是“怎么回收”。不同回收算法解决的问题不一样,也各有代价。
标记-清除:实现直接,但会产生碎片
标记-清除的思路最直接:把垃圾对象标记出来,然后把对应内存释放掉。
问题也很明显。回收完成后,内存中可能留下很多不连续的小块空闲区域,也就是内存碎片。碎片一多,后面即使总空闲空间足够,也可能因为缺少连续大块内存而无法分配大对象。
复制算法:空间换时间,适合存活率低的区域
复制算法会把一块内存平分成两部分,每次只使用其中一半。GC 时,把仍然存活的对象复制到另一块空闲区域,然后一次性清掉原来的那一半。
它的优点是回收后内存比较规整,但缺点同样明显:
- 内存利用率低,通常只有一半可用;
- 如果存活对象很多,复制成本会明显上升。
标记-整理:减少碎片,但移动成本更高
标记-整理可以理解成先标记存活对象,再把这些对象整体向一端移动,最后清理边界外的连续空间。
它解决了碎片问题,但代价是对象移动本身成本较高,尤其是在对象很多、体积较大的情况下更明显。
分代回收为什么是 JVM 的实际选择
Java 实际采用的并不是单一算法,而是分代回收。它把不同区域的对象按“存活特征”分开处理,再组合使用复制算法和标记-整理算法。
新生代与老年代如何分工
分代回收里,堆通常会先划分为新生代和老年代。
一个对象刚创建出来时,GC 轮次为 0。每经历一轮 GC,轮次加 1。对象存活时间越长,通常越有可能继续存活,因此没有必要一直用高频方式反复扫描。基于这个特征,JVM 会让大多数新对象先在新生代活动,把长期存活的对象逐步晋升到老年代。
新生代内部的 8:1:1 结构
新生代又会继续划分为一个伊甸区和两个幸存区,比例大约是 8:1:1。
- 新创建的对象先进入伊甸区;
- 大部分对象撑不过第一轮 GC;
- 熬过回收的对象会通过复制算法进入某个幸存区;
- 之后如果继续存活,再在两个幸存区之间复制移动。
这套设计的前提是:绝大多数对象“生得快、死得也快”。因此,把复制算法放在新生代,通常能取得比较高的效率。
对象晋升与大对象的处理
当某个对象在幸存区经历了多轮 GC,说明它已经不是“短命对象”,就会被晋升到老年代。老年代使用的主要是标记-整理机制。
另外,文中还提到一种特殊情况:如果对象特别大,它会直接进入老年代。这样做的目的,是避免它在新生代里频繁复制,减少不必要的搬运成本。
理解 JVM,重点先抓这三条线
回过头看,JVM 的核心脉络其实可以收束成三条线:
- 内存区域决定了“数据放哪”;
- 类加载机制决定了“类怎么进入运行时”;
- 垃圾回收机制决定了“堆内存怎么被持续整理和释放”。
如果你已经能分清栈、堆、元数据区的职责,知道类初始化发生在加载和连接之后,也能解释新生代为什么适合复制算法、老年代为什么更常用标记-整理,那么对 JVM 的整体运行方式就已经建立起一套相当实用的理解框架。
















