【Java 类加载器】Java 类加载器完整体系深度拆解(上):从 JVM 启动到双亲委派模型
作者介绍: 大家好,我是 CodeStats。一个在底层技术上“考古”了四年的硬核爱好者,也是 WWAIC(全周项目AI编程)范式的提出者和实践者。我曾手写过一个完整的 Java Web 框架(从 IoC 容器到嵌入式 Tomcat,代码全开源),也喜欢用通俗的语言拆解 CPU、JVM、操作系统的运行本质。我的技术信条:所有高深的技术,最后都能用大白话讲清楚。如果讲不清楚,说明还没真正理解。
本文能获得什么
-
摸清家底: 彻底搞懂执行
java命令后,操作系统和 JVM 内部到底发生了什么 -
图解内存: 用大白话把堆、栈、方法区、程序计数器这些“高冷”名词拉下神坛
-
把握时机: 搞清楚三大内置类加载器(Bootstrap、Ext、App)是在哪一秒、由谁创建的
-
吃透源码: 深入
ClassLoader.loadClass()源码,把双亲委派的每一行逻辑掰开揉碎 -
厘清层级: 彻底搞懂 Bootstrap、Ext、App 的逻辑父子关系和各自的“势力范围”
目录
-
提问一:执行
java命令启动 JVM 进程的完整流程,Java 内存结构,类加载器创建时机 -
提问二:Java 的类加载器双亲委派机制是如何实现的,类加载逻辑流程
-
提问三:Java 的 BootstrapClassLoader、AppClassLoader 和 ExtClassLoader 是什么关系
一、执行 java 命令启动 JVM 进程的完整流程,Java 内存结构,类加载器创建时机
1.1 从 java 命令到 JVM 进程的“开荒”四步
当你在命令行敲下 java HelloWorld 并回车的那一刻,操作系统和 JVM 便开始了一场精密的“开荒”协作:
第一步:操作系统创建进程。 操作系统解析命令,在文件系统中找到 java 可执行文件(Windows 下是 java.exe,Linux 下是 java 脚本链接的二进制文件),将其加载到内存中,并分配独立的进程空间和 PID。
第二步:JVM 底层初始化。 java 命令的本质是启动一个 JVM 实例。它会解析我们传入的参数(如 -Xms256m、-Xmx512m),初始化 JVM 内部的全局数据结构,建立基本的运行环境。
第三步:划分运行时内存区域。 JVM 向操作系统申请完堆内存后,会在自己的进程空间内进行二次“领土划分”,这就是 JVM 运行时数据区(也就是我们常说的 JVM 内存模型)。
第四步:创建 Launcher 与类加载器。 内存划分完毕,JVM 会创建 sun.misc.Launcher 对象,并在其构造方法中依次创建出三个内置类加载器。做完这一切后,JVM 才会去加载我们含有 main 方法的入口类。
1.2 Java 内存结构(运行时数据区)全解
堆(Heap): 所有线程共享,是 JVM 内存中最大的一块。所有 new 出来的对象实例和数组都放在这里。它也是垃圾收集器(GC)的主要活动区域,通常分为新生代(Young Generation)和老年代(Old Generation)。
方法区(Method Area): 所有线程共享。它存储的是类级别的元数据——包括类的结构信息(字段、方法字节码)、常量池、静态变量,以及 JIT 编译后的热点代码。在 JDK 8 之前,这块区域叫“永久代”(PermGen),且受 JVM 内存大小限制;JDK 8 之后被“元空间”(Metaspace)取代,直接使用物理内存(本地内存),从此不再轻易抛出 java.lang.OutOfMemoryError: PermGen space。
虚拟机栈(VM Stack): 每个线程私有。每个方法被执行时,都会创建一个栈帧(Stack Frame)。栈帧中包含了局部变量表(存基本类型和对象引用)、操作数栈(用于字节码指令执行)、动态链接和方法出口。方法的调用到结束,对应一个栈帧在虚拟机栈中的入栈和出栈。
本地方法栈(Native Method Stack): 每个线程私有,与虚拟机栈作用类似,但它是为 JVM 执行 Native 方法(比如用 C/C++ 写的 hashCode() 底层实现)服务的。
程序计数器(Program Counter Register): 每个线程私有,可以把它看作当前线程执行字节码的行号指示器。字节码解释器就是通过改变这个计数器的值来选取下一条要执行的指令。它是 JVM 规范中唯一一个没有规定任何 OutOfMemoryError 的内存区域。
1.3 类加载器的创建时机(重点)
类加载器是在 JVM 启动的“地基”打完之后,在加载业务类之前 创建的。具体链路如下:
-
JVM 初始化完毕 -> 调用
sun.misc.Launcher构造器。 -
在 Launcher 构造器中:
-
先创建 ExtClassLoader(扩展类加载器,Java 实现),指定其父加载器为
null(逻辑上指向 Bootstrap)。 -
再创建 AppClassLoader(应用类加载器,Java 实现),并将刚刚创建的 ExtClassLoader 作为父加载器传入。
-
-
Launcher对象创建完成后,会通过Thread.currentThread().setContextClassLoader(appClassLoader)将 AppClassLoader 设置为当前主线程的线程上下文类加载器。 -
最后,由 AppClassLoader 去加载包含
main方法的入口类。
注意:BootstrapClassLoader 并不是在 Launcher 中用 Java
new出来的,它是在 JVM 启动的 C++ 引导阶段(JVM_Start或InitializeJVM)就已经初始化好的 JVM 底层模块。
二、Java 的类加载器双亲委派机制是如何实现的,类加载逻辑流程
2.1 什么是双亲委派机制?
一句话概括: 儿子收到活,不自己干,先丢给老爸;老爸收到活,也不自己干,再丢给爷爷;直到最顶层的祖宗(Bootstrap)接活。祖宗干不了,再一层层丢回来给儿子干。
这种机制保证了 Java 核心类库的安全性和唯一性。
2.2 双亲委派机制的源码实现(JDK 8)
源码在 java.lang.ClassLoader 的 loadClass 方法中,我们抽丝剥茧来看:
java
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
// 1. synchronized 锁,防止并发重复加载
synchronized (getClassLoadingLock(name)) {
// 2. 第一步:检查 JVM 缓存,看这个类是不是已经加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 3. 第二步:如果父加载器不为空,向上委托给父加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 4. 第三步:如果父加载器为空,说明自己就是 Bootstrap 的 Java 代理,
// 直接调用 native 方法让 Bootstrap 去找
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器抛出异常,说明它找不到,暂时忽略,待会自己找
}
// 5. 第四步:如果父加载器没找到(c == null),调用自己的 findClass 加载
if (c == null) {
c = findClass(name); // URLClassLoader 重写了这里,从 URL 路径找
}
}
// 6. 如果需要解析(链接阶段),执行 resolveClass
if (resolve) {
resolveClass(c);
}
return c;
}
}
2.3 加载逻辑流程图(一目了然)
text
收到 loadClass("com.example.Demo")
↓
① 查看本地缓存 findLoadedClass() —— 加载过就直接返回
↓ 没加载过
② 有 parent 吗?
├─ 有 → parent.loadClass()(递归向上)
└─ 无 → 委托给 Bootstrap 尝试加载
↓ 父/祖宗都加载失败
③ 调用本类的 findClass() —— 通常是 URLClassLoader 从指定路径扫描 .class 文件
↓ 找到字节码
④ defineClass() 将字节码转换为 Class 对象
↓
⑤ resolveClass()(链接阶段,可选)
↓
返回 Class 实例
三、Java 的 BootstrapClassLoader、AppClassLoader 和 ExtClassLoader 是什么关系
3.1 逻辑上的“父子”关系
从双亲委派的逻辑层级来看,它们是严格的金字塔结构:
text
BootstrapClassLoader(引导类加载器,C++编写)
↑ 逻辑父(parent = null)
ExtClassLoader(扩展类加载器,Java实现)
↑ 逻辑父(parent = ExtClassLoader)
AppClassLoader(应用类加载器,Java实现)
3.2 各自的“势力范围”(加载路径)
| 类加载器 | 实现语言 | 加载路径 | Java代码中能否直接引用 |
|---|---|---|---|
| BootstrapClassLoader | C++(JVM 内嵌) | JAVA_HOME/jre/lib/ 下的核心类库(rt.jar、charsets.jar 等) | 不能,获取引用返回 null |
| ExtClassLoader | Java(sun.misc.Launcher$ExtClassLoader) | JAVA_HOME/jre/lib/ext/ 或 java.ext.dirs 指定的目录 | 能 |
| AppClassLoader | Java(sun.misc.Launcher$AppClassLoader) | classpath(即 -cp 或环境变量 CLASSPATH 指定的路径) | 能 |
3.3 一个最容易混淆的误区
很多人看到代码里 ExtClassLoader.getParent() 返回的是 null,就误以为 Ext 没有爸爸。
其实不是! 因为 BootstrapClassLoader 根本就不是 Java 对象(它是 C++ 写的),所以在 Java 层的 parent 字段里无法存放它的引用,只能放 null。
但在 loadClass() 源码中,当 parent == null 时,JVM 会主动调用 findBootstrapClassOrNull(),逻辑上依然把 Bootstrap 当爸爸。所以请记住:逻辑父子 ≠ Java 继承父子。
(上篇完,请继续阅读中篇)
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)