从类加载到垃圾回收:今天把 JVM、双亲委派和 G1 串起来了
前言
今天主要复习 JVM,包括一个类从字节码进入内存后的初始化过程、类加载器的双亲委派机制,以及 G1 垃圾回收器的工作原理。
以前对这些知识点的印象比较零散:知道 Java 代码会编译成字节码,也知道 JVM 里有堆、栈和垃圾回收器,但面试官如果继续追问“类什么时候初始化”“父类和子类谁先初始化”“双亲委派有什么用”“G1 为什么可以控制停顿时间”,就很容易讲乱。
这次我想把它们放在同一条链路里理解:JVM 先加载并初始化类,程序运行时创建对象,对象主要进入堆内存,最后由垃圾回收器识别并回收不再使用的对象。
JVM 到底是什么
JVM,也就是 Java Virtual Machine,负责加载并执行 Java 字节码,同时提供内存管理、垃圾回收、运行时优化和跨平台能力。
Java 源代码的执行过程可以简单表示为:
.java 源文件 ↓ javac 编译 .class 字节码 ↓ 类加载器加载 JVM 运行时数据区 ↓ 解释执行或即时编译 本地机器指令Java 所说的“一次编译,到处运行”,并不是字节码可以脱离环境直接运行,而是不同操作系统提供了对应的 JVM,统一执行同一种字节码格式。
JVM 运行时数据区主要包括:
- 堆:存放绝大多数对象实例和数组,是垃圾回收的主要区域。
- Java 虚拟机栈:线程私有,每次方法调用都会创建栈帧。
- 程序计数器:线程私有,记录当前线程执行到的字节码位置。
- 本地方法栈:为 Native 方法服务。
- 方法区:保存类信息、运行时常量池、字段和方法元数据等。HotSpot 从 JDK 8 开始使用元空间实现方法区。
类从字节码到可以使用经历了什么
一个类的生命周期通常包括加载、连接、初始化、使用和卸载。连接阶段又可以分成验证、准备和解析。
加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载 └─────── 连接 ───────┘其中解析在某些情况下可以推迟到真正使用符号引用时进行,所以实际执行顺序不一定能简单理解成所有类都严格一次走完。
加载
加载阶段主要完成三件事:
- 根据类的全限定名找到对应的二进制字节流。
- 把字节流表示的静态存储结构转换成 JVM 方法区中的运行时数据结构。
- 在内存中生成一个代表这个类的
java.lang.Class对象,作为访问类元数据的入口。
字节码不一定只能来自本地.class文件,也可能来自 JAR 包、网络、动态代理或运行时生成的字节码。
验证
验证阶段用于检查字节码是否符合 JVM 规范,避免非法字节码破坏运行环境。
它会涉及文件格式、元数据、字节码指令和符号引用等方面。例如检查魔数是否正确、继承关系是否合法、操作数栈类型是否匹配。
准备
准备阶段为类变量,也就是static变量分配内存,并设置初始零值。
例如:
publicclassUserConfig{privatestaticintmaxUser=100;}在准备阶段,maxUser通常先得到默认值0,真正赋值为100要等到初始化阶段执行类初始化方法。
如果字段是编译期就能确定的常量,情况有所不同:
privatestaticfinalintMAX_USER=100;它带有常量值属性,准备阶段就可以被赋为100。
解析
解析阶段把运行时常量池中的符号引用替换成直接引用。
符号引用可以理解为通过名称描述目标,例如某个类名或方法名;直接引用则是能够直接定位到目标的指针、句柄或偏移量。
初始化
初始化阶段才真正执行类变量赋值和静态代码块。编译器会按源码中的出现顺序收集这些语句,生成类初始化方法<clinit>()。
例如:
publicclassDemo{staticinta=10;static{a=20;b=30;}staticintb=40;}初始化完成后,a是20,b是40。虽然静态代码块先给b赋了30,但后面的静态变量赋值又把它改成了40。
JVM 会保证一个类的<clinit>()在多线程环境中被正确同步。同一个类只会初始化一次,其他线程需要等待初始化完成。
哪些情况会触发类初始化
常见的主动使用包括:
- 使用
new创建对象。 - 读取或设置类的静态字段,编译期常量除外。
- 调用类的静态方法。
- 使用反射对类进行调用。
- 初始化子类时发现父类还没有初始化。
- JVM 启动时初始化包含
main()方法的主类。
下面这些情况通常不会触发目标类初始化:
- 通过子类引用父类定义的静态字段,只初始化真正声明字段的父类。
- 创建某个类的数组,只创建数组类型,不初始化数组元素对应的类。
- 访问编译期常量,因为常量可能已经被编译进调用类的常量池。
例如:
classParent{static{System.out.println("Parent 初始化");}staticintvalue=10;}classChildextendsParent{static{System.out.println("Child 初始化");}}publicclassMain{publicstaticvoidmain(String[]args){System.out.println(Child.value);}}value实际定义在Parent中,所以这里会触发Parent初始化,但不会因为通过Child访问就必然初始化Child。
类初始化和对象初始化不要混淆
类初始化执行的是静态变量赋值和静态代码块,对应<clinit>()。对象初始化则发生在执行new创建实例时,涉及实例字段赋值、实例代码块和构造方法,对应实例初始化方法<init>()。
创建子类对象时,大致顺序是:
- 父类进行类初始化。
- 子类进行类初始化。
- 为对象分配内存并设置字段默认值。
- 执行父类实例字段赋值和实例代码块。
- 执行父类构造方法。
- 执行子类实例字段赋值和实例代码块。
- 执行子类构造方法。
静态部分通常只在类第一次主动使用时执行一次,而实例部分每创建一个新对象都会执行。
JVM 中有哪些类加载器
Java 中常见的类加载器可以分为:
- 启动类加载器:加载 Java 核心类库,由 JVM 底层实现。
- 平台类加载器:加载平台相关的标准类库。在 JDK 8 及更早版本中,常见说法是扩展类加载器。
- 应用程序类加载器:加载应用类路径下的类,通常也是默认类加载器。
- 自定义类加载器:继承
ClassLoader,实现特殊来源或隔离需求下的类加载。
判断两个类是否相同,不能只看全限定类名,还要看加载它们的类加载器。同一个.class文件如果由两个互相独立的类加载器加载,在 JVM 看来可能是两个不同的类型。
什么是双亲委派机制
双亲委派并不是简单地由父加载器直接完成所有加载,而是一种委派顺序。
当类加载器收到类加载请求时,它通常不会立刻自己查找类,而是先把请求交给父加载器。请求会逐层向上委派,只有父加载器无法完成加载时,当前加载器才尝试在自己的范围内寻找并加载这个类。
自定义类加载器 ↓ 委派 应用程序类加载器 ↓ 委派 平台类加载器 ↓ 委派 启动类加载器 父加载器找不到后,再逐层返回并尝试加载这里的“父子关系”通常是组合关系,不是 Java 类继承关系。
双亲委派解决了什么问题
防止核心类被重复加载
如果所有类加载器都各自加载一份java.lang.String,系统中就可能出现多份互不相同的核心类型,整个类型体系会变得混乱。
通过向上委派,核心类优先由启动类加载器加载,应用程序里的多个类加载器可以共享同一份基础类型。
保护核心类库
即使项目中自己编写一个全限定名为java.lang.String的类,按照双亲委派流程,请求也会优先交给上层加载器,不能轻易替换 JVM 已经提供的核心实现。
不过双亲委派是 Java 类加载体系的重要约定,不应该把它理解成全部安全边界。真正的安全还需要依靠模块、权限、字节码验证和运行环境等机制。
双亲委派可以被打破吗
可以。有些场景需要父加载器加载由子加载器提供的实现,或者需要实现插件隔离、热部署和同类不同版本共存。
常见例子包括:
- JDBC 的服务提供者加载。
- Tomcat 等容器对不同 Web 应用进行类隔离。
- OSGi 模块化加载。
- 自定义插件系统和热部署框架。
自定义类加载器如果只需要改变查找类文件的方式,通常重写findClass()即可,仍然保留双亲委派。如果直接重写loadClass()并改变委派顺序,就要非常谨慎,否则可能造成类型冲突、重复加载和核心类安全问题。
JVM 如何判断对象可以被回收
垃圾回收的第一步不是马上清理内存,而是判断哪些对象仍然存活。
现代 JVM 主要使用可达性分析:从一组 GC Roots 出发,沿着引用关系向下搜索。能够到达的对象被认为仍然存活,无法到达的对象才可能被回收。
常见的 GC Roots 包括:
- 虚拟机栈中局部变量引用的对象。
- 类的静态字段引用的对象。
- 运行时常量引用的对象。
- JNI 本地方法引用的对象。
- JVM 内部持有的部分对象和同步锁持有的对象。
可达性分析可以处理循环引用。例如对象 A 引用 B,B 又引用 A,只要它们与 GC Roots 之间已经没有路径,两者仍然可以被回收。
常见的垃圾回收算法
标记—清除
先标记存活对象,再清理未被标记的对象。
实现直接,但回收后会产生大量不连续的内存碎片,后续分配大对象时可能找不到足够大的连续空间。
标记—复制
把内存分成区域,只使用其中一部分。回收时把存活对象复制到另一块可用区域,再整体清空原区域。
它没有内存碎片,而且适合存活对象较少的新生代,但需要额外的复制空间,存活对象很多时复制成本也会提高。
标记—整理
标记存活对象后,让存活对象向内存一端移动,再清理边界之外的空间。
它减少了内存碎片,适合对象存活率较高的区域,但对象移动和引用更新会带来额外开销。
分代收集
分代收集根据对象生命周期不同,把堆划分成新生代和老年代等区域,再为不同区域选择合适的回收方式。
新生代对象大多朝生夕死,适合复制;老年代对象存活率较高,更适合标记—清除或标记—整理。分代不是一种单独的底层算法,而是一种组合和管理思路。
G1 垃圾回收器是什么
G1 的全称是 Garbage First。它面向较大堆内存,并以可预测停顿为重要目标。从 JDK 9 开始,G1 成为 HotSpot 的默认垃圾回收器。
传统分代布局通常把堆划分成物理上连续的新生代和老年代。G1 则把整个堆切分成许多大小相等的 Region,每个 Region 在某个时刻可以承担 Eden、Survivor、Old 或 Humongous 等角色。
┌─────┬─────┬─────┬─────┬─────┬─────┐ │Eden │ Old │空闲 │Surv.│ Old │Eden │ ├─────┼─────┼─────┼─────┼─────┼─────┤ │ Old │空闲 │Hum. │Hum. │Eden │空闲 │ └─────┴─────┴─────┴─────┴─────┴─────┘这种分区方式让 G1 不必每次回收整个老年代。它可以根据各 Region 的垃圾数量和预计回收收益,优先选择价值更高的区域,这也是 Garbage First 名字的来源。
G1 中几个重要概念
Region
Region 是 G1 管理堆内存的基本单位。逻辑上仍然存在新生代和老年代,但物理位置不要求连续,可以根据运行情况动态调整各类 Region 的数量。
Humongous 对象
大小达到或超过一个 Region 容量一半的对象,会被 G1 当作大对象处理,通常占用一个或多个连续的 Humongous Region。
大量大对象会增加内存管理压力,严重时可能提前触发垃圾回收,所以排查 G1 问题时需要关注对象大小和 Region 使用情况。
Remembered Set
回收某个 Region 时,如果为了查找外部引用而扫描整个堆,代价会非常高。G1 会使用 Remembered Set 记录其他 Region 指向当前 Region 的引用线索,从而减少全堆扫描。
引用更新会通过写屏障和卡表等机制被记录。这样提高了回收阶段的效率,但也会增加程序正常运行时的额外开销。
Collection Set
Collection Set 是本次暂停期间计划回收的 Region 集合。G1 会根据停顿目标、Region 回收收益和预测成本决定把哪些 Region 加入集合。
G1 的主要回收过程
Young GC
当 Eden Region 逐渐耗尽时触发 Young GC。所有应用线程会在安全点暂停,存活对象被复制到 Survivor Region,年龄达到阈值或满足晋升条件的对象进入 Old Region。
回收完成后,原来的 Eden Region 可以重新使用。
并发标记周期
当老年代占用达到一定条件后,G1 开始统计整个堆中各 Region 的存活情况。主要阶段包括:
- 初始标记:标记 GC Roots 直接关联的对象,需要短暂停顿,通常借助 Young GC 完成。
- 根区域扫描:扫描 Survivor Region 指向老年代的引用。
- 并发标记:与应用线程并发执行,从根出发遍历对象图。
- 重新标记:处理并发标记期间发生的引用变化,需要暂停应用线程。
- 清理:统计 Region 存活率并回收完全空闲的 Region,为后续选择回收集合提供依据。
G1 使用 SATB,也就是 Snapshot-At-The-Beginning,协助保证并发标记的正确性。它可以大致理解为以并发标记开始时的对象图为逻辑快照,并通过写屏障记录标记期间被覆盖的旧引用,避免存活对象被漏标。
Mixed GC
并发标记完成后,G1 会在回收新生代 Region 的同时,选择一部分垃圾比例较高的 Old Region 一起回收,这就是 Mixed GC。
它通常不是一次清完所有老年代垃圾,而是在多次可控停顿中逐步完成。这样有利于平衡吞吐量和单次停顿时间。
Full GC
如果对象分配速度过快、疏散失败、并发标记来不及完成,或者堆空间过于紧张,G1 仍然可能退化到 Full GC。
Full GC 通常需要更长时间的 Stop-The-World,因此调优目标之一就是避免频繁出现这种情况,而不是认为使用 G1 后就完全不会发生 Full GC。
G1 为什么能尽量控制停顿时间
G1 可以通过参数设置期望的最大暂停时间,例如:
-XX:MaxGCPauseMillis=200这表示期望把 GC 暂停控制在大约 200 毫秒以内,但它是软目标,不是绝对保证。
G1 会记录各 Region 的回收成本、存活对象数量和历史耗时,在垃圾回收时选择一个预计能够满足停顿目标的 Collection Set。与每次回收整个连续老年代相比,这种按收益选择 Region 的方式更容易控制单次工作量。
但暂停目标也不能设置得过于激进。目标越短,单次能回收的 Region 越少,GC 可能变得更频繁,最终影响系统吞吐量。
G1 的本质仍然离不开基础算法
G1 并不是一种完全脱离标记、复制和整理的新算法。
它会通过可达性分析标记存活对象;在回收选中的 Region 时,把存活对象疏散复制到其他 Region;原 Region 被整体清空后重新使用。由于存活对象被集中搬移,整体上也能减少内存碎片。
所以可以把 G1 理解为以 Region 为单位、结合分代思想、并发标记和复制整理能力的垃圾回收器。
使用和观察 G1 时要注意什么
停顿时间和吞吐量需要权衡
GC 调优不是把暂停参数调得越小越好。需要结合接口延迟、系统吞吐量、堆大小和对象分配速度一起观察。
合理设置堆大小
堆太小会造成垃圾回收频繁,堆太大则可能让部分回收阶段的工作量增加。生产环境中通常需要结合监控和压测决定,而不是直接照搬固定参数。
关注对象分配和晋升
如果短命对象大量产生,Young GC 会非常频繁;如果对象过早晋升到老年代,又会增加 Mixed GC 和并发标记压力。代码层面应避免无意义的大量临时对象和超大对象。
先看日志,再做调优
遇到 GC 问题时,应先分析 GC 日志中的暂停原因、回收前后内存、并发周期、疏散失败和 Full GC,再决定调整堆、停顿目标或应用代码。
不基于数据直接堆叠 JVM 参数,往往只会把问题暂时隐藏起来。
今日总结
今天把 JVM 中三块容易分开背的内容串到了一起。
类首先经过加载、验证、准备、解析和初始化,才能被程序正常使用。准备阶段主要设置类变量的零值,初始化阶段才执行静态赋值和静态代码块;类初始化与每次创建对象时执行的实例初始化也不是一回事。
类加载时通常遵循双亲委派:先让父加载器尝试,父加载器无法完成后再由当前加载器处理。它保证了核心类库的统一,也减少了类被重复加载的问题,但在容器、插件和服务提供者等场景中可以有控制地改变加载方式。
对象进入堆以后,JVM 通过可达性分析判断对象是否存活。G1 把堆拆成多个 Region,通过并发标记统计各区域收益,并在 Young GC 和 Mixed GC 中复制存活对象、优先回收垃圾更多的 Region,从而在吞吐量和停顿时间之间取得平衡。
这次复习之后,我对 G1 最重要的理解是:它不是一个完全独立的新回收算法,而是把分区、分代、标记、复制、整理、并发执行和停顿预测组合成了一套更完整的垃圾回收方案。