本文是一份 JVM 学习笔记,围绕三个主题展开:JVM 内存区域划分 、类加载机制 和 JVM 垃圾回收。内容以概念梳理为主,适合作为复习提纲快速过一遍。
一、JVM 内存区域划分
每次运行 Java 程序,本质上就创建了一个对应的 JVM,每个进程内部都包含了 JVM。
JVM 启动的时候,从操作系统申请了一大块内存,应用程序后续需要使用的时候,就可以从 JVM 的内存进行分配。
下面先看 JVM 内存结构的整体划分。

1. 各内存区域说明
| 内存区域 | 说明 |
|---|---|
| 程序计数器 | 很小的区域,只保存一个数据,即下一条要执行的 Java 字节码指令的地址。 |
| 虚拟机栈 | 给 Java 程序使用的栈,维护了方法调用之间的关系(局部变量)。 |
| 本地方法栈 | JVM 的底层是 C++ 实现的,需要单独下载 JVM 的源码来查看,官网 JDK、JVM 是看不到的。 |
| 堆 | new 的对象(普通的成员变量)。 |
| 方法区(元数据区) | .java 文件编写类和方法,编译后生成 .class 二进制的字节码文件;存放类对象、static 修饰的属性。 |
2. 内存溢出
有些情况下,内存会溢出:
- 栈溢出:包含了方法的调用关系(栈帧)太多了。
- 堆溢出 :
new的对象太多了。
3. 内存区域的线程归属
针对上面这些内存区域:
- 堆和方法区:一个进程中只有一份。
- 程序计数器和栈:存在多份,每个线程都拥有一份自己的。
二、(最重要)类加载的机制
类加载就是把 .class 文件读取放到内存中,构造出类对象的过程。
1. 类加载的流程
官方文档分为 5 个步骤(也可以认为是 3 步,因为其中的第二步又分为了 3 步)。
- 加载 :全限定类名,即 Java 代码中要使用某个类需要
import。把.class文件找到,打开文件,并且把里面的数据读取到内存中,根据代码中的"全限定类名",找到相对应的.class文件。 - 验证:根据读取到的二进制内容,验证是否是合法的格式。
- 准备 :给要创建的类对象分配内存空间。在元数据区,Java 默认把新申请的、未初始化的内存全都置为 0,此时尝试获取
static成员,得到的值也为 0。 - 针对字符串常量要直接引用 :需要把当前的
.class中的字符串常量也放到内存中,此时字符串就有了起始地址,顺便可以把这些地址取出来,放到对应的字符串的地方了。 - 初始化:针对类对象进行初始化操作,初始化类的静态成员,执行静态代码块,对父类的加载。
2. 类加载的触发时机
用的时候才会加载,几个常用的地方:
new这个类的实例。- 调用这个类的静态方法/访问静态成员。
- 针对子类的加载,也会触发父类的加载。
3. 双亲委派模型
出现在类加载的第一步,用来找 .class 文件,这时候就涉及到一个模块"类加载器",JVM 中默认包含了 3 个类加载器。
| 类加载器 | 负责加载的内容 |
|---|---|
BootstrapClassLoader |
负责加载 Java 标准库中的类 |
ExtensionClassLoader |
负责加载 Java 扩展库中的类(JDK 厂商做出的扩展) |
ApplicationClassLoader |
负责加载第三方库,以及你当前项目中的类(GitHub 上面自己找的一些库) |
本质上约定了"类加载的优先级":
- 标准库最先加载。
- 其次是扩展库。
- 最后才是第三方库/当前项目。
三、JVM 的垃圾回收
Java 的 GC 要回收的内存是堆上存放的对象,GC 以对象为单位进行内存回收的,一个对象要么不释放,要么整个释放,不会出现释放一半的情况。
垃圾回收具体是怎么进行的?可以分成两步:找出谁是垃圾,以及释放垃圾对象对应的内存。
1. 找出谁是垃圾(不再使用的对象)
在 Java 中,使用对象只有一种方式,那就是引用的方式,如果一个对象没有引用指向,此时就可以认为这个对象不再使用了。
(1)第一种方案:引用计数
引用计数不是 Java 的方案,是 Python 的方案。

给每个对象身上安排一个空间,这个空间存储一个整数,表示指向这个对象引用的个数,围绕对象进行"引用复制"就会更新这个计数器,如果计数器为 0 就会释放。
但是这个方案有两个缺陷:
- 会消耗更多的内存空间。如果对象内存空间比较小,4 个字节的话,计数器就可能有两个字节,此时计数器就占据了 50%,对象比较大就可以忽略不计。
- 产生循环引用会出现误判。

A 对象里面保存 B 的引用,B 对象里面保存了 A 的引用,此时两个对象不为 0,就不会被释放,但是也无法被使用。
(2)第二种方案:可达性分析
在一个 Java 代码中,一系列对象引用存在一定关联的,类似于树状结构。

可达性分析就是从树的根节点(可能有多个)出发,尝试遍历这个对象树,遍历过程中凡是已经经过的对象视为"可达",剩下的就是可回收对象,这是周期性进行的。
GC Roots 包括:
- 栈上的局部变量。
- 常量池引用指向的对象。
- 所有的引用类型静态成员。
缺点:需要消耗更多的 CPU 资源和更多的时间。
2. 释放垃圾对象对应的内存
识别出垃圾后,如何释放内存呢?常见有三种算法。
(1)标记清除

进程中,哪些对象是垃圾是随机的,很可能产生大量的内存碎片。
这些被释放的内存在地址上是"离散"的,但是申请内存都是申请"连续内存",总的可利用空间很充足,但是尝试申请一大块空间就会失败。
(2)复制算法
可以有效解决内存碎片问题。

同一时刻,只使用其中一半空间,把不是垃圾的对象复制到另一边。
缺点:
- 空间利用率很低。
- 如果当前存活对象很多,复制开销就会增大。
(3)标记整理

把对象回收了以后,一个个往前搬运,对于空间利用率来说得到了改善。
缺点:开销同样很大。
三种回收算法对比:
| 算法 | 做法 | 缺点 |
|---|---|---|
| 标记清除 | 标记出垃圾对象后进行回收 | 释放的内存在地址上是离散的,会产生大量内存碎片 |
| 复制算法 | 同一时刻只使用一半空间,把不是垃圾的对象复制到另一边 | 空间利用率很低;如果当前存活对象很多,复制开销就会增大 |
| 标记整理 | 把对象回收后,把存活对象一个个往前搬运 | 开销同样很大 |
3. 分代回收
分代回收根据不同对象的情况/特点,采取不同的方案。

- 把新创建的对象放入伊甸区。
- 伊甸区的对象经过第一轮 GC 后,淘汰绝大部分;没有淘汰的对象,通过复制算法进入其中一个幸存区,每次只用其中一个区。
- 下一轮的 GC 也会针对幸存区扫描,把幸存区里面被淘汰的对象回收,没有淘汰的对象通过复制算法进入另一个幸存区,解决了复制算法开销过大的问题。
- 随着每一轮 GC 的进行,对象会在幸存区里面反复拷贝,年龄也会每次 +1,经过一定时间,计数到达了阈值,就会被转入年龄大的区域。
- 对象进入年龄大的区域以后,针对这个区域的 GC 频率就会减少很多,这时候就会采取标记整理的方案,由于频率比较低,所以开销还能接受。
- 如果对象内存占据比较大,可以直接进入年龄大的区域。
上述分代回收并不是真实情况,而是简化情况。
4. 常见的垃圾回收器
有不同的垃圾回收器可以使用:
| 垃圾回收器 | 特点 |
|---|---|
CMS |
在不影响业务逻辑的情况下,能够尽可能多线程标记 |
G1 |
能够处理内存空间比较大的情况,把整个内存划分出更多的区域,一次 GC 只回收其中的一部分区域 |
ZGC |
目前还没被 JVM 正式采纳,尽量使垃圾回收对于业务逻辑的影响时间缩短 |
总结
JVM 的学习可以沿着"内存怎么划分、类怎么加载、垃圾怎么回收"这条主线展开,先建立整体结构,再逐步深入每个细节。