JVM - 类加载子系统
# 1. 概述
完整图如下:
如果自己想手写一个 Java 虚拟机的话,主要考虑哪些结构呢?
- 类加载器(ClassLoader):
- 职责:负责将 Java 字节码文件加载到 JVM 中,并将其转换为 Class 对象。类加载器采用双亲委派机制,确保类的加载顺序和安全性。
- 双亲委派机制:
- 工作原理:当类加载器收到类加载请求时,它首先将请求委托给父类加载器进行处理。如果父类加载器无法完成加载任务,则子加载器才会尝试加载该类。
- 优势:避免类的重复加载,确保核心类库不被篡改。
- 执行引擎(Execution Engine):
- 职责:负责执行字节码,将字节码翻译成机器码,并在底层硬件上运行。执行引擎包括解释器、即时编译器(JIT)和垃圾收集器(GC)。
- 主要组件:
- 解释器(Interpreter):逐行解释并执行字节码,适合快速启动和调试。
- 即时编译器(JIT Compiler):将热点代码编译成机器码,提高执行效率。
- 垃圾收集器(Garbage Collector):自动管理内存,回收不再使用的对象,防止内存泄漏。
# 2. 类加载器子系统作用
类加载器子系统负责从文件系统或网络中加载 Class 文件。Class 文件在文件头部有特定的文件标识。类加载器只负责 Class 文件的加载,而是否可以运行则由执行引擎(Execution Engine)决定。
类加载器的功能:
- 加载 Class 文件:从文件系统或网络中读取 Class 文件,并将其加载到内存中。
- 存储类信息:将加载的类信息存放在方法区(Method Area)。
- 管理运行时常量池:方法区中还存放运行时常量池信息,包括字符串字面量和数字常量等。
类加载器的类比:
类加载器的作用可以类比为父母负责为孩子找相亲对象,但是否相亲成功取决于个人的表现和互动。类似地,类加载器负责将 Class 文件加载到 JVM 中,而是否可以成功运行由执行引擎决定。
类加载器的层次结构如图所示:
类加载过程图示
从 .class 文件加载到 JVM,并最终成为元数据模板的过程图示如下:
- class file 存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到 JVM 当中来根据这个文件实例化出 n 个一模一样的实例
- class file 加载到 JVM 中,被称为 DNA 元数据模板,放在方法区
- 在 .class 文件 -> JVM -> 最终成为元数据模板,此过程就要一个运输工具(类装载器 Class Loader),扮演一个快递员的角色
笔记
class 文件通过某一个 ClassLoader 生成属于自己的类,然后根据自己的类实例化出三个实例,三个实例可以通过 getClass()
获取自己的类,自己的类也可以通过 getClassLoader()
获取某一个 ClassLoader。
# 3. 类的加载过程
例如下面的一段简单的代码:
public class HelloLoader {
public static void main(String[] args) {
System.out.println("谢谢 ClassLoader 加载我...");
}
}
2
3
4
5
它的加载过程是怎么样的呢?
.class
文件先经过装载,如果文件不合法或被恶意修改,则抛出异常,否则就链接,初始化、调用 main()
方法,输出结果。
完整的流程图如下所示:
# 加载阶段
加载阶段是类加载过程的起点,负责将类的定义从磁盘加载到内存,并进行必要的解析和准备工作,为后续的链接和初始化阶段做好准备。
1. 获取二进制字节流
- 过程:加载阶段首先根据类的全限定名(包括包名)在类路径下查找对应的
.class
文件,然后读取这个文件的二进制字节流。 - 目的:将类文件的内容读取到内存中,以便后续处理。
2. 转化为方法区的运行时数据结构
- 过程:将读取到的二进制字节流中的静态存储结构(类的结构、字段、方法等)转化为方法区中的运行时数据结构。
- 内容:这个过程包括解析类的各种信息,如类的字段、方法、接口等,并存储在内存中的方法区中。
3. 生成 java.lang.Class
对象
- 过程:在内存中生成一个代表这个类的
java.lang.Class
对象。 - 内容:
java.lang.Class
对象包含了类的各种元数据信息,如类名、父类、实现的接口、字段信息、方法信息等。 - 用途:
java.lang.Class
对象提供了对类的各种操作和访问的接口,是 Java 程序访问类结构和数据的入口之一。
提示
- 二进制字节流:类文件是以二进制字节流的形式存储在磁盘上的,包含了类的各种信息,如类的结构、字段、方法、接口等。
- 静态存储结构:在类文件中,类的结构是以一种静态的形式存在的,即在编译时已经确定好的类的结构,包括类的字段、方法等信息。
- 方法区的运行时数据结构:方法区是 Java 虚拟机规范中定义的一块内存区域,用于存储类的元数据信息,如类的字段、方法、常量池等。在加载阶段,Java 虚拟机会将从类文件中解析出来的类的结构转化为方法区中的运行时数据结构,这些数据结构是在程序运行时动态生成和更新的。
在 Java 中,元数据信息主要体现在以下几个方面:
- 类的元数据:包括类的名称、修饰符、父类、实现的接口、字段信息、方法信息等。
- 字段的元数据:包括字段的名称、修饰符、类型、是否为静态字段等。
- 方法的元数据:包括方法的名称、修饰符、参数列表、返回类型、是否为静态方法等。
- 注解信息:注解是一种用于为程序元素(类、方法、字段等)添加元数据信息的方式,它们本身也是一种元数据。注解可以用来为代码提供额外的信息,比如说明某个方法的用途、某个字段的约束条件等。
# 加载class文件的方式
- 从本地系统中直接加载
- 通过网络获取,典型场景:Web Applet
- 从 zip 压缩包中读取,成为日后 jar、war 格式的基础
- 运行时计算生成,使用最多的是:动态代理技术
- 由其他文件生成,典型场景:JSP 应用从专有数据库中提取 .class 文件,比较少见
- 从加密文件中获取,典型的防 Class 文件被反编译的保护措施
# 链接阶段
链接阶段分为三步:验证、准备、解析。如图所示:
# 验证 Verify
目的在于确保 Class 文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
工具:Binary Viewer 查看
验证 Class 文件正确的开始因素就是看开头是否有 CA FE BA BE
(咖啡baby)。
如果出现不合法的字节码文件,那么将会验证不通过。
同时我们可以通过安装 IDEA 的插件 jclass
,来查看我们的 Class 文件。
安装完成后,我们编译完一个 Class 文件后,点击 view 即可显示我们安装的插件来查看字节码方法了。
# 准备 Prepare
在类加载的链接阶段,准备阶段的主要任务是为类变量(静态变量)分配内存并设置默认初始值,即零值。这一步骤确保所有类变量都有初始状态,以便在类的初始化阶段进一步处理。
主要特征
为类变量分配内存:
- 在准备阶段,JVM 为类的静态变量分配内存空间。这些变量将存储在方法区中(或元空间,在现代 JVM 中)。
设置默认初始值:
- 所有静态变量在准备阶段被赋予默认的初始值,即零值。例如:
int
类型变量被初始化为0
。boolean
类型变量被初始化为false
。- 对象引用类型变量被初始化为
null
。
- 所有静态变量在准备阶段被赋予默认的初始值,即零值。例如:
示例代码
public class HelloApp {
private static int a = 1; // 准备阶段为 0,在初始化阶段才是 1
public static void main(String[] args) {
System.out.println(a);
}
}
2
3
4
5
6
- 在准备阶段,变量
a
被初始化为0
。 - 在初始化阶段,变量
a
才被赋值为1
。
例外情况:final
修饰的静态变量
- 对于
final
修饰的静态变量,由于final
变量在编译时已经被确定,因此在准备阶段会被显式初始化为编译时的常量值。 - 例如:
public class FinalExample { private static final int b = 2; // 准备阶段直接被初始化为 2 }
1
2
3
不包含实例变量的初始化
- 在准备阶段,不会为实例变量分配内存或设置初始值。
- 实例变量会在对象实例化时随对象一起分配到 Java 堆中。
总结
准备阶段:
- 为类的静态变量分配内存并设置默认初始值,即零值。
- 不包含用
final
修饰的静态变量,它们在准备阶段会被显式初始化为编译时的常量值。 - 不会为实例变量分配内存,实例变量会在对象实例化时分配到 Java 堆中。
初始化阶段:
- 变量在初始化阶段被赋予程序指定的初始值,例如
int a = 1
。
- 变量在初始化阶段被赋予程序指定的初始值,例如
# 解析 Resolve
在 Java 类的加载过程中,解析阶段是将常量池中的符号引用转换为直接引用的过程。
符号引用和直接引用
符号引用:
- 符号引用是一组符号来描述所引用的目标,通常以字符串的形式出现。
- 在 Java 虚拟机规范中,符号引用的字面量形式在 Class 文件格式中有明确的定义。常量池中的符号引用包括类、接口、字段、方法等的描述信息。
直接引用:
- 直接引用是指向目标的指针、相对偏移量或间接定位到目标的句柄。换句话说,直接引用就是目标对象在内存中的地址。
- 解析阶段将符号引用解析为直接引用,使得 JVM 可以通过直接引用快速访问目标对象。
解析操作的时机:事实上,解析操作往往会在 JVM 执行完初始化之后再执行。这意味着在类加载的链接阶段中,解析操作可能会被延迟到类的初始化之后。
解析的目标:解析动作主要针对以下几种常量池中的符号引用
- 类或接口:常量池中的
CONSTANT_Class_info
。 - 字段:常量池中的
CONSTANT_Fieldref_info
。 - 类方法:常量池中的
CONSTANT_Methodref_info
。 - 接口方法:常量池中的
CONSTANT_InterfaceMethodref_info
。 - 方法类型:常量池中的
CONSTANT_MethodType_info
。
这些符号引用在解析过程中会被转换为直接引用,确保 JVM 可以高效地访问和调用类、接口、字段和方法。
总结
解析阶段是将常量池中的符号引用转换为直接引用的过程。符号引用是一组描述目标的符号,而直接引用是目标在内存中的地址。解析操作确保 JVM 可以通过直接引用高效地访问和调用类、接口、字段和方法。解析阶段通常在初始化之后进行,确保类的所有依赖项都已被正确加载和初始化。
# 初始化阶段
首先看张图:
初始化阶段是类加载过程的最后一步,它负责执行类的初始化方法 <clinit>
和构造方法 <init>
。
<inint>
是针对类的普通变量进行初始化,如int num = 10;
<clinit>
是针对类的静态变量进行初始化,如:static int num = 10;
类型初始化方法 <clinit>
,JVM 通过 Classload 进行类型加载时,如果在加载时需要进行类型初始化操作时,则会调用类型的初始化方法。类型初始化方法主要是对 static 变量进行初始化操作,对 static 域和 static 代码块初始化的逻辑全部封装在 <clinit>
方法中。
<clinit>
方法是由编译器自动收集类中的静态变量赋值和静态代码块中的语句合并生成的,不需要显式定义。也就是说,当类中包含静态变量或静态代码块时,编译器会自动生成
<clinit>
方法。
构造器方法中指令按语句在源文件中出现的顺序执行。
<clinit>
不同于类的构造器。(关联:构造器是虚拟机视角下的 <clinit>
,若该类具有父类,JVM 会保证子类的 <clinit>
执行前,父类的 <clinit>
已经执行完毕。)
- 任何一个类在声明后,都有生成一个构造器,默认是空参构造器,在
<init>
中 <init>
是类的实例初始化方法,用于普通变量的初始化和实例代码块的执行。
public class ClassInitTest {
private static int num = 1;
static {
num = 2;
number = 20;
System.out.println(num);
System.out.println(number); // 报错,非法的前向引用
}
private static int number = 10; // 因为链接阶段(linking)已经将所有的变量赋值为零值,所以位置在哪都可以
public static void main(String[] args) {
System.out.println(ClassInitTest.num); // 2
System.out.println(ClassInitTest.number); // 10
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
第 10 行的 number 赋值过程:
- 链接阶段(linking)的准备 Prepare 已经将所有的变量赋值为零值,所以位置在哪都可以
- 按上下顺序执行,在 static 里将 number 赋值为 20
- 最后来到第 10 行,将 number 赋值为 10
所以最终输出的是 10。
关于涉及到父类时候的变量赋值过程
public class ClinitTest1 {
static class Father {
public static int A = 1;
static {
A = 2;
}
}
static class Son extends Father {
public static int b = A;
}
public static void main(String[] args) {
System.out.println(Son.b); // 输出 2
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
我们输出结果为 2,也就是说首先加载 ClinitTest1 的时候,会找到 main 方法,然后执行 Son 的初始化,但是 Son 继承了 Father,因此还需要执行 Father 的初始化,同时将 A 赋值为 2。我们通过反编译得到 Father 的加载过程,首先我们看到原来的值被赋值成 1,然后又被赋值成 2,最后返回。
iconst_1
putstatic #2 <com/kbt/java/chapter02/ClinitTest1$Father.A>
iconst_2
putstatic #2 <com/kbt/java/chapter02/ClinitTest1$Father.A>
return
2
3
4
5
# 多线程环境下的类初始化
在多线程环境下,JVM 必须确保一个类的 <clinit>
方法只能被一个线程执行,以保证初始化的同步和安全。
public class DeadThreadTest {
public static void main(String[] args) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 线程t1开始");
new DeadThread();
}, "t1").start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 线程t2开始");
new DeadThread();
}, "t2").start();
}
}
class DeadThread {
static {
if (true) {
System.out.println(Thread.currentThread().getName() + "\t 初始化当前类");
while(true) {
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
上面的代码,输出结果为
线程t1开始
线程t2开始
线程t2 初始化当前类
2
3
从上面可以看出初始化后,只能够执行一次初始化,这也就是同步加锁的过程。
# 什么时候初始化普通变量?
普通变量要和类一起初始化(<init>
方法里),也是在初始化阶段,初始化阶段如下:
- 首先,加载父类中的静态代码块和静态属性,按照静态代码块和静态属性在代码中从上到下出现的先后顺序加载
- 然后,加载子类中的静态代码块和静态属性,按照静态代码块和静态属性在代码中从上到下出现的先后顺序加载
- 其次,加载父类中的普通代码块和普通属性,按照普通代码块和普通属性在代码中从上到下出现的先后顺序加载
- 最后,加载子类中的普通代码块和普通属性,按照普通代码块和普通属性在代码中从上到下出现的先后顺序加载
# 4. 类加载器的分类
JVM 支持两种类型的类加载器。分别为 引导类加载器(Bootstrap ClassLoader) 和 自定义类加载器(User-Defined ClassLoader)。
从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是 Java 虚拟机规范却没有这么定义,而是 将所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器。
无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有 3 个,如下所示:
- 系统类加载器(System ClassLoader):通常称为应用类加载器,加载应用程序类路径(
classpath
)下的类。 - 扩展类加载器(Extension ClassLoader):加载扩展目录(
jre/lib/ext
)中的类。 - 引导类加载器(Bootstrap ClassLoader):加载核心类库,无法通过 Java 代码直接获取。
这里的四者之间是包含关系,不是上层和下层,也不是子系统的继承关系。
我们通过一个类,获取它不同的加载器
public class ClassLoaderTest {
public static void main(String[] args) {
// 获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
// 输出系统类加载器
System.out.println(systemClassLoader); // sun.misc.Launcher$AppClassLoader@18b4aac2
// 获取系统类加载器的父类加载器:扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
// 输出扩展类加载器
System.out.println(extClassLoader); // sun.misc.Launcher$ExtClassLoader@1b6d3586
// 尝试获取扩展类加载器的父类加载器:引导类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
// 输出引导类加载器(此处将返回 null,因为引导类加载器无法通过代码获取)
System.out.println(bootstrapClassLoader); // null
// 获取自定义类加载器,默认情况下为系统类加载器
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
// 输出自定义类加载器
System.out.println(classLoader); // sun.misc.Launcher$AppClassLoader@18b4aac2
// 获取 String 类型的类加载器(核心类库)
ClassLoader classLoader1 = String.class.getClassLoader();
// 输出 String 类的类加载器(返回 null,表示由引导类加载器加载)
System.out.println(classLoader1); // null
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
得到的结果,从结果可以看出引导类加载器无法直接通过代码获取,同时目前用户代码所使用的加载器为系统类加载器。同时我们通过获取 String 类型的加载器,发现是 null,那么说明 String 类型是通过引导类加载器进行加载的,也就是说 Java 的核心类库都是使用引导类加载器进行加载的。
代码输出:
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1540e19d
null
sun.misc.Launcher$AppClassLoader@18b4aac2
null
2
3
4
5
笔记
引导类加载器属于 Java 皇室成员使用,平民百姓只能使用自定义类加载器。
# 启动类加载器(引导类加载器,Bootstrap ClassLoader)
- 这个类加载使用 C/C++ 语言实现的,嵌套在 JVM 内部
- 它用来加载 Java 的核心库(
JAVAHOME/jre/1ib/rt.jar、resources.jar
或sun.boot.class.path
路径下的内容),用于提供 JVM 自身需要的类 - 并不继承自 java.lang.ClassLoader,没有父加载器
- 加载扩展类和应用程序类加载器,并指定为他们的父类加载器
- 出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类
# 扩展类加载器(Extension ClassLoader)
- Java 语言编写,由
sun.misc.Launcher$ExtClassLoader
实现 - 派生于 ClassLoader 类
- 父类加载器为启动类加载器
- 从
java.ext.dirs
系统属性所指定的目录中加载类库,或从 JDK 的安装目录的jre/1ib/ext
子目录(扩展目录)下加载类库。如果用户创建的 JAR 放在此目录下,也会自动由扩展类加载器加载
# 应用程序类加载器(系统类加载器,AppClassLoader)
- Java 语言编写,由
sun.misc.LaunchersAppClassLoader
实现 - 派生于 ClassLoader 类
- 父类加载器为扩展类加载器
- 它负责加载环境变量 classpath 或系统属性
java.class.path
指定路径下的类库 - 该类加载是程序中默认的类加载器,一般来说,Java 应用的类都是由它来完成加载
- 通过
classLoader#getSystemclassLoader()
方法可以获取到该类加载器
# 用户自定义类加载器
在 Java 的日常应用程序开发中,类的加载几乎是由系统类加载器、扩展类加载器和引导类加载器这三种类加载器相互配合执行的。但是在某些特殊需求下,我们可以自定义类加载器来定制类的加载方式。自定义类加载器的目的包括但不限于:
- 隔离加载类:避免类冲突或版本不兼容。
- 修改类加载方式:可以实现类的动态加载或修改。
- 扩展加载源:从非传统的源(如网络、数据库等)加载类。
- 防止源码泄漏:通过自定义类加载器加载加密的类文件,防止源码泄漏。
用户自定义类加载器实现步骤:
- 开发人员可以通过继承抽象类
java.1ang.ClassLoader
类的方式,实现自己的类加载器,以满足一些特殊的需求 - 在 JDK1.2 之前,在自定义类加载器时,总会去继承 ClassLoader 类并重写
loadClass()
方法,从而实现自定义的类加载类,但是在 JDK1.2 之后已不再建议用户去覆盖loadClass()
方法,而是建议把自定义的类加载逻辑写在findClass()
方法中 - 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承 URIClassLoader 类,这样就可以避免自己去编写
findClass()
方法及其获取字节码流的方式,使自定义类加载器编写更加简洁
代码实例:
import java.io.FileNotFoundException;
public class CustomClassLoader extends ClassLoader {
// 重写 findClass 方法,定义类的加载逻辑
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
// 从自定义路径获取类的字节码
byte[] result = getClassFromCustomPath(name);
if (result == null) {
throw new FileNotFoundException();
} else {
// 调用 defineClass 方法,将字节码转换为 Class 对象
return defineClass(name, result, 0, result.length);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
}
// 如果类未找到,则抛出 ClassNotFoundException
throw new ClassNotFoundException(name);
}
// 自定义方法:从自定义路径加载类的字节码
private byte[] getClassFromCustomPath(String name) {
// 这里添加从自定义路径加载类字节码的逻辑
// 如果类文件进行了加密,需要在此处进行解密操作
return null; // 仅为示例,实际应返回类的字节码数组
}
public static void main(String[] args) {
// 创建自定义类加载器实例
CustomClassLoader customClassLoader = new CustomClassLoader();
try {
// 使用自定义类加载器加载类
Class<?> clazz = Class.forName("One", true, customClassLoader);
// 创建类的实例
Object obj = clazz.newInstance();
// 输出加载该类的类加载器
System.out.println(obj.getClass().getClassLoader());
} catch (Exception e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 引导类加载器加载的目录
刚刚我们通过概念了解到了,根加载器只能够加载 java /lib 目录下的 class,我们通过下面代码验证一下
public class ClassLoaderTest1 {
public static void main(String[] args) {
System.out.println("*********启动类加载器************");
// 获取 Bootstrap ClassLoader 能够加载的 API 的路径
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for (URL url : urls) {
System.out.println(url.toExternalForm());
}
// 从上面路径中,随意选择一个类,来看看他的类加载器是什么:得到的是 null,说明是引导类加载器
ClassLoader classLoader = Provider.class.getClassLoader();
System.out.println(classLoader);
System.out.println("*********扩展类加载器************");
String extDirs = System.getProperty("java.ext.dirs");
for (String path : extDirs.split(";")){
System.out.println(path);
}
// 从上面路径中,随意选择一个类,来看看他的类加载器是什么:得到的是扩展类加载器
ClassLoader classLoader1 = CurveDB.class.getClassLoader();
System.out.println(classLoader1);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
得到的结果
*********启动类加载器************
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/lib/resources.jar
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/lib/rt.jar
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/lib/sunrsasign.jar
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/lib/jsse.jar
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/lib/jce.jar
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/lib/charsets.jar
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/lib/jfr.jar
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/classes
null
*********扩展类加载器************
C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext
C:\Windows\Sun\Java\lib\ext
sun.misc.Launcher$ExtClassLoader@4b67cf4d
2
3
4
5
6
7
8
9
10
11
12
13
14
# 关于 ClassLoader(类加载器)
ClassLoader
是一个抽象类,Java 中的所有类加载器都继承自它(除了启动类加载器,它是由 JVM 本身实现的)。ClassLoader
类及其子类负责加载类文件到 JVM 中,并且允许用户定义自定义的类加载机制。
ClassLoader 类的基本结构
ClassLoader
类的继承关系如下图所示:
sun.misc.Launcher
sun.misc.Launcher
是 Java 虚拟机的入口应用,负责初始化并启动 JVM,它本身也是类加载器的一部分。
获取 ClassLoader 的途径:
- 获取当前类的 ClassLoader:
clazz.getClassLoader()
1 - 获取当前线程上下文的 ClassLoader:
Thread.currentThread().getContextClassLoader()
1 - 获取系统的 ClassLoader:
ClassLoader.getSystemClassLoader()
1 - 获取调用者的 ClassLoader:
DriverManager.getCallerClassLoader()
1
以下代码展示了如何获取不同的 ClassLoader:
public class ClassLoaderTest2 {
public static void main(String[] args) {
try {
// 1. 获取 java.lang.String 类的类加载器
ClassLoader classLoader = Class.forName("java.lang.String").getClassLoader();
System.out.println(classLoader); // 输出 null,表示由引导类加载器加载
// 2. 获取当前线程上下文的类加载器
ClassLoader classLoader1 = Thread.currentThread().getContextClassLoader();
System.out.println(classLoader1); // 输出应用类加载器
// 3. 获取系统类加载器的父类加载器
ClassLoader classLoader2 = ClassLoader.getSystemClassLoader().getParent();
System.out.println(classLoader2); // 输出扩展类加载器
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
代码输出结果:
null
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1540e19d
2
3
# 5. 双亲委派机制
Java 虚拟机对 Class 文件采用的是 按需加载 的方式,即当需要使用某个类时,才会将其 Class 文件加载到内存中生成 Class 对象。在加载类的过程中,Java 虚拟机采用 双亲委派模式(Parent Delegation Model),即把请求交由父类处理,它是一种任务委派模式,确保类加载的安全性和一致性。
# 工作原理
- 请求委托:
- 当类加载器收到类加载请求时,并不会立即尝试加载该类,而是将请求委托给父类加载器处理。
- 递归委托:
- 如果父类加载器还存在其父类加载器,则继续向上委托,依次递归,直到请求到达顶层的启动类加载器(Bootstrap ClassLoader)。
- 加载尝试:
- 顶层加载器:启动类加载器尝试加载类,如果成功,则返回该类。
- 失败回退:如果启动类加载器无法加载类,则逐层向下返回,依次回退到下一级类加载器。每个子类加载器都会尝试加载该类。
- 每个子类加载器在收到其父类无法加载的结果后,才会自己尝试加载类。
- 这种机制确保了只有当所有父级加载器都无法加载该类时,才会由当前子类加载器进行加载。
示例图解
# 双亲委派机制举例
举例 1
1、我们自己建立一个 java.lang.String 类,写上 static 代码块
public class String {
static{
System.out.println("我是自定义的String类的静态代码块");
}
}
2
3
4
5
2、在另外的程序中加载 String 类,看看加载的 String 类是 JDK 自带的 String 类,还是我们自己编写的 String 类
public class StringTest {
public static void main(String[] args) {
java.lang.String str = new java.lang.String();
System.out.println("hello,scholar");
StringTest test = new StringTest();
System.out.println(test.getClass().getClassLoader());
}
}
2
3
4
5
6
7
8
9
10
输出结果:
hello,scholar
sun.misc.Launcher$AppClassLoader@18b4aac2
2
分析:程序并没有输出我们静态代码块中的内容,说明加载的是 JDK 自带的 String
类,而不是我们自定义的 String
类。这是因为双亲委派机制导致自定义的 java.lang.String
类没有被加载。
尝试运行自定义 String
类
假设我们写了下面的自定义 String
类并尝试运行:
package java.lang;
public class String {
static{
System.out.println("我是自定义的String类的静态代码块");
}
// 错误: 在类 java.lang.String 中找不到 main 方法
public static void main(String[] args) {
System.out.println("hello,String");
}
}
2
3
4
5
6
7
8
9
10
分析:运行时会出现错误:“在类 java.lang.String 中找不到 main 方法”。这是因为双亲委派机制首先会向上委派加载请求,最终由引导类加载器(Bootstrap ClassLoader)加载 JDK 自带的 String
类。然而,JDK 自带的 String
类中并没有 main
方法,所以会报错。
总结
- 双亲委派机制确保了 Java 标准库类的优先加载。自定义的
java.lang.String
类由于双亲委派机制不会被加载。 - 类加载顺序:首先从引导类加载器(Bootstrap ClassLoader)开始,如果找不到,再依次委派给扩展类加载器(ExtClassLoader)和应用类加载器(AppClassLoader)。
- 避免命名冲突:为了避免与标准库类的命名冲突,最好不要自定义与标准库类同名的类。
举例 2
package java.lang;
public class ShkStart {
public static void main(String[] args) {
System.out.println("hello!");
}
}
2
3
4
5
6
7
8
输出结果:
java.lang.SecurityException: Prohibited package name: java.lang
at java.lang.ClassLoader.preDefineClass(ClassLoader.java:662)
at java.lang.ClassLoader.defineClass(ClassLoader.java:761)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:335)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main"
Process finished with exit code 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
即使类名没有重复,也禁止使用 java.lang 这种包名。这是一种保护机制,这样可以保证对 java 核心源代码的保护。
总结
- 禁止使用核心包名:在 Java 中,核心包名如
java.lang
是受保护的,用户不能在这些包名下定义自己的类。这是为了防止用户类与核心类冲突,确保 JVM 的正常运行。 - 安全机制:JVM 通过在类加载时检查类的包名,如果发现使用了受保护的包名,则抛出
SecurityException
,阻止类的加载。 - 错误信息:当尝试在受保护的包名下定义类时,会抛出
java.lang.SecurityException: Prohibited package name: java.lang
,并且类加载过程会中断。
举例 3
当我们加载 jdbc.jar 用于实现数据库连接的时候,首先我们需要知道的是 jdbc.jar 是基于 SPI 接口进行实现的,所以在加载的时候,会进行双亲委派,最终从根加载器中加载 SPI 核心类,然后在加载 SPI 接口类,接着在进行反向委派,通过线程上下文类加载器进行实现类 jdbc.jar 的加载。
上下文类加载器的出现背景具体看 再谈类的加载器。
# 沙箱安全机制
Java 安全模型的核心就是 Java 沙箱(Sandbox)。沙箱是一个限制程序运行的环境,旨在保护系统资源免受恶意代码的侵害。
什么是沙箱?
沙箱机制是将 Java 代码限定在虚拟机(JVM)特定的运行范围内,并严格限制代码对本地系统资源的访问。这种限制通过制定和执行安全策略来实现,以确保代码在受控环境中运行,防止对本地系统造成破坏。
沙箱机制的特点
资源访问限制:
- CPU:限制程序能够使用的 CPU 资源,防止恶意代码占用过多的计算资源。
- 内存:控制程序使用的内存量,防止内存泄漏或过度使用。
- 文件系统:限制程序对文件系统的读写操作,防止未经授权的文件访问和修改。
- 网络:限制程序的网络访问权限,防止未经授权的网络连接和数据传输。
安全策略定制:
- 所有 Java 程序运行时都可以指定沙箱,并定制安全策略以适应不同的安全需求。通过配置安全策略文件,可以定义哪些操作是允许的,哪些是禁止的。
不同级别的沙箱
不同级别的沙箱对系统资源访问的限制可以有所不同。通常,沙箱机制会根据程序的来源和可信度来决定其访问权限。例如:
- Applets:在浏览器中运行的小程序,通常被限制在最严格的沙箱中,无法访问本地文件系统或网络。
- 应用程序:根据签名和安全策略,可能会有较高的访问权限。
关于不同 JDK 版本的沙箱安全机制进化,请看 再谈类的加载器。
# 双亲委派机制的优势
双亲委派机制是 Java 类加载器的一种重要机制,其核心思想是将类加载请求从子类加载器向上委派给父类加载器,直到引导类加载器。这种机制具有以下优势:
- 避免类的重复加载:
- 减少资源浪费:通过双亲委派机制,每个类只会被加载一次,并由唯一的类加载器进行管理。这样可以避免类的重复加载,从而节省系统资源。
- 确保类的一致性:保证同一个类在 JVM 中只存在一个实例,这样可以避免类冲突和不一致性问题。
- 保护程序安全,防止核心 API 被随意篡改:
- 防止核心类被篡改:双亲委派机制首先由引导类加载器加载核心类库,如 java.lang.*,确保这些类的加载优先级最高,从而防止用户自定义类加载器加载伪造的核心类。
- 自定义类
java.lang.String
:用户自定义一个java.lang.String
类,在双亲委派机制下,该请求会被委派给引导类加载器,加载核心库中的String
类,而不是用户自定义的类。 - 自定义类
java.lang.ShkStart
:尝试创建一个自定义的java.lang.ShkStart
类,会抛出SecurityEx
- 自定义类
- 防止核心类被篡改:双亲委派机制首先由引导类加载器加载核心类库,如 java.lang.*,确保这些类的加载优先级最高,从而防止用户自定义类加载器加载伪造的核心类。
# 6. 其它
# 判断两个 Class 对象是否相同
在 JVM 中,判断两个 Class 对象是否表示同一个类需要满足两个条件:
- 类的完整类名必须一致:包括包名在内的全限定类名必须相同。
- 加载类的 ClassLoader 必须相同:即加载这两个类的 ClassLoader 实例对象必须是同一个。
即使两个类对象(Class 对象)来源于同一个 Class 文件,被同一个 JVM 所加载,只要加载它们的 ClassLoader 实例对象不同,这两个类对象也是不相等的。
JVM 如何管理类加载器信息
- 类加载器引用:如果一个类型是由用户类加载器加载的,那么 JVM 会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用时,JVM 需要保证这两个类型的类加载器是相同的。
下面是一个示例代码,展示如何判断两个 Class 对象是否相同:
public class ClassLoaderTest3 {
public static void main(String[] args) {
try {
// 使用系统类加载器加载类
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
Class<?> class1 = systemClassLoader.loadClass("java.lang.String");
// 使用自定义类加载器加载类
CustomClassLoader customClassLoader = new CustomClassLoader();
Class<?> class2 = customClassLoader.loadClass("java.lang.String");
// 判断两个类对象是否相同
boolean isSameClass = class1 == class2;
System.out.println("Are the two classes the same? " + isSameClass);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
class CustomClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
return super.loadClass(name);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
输出结果:
Are the two classes the same? false
- 解释:即使两个类的全限定名相同(例如都是
java.lang.String
),但由于它们是由不同的 ClassLoader 加载的,因此这两个类对象是不相等的。
# 类的主动使用和被动使用
Java 程序对类的使用方式分为主动使用和被动使用。
# 主动使用
主动使用会导致类的初始化,具体包括以下七种情况:
创建类的实例:
MyClass myClass = new MyClass();
1访问或修改类或接口的静态变量:
int value = MyClass.staticField; MyClass.staticField = 100;
1
2调用类的静态方法:
MyClass.staticMethod();
1反射:
Class.forName("com.example.MyClass");
1初始化一个类的子类:
// 假设 Parent 类是一个类,Child 是其子类 Child child = new Child();
1
2Java 虚拟机启动时被标明为启动类的类:
- 例如,包含
main
方法的类。
- 例如,包含
JDK 7 动态语言支持:
- 使用
java.lang.invoke.MethodHandle
实例的解析结果,如果是REF_getStatic
、REF_putStatic
、REF_invokeStatic
句柄对应的类没有初始化,则会初始化该类。
- 使用
# 被动使用
除了上述七种情况,其他使用 Java 类的方式都被认为是被动使用,不会导致类的初始化。例如:
通过子类引用父类的静态字段(只会初始化父类,不会初始化子类):
System.out.println(ChildClass.parentStaticField);
1定义类数组:
MyClass[] myClassArray = new MyClass[10];
1引用常量:
System.out.println(MyClass.CONSTANT);
1
总结
通过 JVM 判断两个 Class 对象是否相同,需要满足类的全限定名相同和加载类的 ClassLoader 相同这两个条件。Java 中类的使用分为主动使用和被动使用,主动使用会触发类的初始化,而被动使用不会。理解这些机制对于掌握 Java 的类加载过程和调试复杂的类加载问题非常有帮助。
JVM 在管理类加载器信息时,会将类加载器的引用作为类型信息的一部分保存在方法区中,以确保在类型解析时,类加载器的一致性,从而保证系统的安全性和稳定性。