CS-Notes 深入理解 Java 虚拟机:运行时数据区域、垃圾收集、内存分配策略与类加载机制全景
本篇技术指南基于 CS-Notes 笔记仓库中的 Java 虚拟机 文档展开,系统梳理 HotSpot 虚拟机的运行时数据区域(程序计数器、虚拟机栈、堆、方法区、直接内存)、垃圾收集的判定依据与七大收集器、内存分配与 Full GC 触发策略,以及类加载机制与双亲委派模型的实现源码。读完后你将能够解释 StackOverflowError/OutOfMemoryError 的内存成因、合理配置 -Xss、-Xms、-Xmx 等虚拟机参数、根据吞吐量和停顿时间需求选择 GC 收集器,并亲手实现一个自定义类加载器。
一、运行时数据区域:JVM 内存模型的地基
Java 程序运行时,虚拟机把内存划分为线程私有与线程共享两大类区域。整体结构如下图所示(图中标注为 JDK 1.6 时期的布局,其中方法区在 JDK 8 之后被元空间取代,后文会展开):
程序计数器(PC Register)
程序计数器记录当前线程正在执行的虚拟机字节码指令的地址,用于线程切换后恢复到正确的执行位置;如果线程正在执行的是本地方法(Native Method),则计数器的值为空(Undefined)。它是唯一一个不会发生 OutOfMemoryError 的内存区域,因为它的值随指令指针实时更新、不涉及动态内存申请。
Java 虚拟机栈(JVM Stack)与栈帧
每个 Java 方法在执行的同时会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、常量池引用、方法返回地址等信息。从方法调用直至执行完成的过程,恰好对应一个栈帧在 Java 虚拟机栈中入栈和出栈的过程——这也是"线程私有"和"生命周期与线程相同"的由来:栈帧不跨线程共享,线程死亡后栈随之销毁。
可以通过 -Xss 虚拟机参数指定每个线程的 Java 虚拟机栈内存大小(JDK 1.4 中默认为 256K,JDK 1.5+ 默认为 1M):
java -Xss2M HackTheJava
该区域可能抛出两类异常,二者分别对应两种内存边界被打破的场景:
- 当线程请求的栈深度超过最大值(递归过深、调用链过长),会抛出
StackOverflowError; - 栈进行动态扩展时如果无法申请到足够内存,会抛出
OutOfMemoryError。
本地方法栈(Native Method Stack)
本地方法栈与 Java 虚拟机栈在功能上几乎一致,区别在于它专为本地方法服务。本地方法一般用其它语言(C、C++ 或汇编)编写,并被编译为基于本机硬件和操作系统的原生程序,运行在本地方法栈中,通常以 JNI(Java Native Interface)方式被 Java 代码调用。HotSpot 中本地方法栈与虚拟机栈是合二为一的,但虚拟机规范仍将二者区分开来。
堆(Heap):GC 的主要区域
堆是所有对象实例和数组分配内存的地方,是被所有线程共享的区域,也是垃圾收集(GC)的主要区域,因此常被称为"GC 堆"。从内存回收的角度看,现代垃圾收集器基本都采用分代收集思想,把堆进一步划分为:
- 新生代(Young Generation):新创建的对象主要在这里分配,对象存活时间短,回收频繁;
- 老年代(Old Generation):长期存活的对象最终进入这里。
堆不需要是物理上连续的内存,并且可以动态增加内存大小;当堆中所有空间都用于对象分配、且无法申请到更多空间时,抛出 OutOfMemoryError。堆大小可通过以下两个参数控制:
java -Xms1M -Xmx2M HackTheJava
-Xms:堆内存初始大小;-Xmx:堆内存最大值。
实践中常见做法是将两者设为相同值,避免 JVM 在运行期反复调整堆大小带来的抖动。
方法区:从永久代到元空间
方法区用于存放已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,同样不需要连续内存且可动态扩展,扩展失败抛出 OutOfMemoryError。对该区域做垃圾回收的目标主要是回收常量池中的废弃常量和卸载不再被使用的类,难度远高于堆的回收。
方法区、永久代与元空间三者的关系是面试与调优中的高频考点,务必区分"规范"与"实现"两个层面:
| 概念 | 性质 | 说明 |
|---|---|---|
| 方法区 | JVM 规范定义的逻辑区域 | 是一个抽象概念,只规定"必须存在这样一块区域" |
| 永久代(PermGen) | HotSpot 对方法区的一种实现 | 每次 Full GC 后大小都会改变,难以预估,-XX:PermSize/-XX:MaxPermSize 常调不好 |
| 元空间(Metaspace) | HotSpot 对方法区的另一种实现 | JDK 1.8 起默认实现,位于本地内存而非虚拟机堆内存中,可通过 -XX:MaxMetaspaceSize 限制 |
HotSpot 虚拟机早期把方法区当作永久代来进行垃圾回收,但由于很难确定永久代的大小(受类数量、字符串常量、反射缓存等诸多因素影响),且每次 Full GC 后其大小都会变化,经常出现 java.lang.OutOfMemoryError: PermGen space。为了更容易管理方法区,从 JDK 1.8 开始移除永久代,把方法区移至元空间,它位于本地内存(Native Memory)中。JDK 1.8 之后,原来永久代的数据被重新划分:类的元信息(元数据)进入元空间,而静态变量和字符串常量池等被移入堆中。
运行时常量池(Runtime Constant Pool)
运行时常量池是方法区的一部分,是类文件(Class File)中常量池表的运行时内存表示:
- Class 文件中的常量池表包含编译器生成的字面量(如字符串、数值常量)和符号引用(如类的全限定名、字段名与方法签名),类加载后这些内容会被放入运行时常量池;
- 与 Class 文件常量池不同,运行时常量池具备动态性:运行时也能生成新的常量放入其中,最典型的例子是
String类的intern()方法。
直接内存(Direct Memory)
JDK 1.4 引入 NIO 类库后,可以直接使用 Native 函数库分配堆外内存,并通过 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作,无需在堆内存与堆外内存之间来回拷贝数据。由于省去了一次内存复制,它在网络传输、大文件读写等场景中能显著提高性能。
需要注意两点边界:一是直接内存不属于运行时数据区域中的任何一块,它的内存使用直接受系统内存和进程虚拟内存的限制;二是 DirectByteBuffer 对象本身仍分配在堆中,其回收依赖 GC——堆内对象被回收时才会通过 Cleaner 机制释放堆外内存,这也是"堆外内存泄漏"问题的来源。仓库中 Java IO 笔记的 NIO 章节有面向块、非阻塞的 I/O 模型说明及文件、套接字 NIO 实例,可作为本节内容的配套延伸阅读。
二、垃圾收集(Garbage Collection)
垃圾收集主要对象是堆和方法区。程序计数器、Java 虚拟机栈、本地方法栈三个区域属于线程私有,生命周期与线程相同,线程结束区域即消失,因此天然不需要 GC。
判断一个对象是否可被回收
1. 引用计数算法
为对象添加一个引用计数器:对象每被引用一次计数器加 1,引用失效时减 1;计数为 0 的对象可被回收。该算法判定效率简单,但存在致命缺陷——循环引用:两个对象互相持有引用时,即使外界已不再使用它们,计数器也永远不为 0:
public class Test {
public Object instance = null;
public static void main(String[] args) {
Test a = new Test();
Test b = new Test();
a.instance = b;
b.instance = a;
a = null;
b = null;
doSomething();
}
}
在上述代码中,a 与 b 引用的对象实例互相持有了引用,因此当我们把对 a、b 的引用去除之后,由于两个对象还存在互相之间的引用,导致两个 Test 对象无法被回收。正是因为循环引用的存在,Java 虚拟机不使用引用计数算法(Python、Objective-C 等语言则使用它)。
2. 可达性分析算法
Java 虚拟机采用可达性分析(Reachability Analysis):以一组称为 GC Roots 的根对象作为起始点集,从它们开始向下搜索,搜索路径称为引用链,能被引用链触及到的对象视为存活,不可达的对象可被回收。
GC Roots 一般包含:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象;
- 本地方法栈中 JNI(Native 方法)引用的对象;
- 方法区中类静态属性引用的对象(如静态变量指向的对象);
- 方法区中常量引用的对象(如字符串常量池中的引用);
- 此外还包括被 synchronized 持有的对象、JVM 内部引用(基本类型 Class 对象、常驻异常对象、系统类加载器等)。
3. 方法区的回收:常量池回收与类卸载
方法区主要存放类元数据,其回收率比新生代低很多,回收性价比不高。方法区中的 GC 主要目标是对常量池的回收和对类的卸载。为了避免内存溢出,在大量使用反射和动态代理的场景中都需要虚拟机具备类卸载功能。
一个类要被卸载,必须同时满足以下三个条件,且满足条件也不一定会被卸载:
- 该类所有的实例都已经被回收,堆中不存在该类的任何实例;
- 加载该类的 ClassLoader 已经被回收;
- 该类对应的
java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
4. finalize() 方法
finalize() 类似 C++ 的析构函数,可用于关闭外部资源。但 try-finally 等机制能完成同样的事情且更可靠;finalize() 运行代价高、执行时机不确定、无法保证各对象的调用顺序,官方也不建议使用。
它有一个特殊的"自救"特性:当对象被判定为可回收、且重写了 finalize() 时,可以在方法中让对象重新被引用,从而逃脱回收。自救只能成功一次——如果同一个对象再次面临回收,虚拟机不会再调用它的 finalize()。
四种引用类型
无论引用计数还是可达性分析,判定对象是否可被回收都与引用有关。Java 在 java.lang.ref 包中提供了四种强度递减的引用类型,为"可回收"划定了更精细的边界:
1. 强引用
被强引用关联的对象永远不会被回收(只要引用还在)。使用 new 创建一个对象引用即典型的强引用:
Object obj = new Object();
2. 软引用(SoftReference)
被软引用关联的对象只有在内存不足(即将发生 OutOfMemoryError)的情况下才会被回收。适合实现缓存等场景:内存充裕时保留对象,内存紧张时牺牲它们:
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联
3. 弱引用(WeakReference)
被弱引用关联的对象只能存活到下一次垃圾回收发生之前,无论内存是否充足,只要 GC 运行就会回收它。常用于避免内存泄漏的辅助映射(如 ThreadLocal 的 Entry 对 ThreadLocal 的弱引用):
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
4. 虚引用(PhantomReference)
又称幽灵引用或幻影引用。对象是否有虚引用存在完全不影响其生存时间,也无法通过虚引用获取对象实例(get() 永远返回 null)。为对象设置虚引用的唯一目的,是在该对象被回收时收到一个系统通知——需配合 ReferenceQueue 使用,常在堆外内存管理(如 ByteBuffer 的 Cleaner)等场景用于回收通知:
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;
垃圾收集算法
1. 标记 - 清除(Mark-Sweep)
分两个阶段:标记阶段遍历所有对象,检查每个对象是否为活动对象,是则在其对象头打上标记;清除阶段回收被标记/未标记为存活的对象并复位标志。清除阶段的实现中还会判断回收后的空闲分块与前一个空闲分块是否连续,若连续则合并分块,并将空闲分块连接到"空闲链表"上;分配时程序搜索空闲链表寻找空间大于等于新对象大小 size 的块——若恰好等于 size 直接返回,若大于 size 则把块分割为 size 与 (block - size) 两部分,返回前者、把后者归还空闲链表。
不足:
- 标记和清除过程效率都不高;
- 会产生大量不连续的内存碎片,导致后续无法为大对象分配连续内存。
2. 标记 - 整理(Mark-Compact)
让所有存活对象都向内存一端移动,然后直接清理端边界以外的内存。
- 优点:不产生内存碎片;
- 不足:需要移动大量对象(对象地址变化后需更新所有引用),处理效率比较低。
3. 复制(Copy)
将可用内存划分为大小相等的两块,每次只使用其中一块;这一块用完后,将存活对象全部复制到另一块,再把使用过的内存空间一次性清理。主要不足是空间利用率只有一半。
现代商业虚拟机用变形的复制算法回收新生代:不是均分两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间(From/To),每次使用 Eden 和其中一块 Survivor。回收时把 Eden 与当前 Survivor 中的存活对象全部复制到另一块 Survivor,再一次性清理 Eden 和使用过的那块 Survivor。
HotSpot 虚拟机的 Eden 与 Survivor 比例默认是 8:1(两块 Survivor 各占 1/10),保证 90% 的可用内存。如果每次回收后存活对象超过 10%,一块 Survivor 放不下,则需要老年代进行空间分配担保——借用老年代空间存放这些多余对象。
4. 分代收集(Generational Collection)
基于"绝大多数对象朝生夕灭"的弱分代假说,把堆按对象存活周期划分为新生代与老年代,各区域采用最适合其对象特征的算法:
- 新生代:复制算法(对象死亡率高、无需额外对象协助即可回收);
- 老年代:标记 - 清除 或 标记 - 整理算法(对象存活率高、没有额外空间协助)。
HotSpot 的 7 个垃圾收集器
下图中橙色(Young generation)为新生代收集器,蓝色(Tenured generation)为老年代收集器,连线表示可以搭配使用,G1 则横跨新生代与老年代:
先厘清两个维度:
- 单线程与多线程:单线程收集器只使用一个线程做 GC;多线程收集器使用多个线程并行回收;
- 串行与并行:串行指 GC 与用户程序交替执行(GC 时必须停顿用户程序,即 STW);并行指 GC 与用户程序同时执行。除了 CMS 和 G1 外,其它收集器均以串行方式执行。
1. Serial 收集器
以串行方式执行的单线程收集器。优点是简单高效:在单个 CPU 环境下,由于没有线程交互的开销,它拥有最高的单线程收集效率。它是 Client 场景下的默认新生代收集器,因为该场景下内存通常不会很大——Serial 收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不频繁,这种停顿是可以接受的。
2. ParNew 收集器
Serial 的多线程版本。它是 Server 场景下(搭配 CMS 时的)默认新生代收集器——除性能原因外,关键原因是除了 Serial 之外,它是当时唯一能与 CMS 收集器配合使用的新生代收集器。它本身并未引入新东西,与 Serial 的区别仅在于多线程执行。
3. Parallel Scavenge 收集器
同为多线程新生代收集器。其它收集器的目标是尽可能缩短 GC 时用户线程的停顿时间(低延迟),而它的目标是达到一个可控制的吞吐量——即 CPU 用于运行用户代码的时间占总时间的比值,因此被称为"吞吐量优先"收集器。
停顿时间越短越适合需要与用户交互的程序(提升响应体验);高吞吐量则能高效利用 CPU,尽快完成运算任务,适合后台运算且无需太多交互的任务。注意二者的权衡:缩短停顿时间是以牺牲吞吐量和新生代空间为代价的——新生代空间变小后,GC 更频繁,吞吐量下降。
Parallel Scavenge 提供了一个开关参数可打开 GC 自适应调节策略(GC Ergonomics),开启后无需手工指定新生代大小(-Xmn)、Eden 与 Survivor 区比例、晋升老年代的对象年龄等细节参数,虚拟机会根据系统运行状况动态调整这些参数,提供最合适的停顿时间或最大的吞吐量。
4. Serial Old 收集器
Serial 的老年代版本,同样为 Client 场景设计。若在 Server 场景下使用,它有两个用途:
- 在 JDK 1.5 及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 搭配使用;
- 作为 CMS 收集器的后备预案:当 CMS 发生 Concurrent Mode Failure(并发模式失败)时,临时启用 Serial Old 来替代 CMS 完成收集。
5. Parallel Old 收集器
Parallel Scavenge 的老年代版本,同样是多线程、以吞吐量为目标。在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge + Parallel Old 的组合。
6. CMS 收集器(Concurrent Mark Sweep)
CMS 是一款以获取最短回收停顿时间为目标的老年代收集器,基于标记 - 清除算法实现(Mark Sweep 即其含义)。它把回收过程细分为四个阶段,其中两个阶段可与用户线程并发执行:
| 阶段 | 是否需 STW | 说明 |
|---|---|---|
| 初始标记(Initial Mark) | 需要 | 仅标记 GC Roots 能直接关联到的对象,速度很快 |
| 并发标记(Concurrent Mark) | 不需要 | 对 GC Roots Tracing 的漫长过程,是整个过程耗时最长的一步 |
| 重新标记(Remark) | 需要 | 修正并发标记期间因用户程序继续运作而产生的标记变动 |
| 并发清除(Concurrent Sweep) | 不需要 | 清理未标记对象,耗时不短但无需停顿 |
由于耗时最长的并发标记和并发清除都不停顿用户线程,CMS 的总停顿时间明显低于 Serial Old。但它有三个固有缺点:
- 吞吐量低:低停顿以牺牲吞吐量为代价,多线程的 CMS 需要更多 CPU 资源,CPU 利用率不够高;
- 无法处理浮动垃圾,可能出现 Concurrent Mode Failure:并发清除阶段用户线程仍在运行,会产生新的垃圾(浮动垃圾),这部分只能等下一次 GC 回收。因此必须预留内存,不能像其他收集器那样等老年代快满才回收;若预留空间不足,就会发生 Concurrent Mode Failure,虚拟机将临时启用 Serial Old 收集器重新进行老年代收集;
- 空间碎片:标记 - 清除算法会产生不连续碎片,可能出现老年代剩余空间足够但找不到足够大的连续空间来分配对象,不得不提前触发一次 Full GC。
7. G1 收集器(Garbage-First)
G1 是一款面向服务端应用、可并行与并发的收集器,在多 CPU 和大内存场景下有很好的性能,HotSpot 开发团队赋予它的使命是未来替换掉 CMS。与其它收集器只回收整个新生代或老年代不同,G1 可以同时对新生代和老年代进行回收。
G1 的关键设计:
- Region 化布局:把堆划分为多个大小相等的独立区域(Region),每个 Region 可以根据需要扮演 Eden、Survivor 或 Old 的角色,新生代与老年代不再是物理隔离的;
- 可预测的停顿时间模型:每个 Region 都可单独回收。G1 通过记录每次 Region 回收的耗时与回收空间(基于历史经验),维护一个优先列表,每次按用户允许的收集时间,优先回收价值最大的 Region(Garbage-First 名称的由来);
- Remembered Set 避免全堆扫描:每个 Region 都有一个 Remembered Set,记录该 Region 中对象的引用来自哪些其它 Region,做可达性分析时凭它即可避免对整个堆扫描。
G1 的运作大致划分为四个步骤(不计维护 Remembered Set 的开销):
- 初始标记:与 CMS 相同,仅标记 GC Roots 直接关联对象,需要 STW;
- 并发标记:从 GC Roots 开始遍历整个堆,与用户线程并发;
- 最终标记(Final Mark):处理并发标记期间的对象变动——虚拟机把这段时间的变化记录在线程的 Remembered Set Logs 中,本阶段将这些日志数据合并到 Remembered Set 中。该阶段需要停顿线程,但可以并行执行;
- 筛选回收(Select Relocatable Collections):对各 Region 的回收价值和成本排序,根据用户期望的 GC 停顿时间制定回收计划。此阶段可并发执行,但为控制整体停顿、提高收集效率,实际会停顿用户线程,且只回收一部分 Region。
G1 的两个显著特点:
- 空间整合:整体基于"标记 - 整理"算法实现,局部(两个 Region 之间)基于"复制"算法实现,运行期间不产生内存碎片;
- 可预测的停顿:可明确指定"在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒"。
三、内存分配与回收策略
Minor GC 与 Full GC
- Minor GC(Young GC):只回收新生代。新生代对象存活时间短、死亡率高,因此 Minor GC 执行频繁、速度相对较快;
- Full GC(Major GC):回收老年代与新生代(通常还包含方法区/元空间相关处理)。老年代对象存活时间长,Full GC 很少发生,但每次执行速度都比 Minor GC 慢很多。
内存分配策略:对象进入老年代的五条路径
1. 对象优先在 Eden 分配
新建对象优先在新生代 Eden 区分配;当 Eden 空间不够时,触发一次 Minor GC。
2. 大对象直接进入老年代
大对象指需要较大连续内存空间的对象,最典型的是很长的字符串和数组。为避免为大对象反复在 Eden 与 Survivor 之间复制,可通过参数让超过阈值的对象直接在老年代分配:
-XX:PretenureSizeThreshold
# 大于此值(字节)的对象直接在老年代分配(仅对 Serial/ParNew 等使用分代复制算法的收集器生效,G1 中该参数无效)
经常出现大对象也会提前触发垃圾收集,以腾出足够的连续空间。
3. 长期存活的对象进入老年代
对象在 Eden 出生,经过一次 Minor GC 仍存活则被移到 Survivor,且年龄加 1;年龄达到阈值后晋升老年代。阈值由下参数控制:
-XX:MaxTenuringThreshold
# 对象晋升老年代所需的最小年龄(HotSpot 默认值通常为 15)
4. 动态对象年龄判定
虚拟机并不死板地要求年龄必须达到 MaxTenuringThreshold 才晋升:如果在 Survivor 中相同年龄的所有对象大小的总和超过 Survivor 空间的一半,则年龄大于等于该年龄的对象可以直接进入老年代,无需等到阈值年龄。
5. 空间分配担保(Allocation Failure / Promotion Failure)
使用复制算法的 Minor GC 需要老年代提供内存担保:
- 若老年代最大可用连续空间 > 新生代所有对象总空间,则 Minor GC 可确认安全;
- 否则,查看
HandlePromotionFailure是否允许担保失败:允许时,再检查老年代最大可用连续空间是否大于历次晋升到老年代的对象平均大小,若是则尝试一次 Minor GC,若否则先执行一次 Full GC; - 若
HandlePromotionFailure不允许冒险,则直接 Full GC。
Full GC 的触发条件
Minor GC 的触发条件很简单:Eden 区满。而 Full GC 的触发场景相对复杂,常见的有以下五种:
- 代码中主动调用
System.gc():只是建议虚拟机执行 Full GC,虚拟机可以但不一定真正执行。官方不建议使用这种方式,而应交给虚拟机自行管理内存; - 老年代空间不足:常见于大对象直接进入老年代、长期存活对象晋升等场景。规避手段包括:尽量不创建过大的对象与数组;用
-Xmn调大新生代,让对象在新生代就被回收掉;用-XX:MaxTenuringThreshold调大晋升年龄,让对象在新生代多存活一段时间; - 空间分配担保失败:使用复制算法的 Minor GC 需要老年代空间担保,担保失败则 Full GC(见上文第 5 条分配策略);
- JDK 1.7 及以前的永久代空间不足:JDK 1.7 及以前 HotSpot 的方法区由永久代实现,其中存放类信息、常量、静态变量等。当要加载的类、反射类及调用的方法较多时,永久代可能被占满;在未配置 CMS GC 的情况下会执行 Full GC,若 Full GC 后仍无法回收,则抛出
java.lang.OutOfMemoryError。规避方法:增大永久代空间(-XX:MaxPermSize)或改用 CMS GC; - CMS 收集器并发模式失败(Concurrent Mode Failure):CMS GC 过程中同时有对象要放入老年代,而此时老年代空间不足(可能是浮动垃圾过多导致的暂时性不足),便会触发 Full GC,此时由 Serial Old 以串行方式重新收集老年代。
四、类加载机制
Java 类是在运行期间第一次使用时动态加载的,而不是一次性加载所有类——如果一次性加载,会占用大量内存。
类的生命周期:7 个阶段
类从被加载到内存开始,直到卸载出内存为止,整个生命周期包括 7 个阶段:加载(Loading)→ 验证(Verification)→ 准备(Preparation)→ 解析(Resolution)→ 初始化(Initialization)→ 使用(Using)→ 卸载(Unloading)。其中解析与初始化的先后次序并非固定——类加载过程包含加载、验证、准备、解析、初始化 5 个阶段,解析可以在初始化之后才开始,这是为了支持 Java 语言的动态绑定。
1. 加载
完成三件事:
- 通过类的完全限定名称获取定义该类的二进制字节流;
- 将该字节流表示的静态存储结构转换为方法区的运行时存储结构;
- 在内存中生成一个代表该类的
java.lang.Class对象,作为方法区中该类各种数据的访问入口。
二进制字节流可以来自多种渠道:
- 从 ZIP 包中读取——这是 JAR、EAR、WAR 格式的基础;
- 从网络中获取——最典型的应用是 Applet;
- 运行时计算生成——例如动态代理技术,
java.lang.reflect.Proxy使用ProxyGenerator.generateProxyClass生成代理类的二进制字节流; - 由其它文件生成——例如读取 JSP 文件,运行时生成对应的 Class 类。
2. 验证
确保 Class 文件字节流中的信息符合当前虚拟机的要求,并且不会危害虚拟机自身安全(如是否带有魔数、版本号是否匹配、常量池中是否存在不存在的符号引用等)。
3. 准备
类变量是被 static 修饰的变量。准备阶段为类变量分配内存并设置"零值"初始值(如 int 为 0、引用为 null),使用的方法区内存。注意两点:
- 实例变量不会在这阶段分配内存,它随对象实例化一起分配在堆中。实例化不是类加载的过程:类加载只发生一次,实例化可发生多次;
- 若类变量是常量(
static final且编译期可确定),则初始化为表达式定义的值而非零值:
// 准备阶段被初始化为 0(零值),123 是初始化阶段赋的值
public static int value = 123;
// 常量在准备阶段即被初始化为 123
public static final int value = 123;
4. 解析
把常量池中的符号引用替换为直接引用的过程。符号引用以字符串形式描述目标(类全名、方法名等),直接引用则是直接指向目标的指针、相对偏移量或句柄。解析过程在某些情况下可以在初始化之后才开始,以支持动态绑定。
5. 初始化
初始化阶段才真正开始执行类中定义的 Java 程序代码。它是虚拟机执行类构造器 <clinit>() 方法的过程:
<clinit>()由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生,收集顺序由语句在源文件中出现的顺序决定,与语法分析器采用的任何一种"深度优先"策略无关;- 静态语句块只能访问定义在它之前的类变量;定义在它之后的类变量只能赋值,不能访问,否则编译器报"非法向前引用":
public class Test {
static {
i = 0; // 给变量赋值可以正常编译通过
System.out.print(i); // 这句编译器会提示"非法向前引用"
}
static int i = 1;
}
- 父类的
<clinit>()优先于子类执行,即父类的静态语句块先于子类:
static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B); // 输出 2
}
- 接口:接口中不能使用静态语句块,但仍会有类变量赋值的初始化操作,因此接口与类一样都会生成
<clinit>()。但接口与类不同:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()——只有父接口中定义的变量被使用时,父接口才会初始化;接口的实现类在初始化时也一样不会执行接口的<clinit>(); - 线程安全:虚拟机会保证一个类的
<clinit>()方法在多线程环境下被正确地加锁与同步——多个线程同时去初始化一个类时,只会有一个线程执行这个类的<clinit>(),其它线程阻塞等待。若<clinit>()中有耗时操作,可能造成多块线程阻塞,且这种阻塞很隐蔽。
类初始化时机:主动引用与被动引用
虚拟机规范并未强制约束何时加载、验证、准备一个类,但严格规定了有且只有以下 5 种情况必须对类进行初始化(加载、验证、准备随之发生),即"主动引用":
- 遇到
new、getstatic、putstatic、invokestatic四条字节码指令时,若类没有初始化过,必须先触发其初始化。最常见的生成场景:使用new实例化对象;读取或设置一个类的静态字段(被final修饰、编译期已存入常量池的静态字段除外);调用一个类的静态方法; - 使用
java.lang.reflect包的方法对类进行反射调用时; - 当初始化一个类时,若发现其父类还未初始化,需要先触发父类初始化;
- 虚拟机启动时,需要指定主类(包含
main()方法的类),虚拟机先初始化这个主类; - 使用 JDK 1.7 动态语言支持时,若一个
java.lang.invoke.MethodHandle实例最终解析为REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,且对应类未初始化过,则先触发其初始化。
除以上 5 种场景外的所有引用方式都称为被动引用,不会触发初始化。常见例子有三个:
- 通过子类引用父类的静态字段,不会导致子类初始化:
System.out.println(SubClass.value); // value 字段在 SuperClass 中定义
- 通过数组定义来引用类,不会触发该类初始化:
SuperClass[] sca = new SuperClass[10];该过程初始化的是数组类——一个由虚拟机自动生成的、直接继承自Object的子类,其中包含数组的属性和方法——而不是SuperClass本身; - 通过常量引用定义常量的类不会触发其初始化:常量在编译期就存入调用方的常量池,本质上没有直接引用到定义常量的类:
System.out.println(ConstClass.HELLOWORLD); // 不会触发 ConstClass 的初始化
类与类加载器:独立的命名空间
判断两个类是否"相等",除了类本身(全限定名)要一样,加载它们的类加载器也必须相同——因为每一个类加载器都拥有一个独立的类名称空间。这里的相等涵盖 Class 对象 equals()、isAssignableFrom()、isInstance() 的返回结果,以及 instanceof 判定的结果。这也意味着:即使两份 com.example.Foo 字节码完全一致,只要由两个不同的类加载器加载,它们就是两个不同的类。
类加载器分类
从 Java 虚拟机规范角度看,只存在两种类加载器:
- 启动类加载器(Bootstrap ClassLoader):用 C++ 实现,是虚拟机自身的一部分;
- 所有其它的类加载器:用 Java 实现,独立于虚拟机存在,且都继承自抽象类
java.lang.ClassLoader。
从 Java 开发者角度,通常划分为三层:
| 类加载器 | 实现 | 职责 |
|---|---|---|
| 启动类加载器(Bootstrap) | C++,虚拟机自身的一部分 | 加载 <JRE_HOME>\lib 目录中、或被 -Xbootclasspath 参数指定路径中、且虚拟机识别的(仅按文件名识别,如 rt.jar,名字不符的类库即使放在 lib 下也不会被加载)类库。无法被 Java 程序直接引用,需要委派时直接用 null 代替 |
| 扩展类加载器(Extension) | sun.misc.Launcher$ExtClassLoader |
加载 <JAVA_HOME>/lib/ext 或被 java.ext.dirs 系统属性指定路径中的类库,开发者可直接使用 |
| 应用程序类加载器(Application) | sun.misc.Launcher$AppClassLoader |
ClassLoader.getSystemClassLoader() 的返回值,故又称"系统类加载器"。负责加载用户类路径(ClassPath)上的类库;若应用中没有自定义类加载器,它就是默认的类加载器 |
双亲委派模型(Parents Delegation Model)
应用程序由上述三种类加载器互相配合实现类加载,此外还可以加入自定义类加载器。类加载器之间的层次关系如下图所示,即双亲委派模型:除了顶层的启动类加载器外,其它类加载器都必须有自己的父类加载器。注意这里的"父子关系"一般通过**组合关系(Composition)**而非继承关系(Inheritance)实现:
工作过程
一个类加载器收到加载请求时,先把请求转发到父加载器,逐级向上传递直到启动类加载器;只有父加载器无法完成(即在自己的搜索范围内找不到该类)时,子加载器才尝试自己加载。
好处
Java 类随其类加载器一起形成带有优先级的层次关系,从而使基础类得到统一。以 java.lang.Object 为例:它存放在 rt.jar 中,若开发者再写一个 java.lang.Object 并放到 ClassPath 中,程序照样能编译通过;但由于双亲委派模型的存在,rt.jar 中的 Object(由启动类加载器加载)优先级更高,ClassPath 中的 Object(由应用程序类加载器加载)永远不会被加载——程序中所有的 Object 都是 rt.jar 里那一个。这保证了核心 API 类不会被恶意篡改。
源码实现
java.lang.ClassLoader 的 loadClass() 方法正是上述逻辑的体现:先检查类是否已加载,未加载则委派父加载器加载;父加载器抛出 ClassNotFoundException 后,再调用自己的 findClass() 尝试加载:
public abstract class ClassLoader {
// The parent class loader for delegation
private final ClassLoader parent;
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
}
自定义类加载器实现
自定义类加载器一般不重写 loadClass()(它承载了双亲委派逻辑),而是重写 findClass():根据类的全名定位到 .class 文件,读取字节流后通过 defineClass() 转换成 Class 实例。以下 FileSystemClassLoader 用于从文件系统加载类:
public class FileSystemClassLoader extends ClassLoader {
private String rootDir;
public FileSystemClassLoader(String rootDir) {
this.rootDir = rootDir;
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] getClassData(String className) {
String path = classNameToPath(className);
try {
InputStream ins = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead;
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
private String classNameToPath(String className) {
return rootDir + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
}
}
由于 loadClass() 内置了双亲委派,FileSystemClassLoader 加载的请求会先逐级委派给应用、扩展、启动类加载器;只有当父加载器都找不到该类时,才会真正执行上面重写的 findClass() 从 rootDir 指定的文件系统路径读取字节码并定义新类。这也解释了为什么在普通场景下不能简单"绕过"双亲委派来重新加载 java.lang 包下的类。
五、仓库内相关文档
本篇内容以 notes/Java 虚拟机 为主体(原文大部分内容参考周志明《深入理解 Java 虚拟机》一书),结合仓库内以下文档可形成完整的 Java 知识体系:
- Java 基础:Java 语言基础,与本文的引用、初始化等内容互补;
- Java 容器:JDK 集合实现,弱引用在
WeakHashMap类结构中的典型应用; - Java 并发:线程与共享内存模型,理解"线程私有/共享区域"划分的背景;
- Java IO:NIO 章节(面向块、非阻塞的 I/O 模型与文件、套接字实例),是"直接内存"一节的应用延伸;
- 计算机操作系统 - 内存管理:虚拟内存与分页机制,理解元空间使用本地内存、堆外内存与操作系统交互的基础。
参考资料
- 周志明. 深入理解 Java 虚拟机 [M]. 机械工业出版社(本文主体内容的参考来源)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00



