对于Java开发者来说,我们每天都在编写 .java
文件,然后通过编译器将其编译成 .class
文件。那么,这些 .class
文件是如何被加载到Java虚拟机(JVM)中,并最终变成我们可以在程序中使用的对象和方法的呢?这个过程就是 类加载(Class Loading)。
理解类加载机制,不仅仅是满足技术好奇心,更是解决实际问题的关键。你是否遇到过 ClassNotFoundException
或 NoClassDefFoundError
异常?是否好奇为什么 Tomcat 等Web容器可以隔离不同应用的类库?是否想了解热部署、模块化等高级特性是如何实现的?这些问题的答案,都深藏在类加载机制之中。
类加载 是指 Java 虚拟机(JVM)将描述类的数据从 .class
文件(或其他来源)加载到内存,并对数据进行校验、转换、解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型(java.lang.Class
对象)的过程。
一个类型(类或接口)从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期可以划分为以下 七个阶段:
其中,验证、准备、解析 三个阶段通常被统称为 连接 (Linking) 阶段。
这个过程并不是严格按照这个顺序按部就班地执行,比如解析阶段有时可以在初始化阶段之后再开始,这是为了支持 Java 语言的动态绑定(也称为晚期绑定或运行时绑定)。但大体上,类的加载、连接、初始化这几个主要步骤的开始顺序是确定的。
接下来的章节,我们将详细探讨加载、连接(验证、准备、解析)、初始化这五个核心阶段。
“加载”是整个类加载过程的第一个阶段。在这个阶段,JVM 需要完成以下三件事情:
通过一个类的全限定名来获取定义此类的二进制字节流。
java.lang.String
。JVM 需要根据这个名字找到对应的 .class
文件。这个字节流并不一定来自本地文件系统中的 .class
文件,它可以来自多种渠道,例如:
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
.class
文件中存储的是类的静态描述信息,比如类的字段、方法、常量池等。JVM 需要将这些静态信息解析出来,并按照 JVM 规范的要求,在内存的方法区(Metaspace 或 PermGen)中组织成特定的数据结构,供后续阶段使用。在内存中生成一个代表这个类的 java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。
MyClass.class
或者 obj.getClass()
获取到的就是这个 Class
对象。它像是一个接口,让我们可以通过它访问到 JVM 方法区中关于这个类的所有信息(字段、方法、构造器等)。这个 Class
对象通常存储在 Java 堆(Heap)中,但 HotSpot VM 将其放到了方法区。注意:加载阶段和连接阶段的部分动作(如一部分字节码文件格式验证)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。但这并不影响我们概念上对这两个阶段的划分。
连接阶段是将加载到内存的类数据组装起来,使其成为 JVM 可以运行的状态。它又细分为验证、准备和解析三个子阶段。
验证是连接阶段的第一步,其目的是确保 Class 文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
理解帮助:想象一下,如果任何人都可以随意编写字节码并让 JVM 加载运行,那将是极其危险的。验证阶段就像是 JVM 的一道安全防线,对加载进来的字节码进行严格的检查。这个过程大致会进行下面四个阶段的检验:
文件格式验证:
0xCAFEBABE
开头。元数据验证:
java.lang.Object
之外,所有的类都应当有父类)。final
修饰的类)。final
字段,或者出现不符合规则的方法重载)。字节码验证:
int
类型的数据,使用时却按 long
类型来加载入本地变量表中”这样的情况。符号引用验证:
private
、protected
、public
、package
)是否可被当前类访问。java.lang.IncompatibleClassChangeError
的子类异常,如 IllegalAccessError
、NoSuchFieldError
、NoSuchMethodError
等。验证阶段对于虚拟机的安全至关重要,但不是必须的(因为大部分验证在编译期间已经完成)。如果代码来源可靠,可以通过 -Xverify:none
参数关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备阶段是 正式为类中定义的变量(即静态变量,被 static
修饰的变量)分配内存并设置类变量初始值 的阶段。
理解帮助:
static
的)是在对象实例化时随着对象一起分配在 Java 堆中的。int
: 0long
: 0Lshort
: (short) 0char
: ‘\u0000’byte
: (byte) 0boolean
: falsefloat
: 0.0fdouble
: 0.0dreference
(引用类型): null示例:
public class MyClass {
public static int value = 123; // 准备阶段后 value 的值是 0,而不是 123
public static final int CONST_VALUE = 456; // 特殊情况!
public static String text = "hello"; // 准备阶段后 text 的值是 null
public static final String CONST_TEXT = "world"; // 特殊情况!
}
在准备阶段:
value
会被分配内存,并初始化为 0
。赋值 123
的动作是在 初始化阶段 执行的。text
会被分配内存,并初始化为 null
。赋值 "hello"
的动作也是在 初始化阶段 执行的。特殊情况:static final
常量
如果类字段的字段属性表中存在 ConstantValue
属性(即同时被 static
和 final
修饰,并且是基本类型或 String
类型),那么在准备阶段,变量就会被初始化为 ConstantValue
属性所指定的值。
所以在上面的例子中:
CONST_VALUE
在准备阶段就会被直接赋值为 456
。CONST_TEXT
在准备阶段就会被直接赋值为 "world"
。这是因为这些常量的值在编译时就已经确定,并存储在 .class
文件的常量池中。
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用 的过程。
理解帮助:
符号引用 (Symbolic References):以一组符号来描述所引用的目标。符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。
.class
文件中用 CONSTANT_Class_info
、CONSTANT_Fieldref_info
、CONSTANT_Methodref_info
等常量来描述类、字段、方法。这些都是符号引用。直接引用 (Direct References):可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
解析动作主要针对 类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 这 7 类符号引用进行。
解析阶段的发生时机并不固定,JVM 规范并未规定解析阶段发生的具体时间,只要求了在执行 ane-warray
、checkcast
、getfield
、getstatic
、instanceof
、invokedynamic
、invokeinterface
、invokespecial
、invokestatic
、invokevirtual
、ldc
、ldc_w
、ldc2_w
、multianewarray
、new
、putfield
、putstatic
这 17 个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。
所以,虚拟机实现可以根据需要来判断,是在类被加载器加载时就对常量池中的符号引用进行解析(饿汉式/静态解析),还是等到一个符号引用将要被使用前才去解析它(懒汉式/动态解析)。
对同一个符号引用进行多次解析请求是很常见的事情,除了 invokedynamic
指令以外,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态),从而避免解析操作的重复执行。
初始化阶段是类加载过程的最后一步,之前的所有阶段,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。直到初始化阶段,Java 虚拟机才真正开始执行类中编写的 Java 程序代码(或者说是字节码)。
理解帮助:
在准备阶段,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去 初始化类变量和其他资源。
简单来说,初始化阶段就是执行 类构造器
方法 的过程。
()
方法
方法并不是程序员在 Java 代码中直接编写的,它是 Javac 编译器的自动生成物。它是由编译器自动收集类中的所有 类变量的赋值动作 和 静态语句块(static{}
块) 中的语句合并产生的。
合并规则:
示例:
public class InitOrder {
static {
i = 0; // 给变量 i 赋值可以正常编译通过
// System.out.print(i); // 这句编译器会提示“非法向前引用” (Illegal forward reference)
}
static int i = 1;
static {
System.out.println(i); // 这里可以访问和打印 i,输出 1
}
public static void main(String[] args) {
// ...
}
}
编译后生成的
方法大致如下(伪代码):
() {
i = 0;
i = 1; // 静态变量赋值语句
System.out.println(i); // 静态块语句
return;
}
方法的特点:
()
方法与类的构造函数(即在虚拟机视角中的实例构造器 ()
方法)不同,它不需要显式调用父类的 ()
方法。Java 虚拟机会保证在子类的 ()
方法执行前,父类的 ()
方法已经执行完毕。因此,在虚拟机中第一个被执行的 ()
方法的类型肯定是 java.lang.Object
。()
优先执行:由于父类的 ()
方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。()
方法。()
:接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 ()
方法。但接口与类不同的是,执行接口的 ()
方法不需要先执行父接口的 ()
方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的 ()
方法。()
方法在多线程环境中被正确地加锁同步。如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 ()
方法,其他线程都需要阻塞等待,直到活动线程执行完毕 ()
方法。Java 虚拟机规范严格规定了 有且只有 六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始),这六种场景被称为对一个类型的 主动引用 (Active Reference):
遇到 new
、getstatic
、putstatic
或 invokestatic
这四条字节码指令时:
new
关键字实例化对象的时候。final
修饰、已在编译期把结果放入常量池的静态字段除外)的时候。使用 java.lang.reflect
包的方法对类型进行反射调用的时候:如果类型没有进行过初始化,则需要先触发其初始化。例如 Class.forName("com.example.MyClass")
。
当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()
方法的那个类),虚拟机会先初始化这个主类。
当使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle
实例最后的解析结果为 REF_getStatic
、REF_putStatic
、REF_invokeStatic
、REF_newInvokeSpecial
四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
当一个接口定义了 JDK 8 新加入的默认方法(被 default
关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
注意:这六种场景以外,所有引用类型的方式都不会触发初始化,称为 被动引用 (Passive Reference)。常见的被动引用例子:
class Parent {
static int value = 10;
static { System.out.println("Parent init!"); }
}
class Child extends Parent {
static { System.out.println("Child init!"); }
}
public class Test {
public static void main(String[] args) {
System.out.println(Child.value); // 只会输出 "Parent init!" 和 10,不会输出 "Child init!"
}
}
public class Test {
public static void main(String[] args) {
Parent[] parents = new Parent[10]; // 不会输出 "Parent init!"
}
}
class ConstClass {
static final String HELLO = "hello world";
static { System.out.println("ConstClass init!"); }
}
public class Test {
public static void main(String[] args) {
System.out.println(ConstClass.HELLO); // 不会输出 "ConstClass init!"
}
}
这里 HELLO
在编译后,Test
类的常量池中会直接持有 "hello world"
的引用,与 ConstClass
没有关系了。类的初始化阶段是线程安全的。JVM 内部会保证一个类的
方法在多线程环境下被正确地加锁、同步。
理解帮助:
想象一下,如果有多个线程同时尝试初始化同一个类(例如,同时调用该类的静态方法),如果没有同步机制,可能会导致
方法被执行多次,或者出现状态不一致的问题。
JVM 的实现方式通常是这样的:
()
方法。()
方法并释放锁。()
方法时,发现该类已经被自己初始化过(递归初始化),则直接返回,不会引起死锁。重要:如果在一个类的
方法中有耗时很长的操作,就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
前面我们讨论了类加载的过程,那么这个过程具体是由谁来完成的呢?答案是 类加载器 (Class Loader)。
类加载器是 Java 虚拟机实现的一个重要模块,它的主要作用就是实现类加载过程中的第一个步骤:“通过一个类的全限定名来获取描述此类的二进制字节流”。
对于任意一个类,都必须由 加载它的类加载器 和 这个类本身 一起共同确立其在 Java 虚拟机中的唯一性。比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个 Java 虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
从 Java 虚拟机的角度来看,只存在两种不同的类加载器:
java.lang.ClassLoader
。从我们 Java 开发人员的角度来看,类加载器就应当划分得更细致一些。自 JDK 1.2 以来,Java 一直保持着三层类加载器、双亲委派的类加载架构(JDK 9 后有所调整)。
\lib
目录,或者被 -Xbootclasspath
参数所指定的路径中存放的,而且是 Java 虚拟机能够识别的(按照文件名识别,如 rt.jar
、tools.jar
,名字不符合的类库即使放在 lib 目录下也不会被加载)核心类库。例如 java.lang.*
, java.util.*
等。null
来表示。尝试调用 String.class.getClassLoader()
会返回 null
。\lib\ext
目录中,或者被 java.ext.dirs
系统变量所指定的路径中所有的类库。sun.misc.Launcher$ExtClassLoader
(JDK 8 及之前)或 jdk.internal.loader.ClassLoaders$PlatformClassLoader
(JDK 9 及之后)的实例。ClassLoader.getSystemClassLoader().getParent()
来获取(但 JDK 9 后可能不完全准确,因为模型有变)。sun.misc.Launcher$AppClassLoader
(JDK 8 及之前)或 jdk.internal.loader.ClassLoaders$AppClassLoader
(JDK 9 及之后)的实例。getSystemClassLoader()
方法的返回值,所以也称为“系统类加载器 (System ClassLoader)”。它是程序中默认的类加载器。java.lang.ClassLoader
类,重写 findClass()
或 loadClass()
方法。这种层次关系,以及类加载器之间的协作模式,就是著名的 双亲委派模型 (Parents Delegation Model)。
前面提到,在 JVM 中,一个类的唯一性是由 类加载器 + 类的全限定名 共同确定的。
理解帮助:
.class
文件两次会失败或返回同一个 Class
对象)。.class
文件加载)。但在 JVM 看来,这两个 Class
对象是完全不同的、不兼容的类型。示例:尝试用不同的类加载器加载同一个类,然后进行类型转换或比较。
import java.io.*;
import java.lang.reflect.Method;
// 自定义一个简单的类加载器
class MyClassLoader extends ClassLoader {
private String rootDir;
public MyClassLoader(String rootDir) {
this.rootDir = rootDir;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String filePath = rootDir + File.separator + name.replace('.', File.separatorChar) + ".class";
try (InputStream is = new FileInputStream(filePath);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
byte[] classBytes = baos.toByteArray();
// 调用 defineClass 将字节数组转为 Class 对象
return defineClass(name, classBytes, 0, classBytes.length);
} catch (IOException e) {
throw new ClassNotFoundException("Failed to load class: " + name, e);
}
}
}
// 假设在 D:/temp/ 目录下有一个 MySample.class 文件
// 内容为:
// package com.example;
// public class MySample {
// public void sayHello() { System.out.println("Hello from " + this.getClass().getClassLoader()); }
// }
public class ClassIdentityTest {
public static void main(String[] args) throws Exception {
MyClassLoader loader1 = new MyClassLoader("D:/temp");
MyClassLoader loader2 = new MyClassLoader("D:/temp");
// 使用 loader1 加载 MySample 类
Class<?> clazz1 = loader1.loadClass("com.example.MySample");
Object obj1 = clazz1.getDeclaredConstructor().newInstance();
// 使用 loader2 加载 MySample 类
Class<?> clazz2 = loader2.loadClass("com.example.MySample");
Object obj2 = clazz2.getDeclaredConstructor().newInstance();
System.out.println("clazz1: " + clazz1);
System.out.println("clazz1 Loader: " + clazz1.getClassLoader());
System.out.println("obj1 instanceof com.example.MySample (loaded by loader1)? " + (obj1 instanceof com.example.MySample)); // 这里会编译报错,因为 com.example.MySample 是由 AppClassLoader 加载的,无法直接比较
System.out.println("\nclazz2: " + clazz2);
System.out.println("clazz2 Loader: " + clazz2.getClassLoader());
System.out.println("\nclazz1 == clazz2 ? " + (clazz1 == clazz2)); // 输出 false
// 尝试调用方法
Method method1 = clazz1.getMethod("sayHello");
method1.invoke(obj1);
Method method2 = clazz2.getMethod("sayHello");
method2.invoke(obj2);
// 尝试类型转换 (会失败)
try {
com.example.MySample castedObj = (com.example.MySample) obj1; // 这里会抛 ClassCastException,因为 obj1 的类加载器是 loader1,而 (com.example.MySample) 期望的类加载器是 AppClassLoader
} catch(ClassCastException e) {
System.out.println("\nCaught ClassCastException as expected when casting obj1.");
}
try {
// 即使目标类型是用同一个加载器加载的,但如果变量类型是另一个加载器加载的类,也会失败
Object temp = clazz1.cast(obj2); // 尝试将 loader2 加载的对象转换为 loader1 加载的类类型
} catch(ClassCastException e) {
System.out.println("Caught ClassCastException as expected when casting obj2 to clazz1 type.");
}
}
}
这个例子清晰地展示了:
clazz1
和 clazz2
来自同一个 .class
文件,但因为由不同的 MyClassLoader
实例加载,它们是不同的 Class
对象 (clazz1 == clazz2
为 false)。loader1
和 loader2
实例。loader1
加载的对象 obj1
强制转换为由应用程序类加载器(默认加载 ClassIdentityTest
的加载器)加载的 com.example.MySample
类型时,会抛出 ClassCastException
。同样,在 loader1
和 loader2
加载的类型之间进行转换也会失败。这种命名空间的隔离性是实现热部署、多版本类库共存、容器类隔离(如 Tomcat 为每个 Web 应用创建独立的类加载器)等功能的基础。
双亲委派模型是 Java 类加载器在 JDK 1.2 之后引入的一种推荐的类加载机制,它并非一个强制性的约束模型,而是 Java 设计者推荐给开发者的一种类加载器实现方式。
工作过程:
理解帮助:
这就像一个公司的层级汇报制度。一个员工(子加载器)接到一个任务(类加载请求),他不会马上自己做,而是先问他的直接上级(父加载器)能不能做。上级也一样,先问自己的上级。任务一直传递到最高层老板(启动类加载器)。老板说我做不了(或做完了),任务才一级级往下退,直到某个层级的负责人说“我能做”(在自己的负责范围内找到了类),他就把任务完成了。如果一直退回到最初的员工,他发现上级都做不了,最后才轮到他自己尝试去做。
ClassLoader.loadClass()
双亲委派模型的核心逻辑实现在 java.lang.ClassLoader
的 loadClass(String name, boolean resolve)
方法中。以下是 JDK 8 中该方法的一个简化版本(去除了部分权限检查和非关键逻辑),并添加了中文注释:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 同步块,确保类加载的线程安全
synchronized (getClassLoadingLock(name)) {
// 步骤 1: 检查请求的类是否已经被加载过
// findLoadedClass(name) 会在当前类加载器的缓存中查找类
Class<?> c = findLoadedClass(name);
// 如果缓存中找到了,直接返回已加载的 Class 对象
if (c == null) {
long t0 = System.nanoTime();
try {
// 步骤 2: 尝试委派给父类加载器加载
// getParent() 获取当前类加载器的父加载器
if (parent != null) {
// 调用父加载器的 loadClass 方法,递归向上委派
c = parent.loadClass(name, false);
} else {
// 如果父加载器为 null,说明父加载器是启动类加载器 (Bootstrap ClassLoader)
// 尝试使用启动类加载器加载
// findBootstrapClassOrNull 是一个本地方法或内部实现,用于调用 Bootstrap ClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 异常捕捉:如果父加载器(包括启动类加载器)抛出 ClassNotFoundException
// 说明父加载器无法完成加载请求。
// 注意:这里只是捕捉异常,不做处理,会继续执行后续步骤。
}
// 步骤 3: 如果父加载器无法加载 (c 仍然为 null)
if (c == null) {
// 父类加载器加载失败,轮到当前类加载器自己尝试加载
long t1 = System.nanoTime();
// 调用 findClass(name) 方法,这个方法通常由子类重写,实现具体的类加载逻辑
// 例如,从文件系统、网络等地方获取字节码并定义类
c = findClass(name);
// 记录类加载相关的数据,用于性能统计等 (非核心逻辑)
// ... record stats ...
}
}
// 步骤 4: 解析阶段 (根据 resolve 参数决定)
// 如果 resolve 参数为 true,则在加载完成后立即进行链接阶段的解析操作
if (resolve) {
resolveClass(c);
}
// 返回加载并(可能)解析后的 Class 对象
return c;
}
}
// 子类通常需要重写 findClass 方法来实现自己的加载逻辑
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 默认实现是抛出 ClassNotFoundException
// 子类需要在这里实现:
// 1. 根据 name 找到对应的 .class 字节码 (例如从文件、网络读取)
// 2. 调用 defineClass() 方法将字节码转换成 Class 对象
throw new ClassNotFoundException(name);
}
源码解读要点:
findLoadedClass(name)
检查当前加载器是否已经加载过这个类。如果加载过,直接返回缓存中的 Class
对象,避免重复加载。parent
)。
parent != null
),调用 parent.loadClass(name, false)
,将请求委派给父加载器。注意这里的 resolve
参数传 false
,表示父加载器加载时不需要立即解析。parent == null
),意味着父加载器是启动类加载器,尝试调用 findBootstrapClassOrNull(name)
(这是一个内部方法)让启动类加载器加载。ClassNotFoundException
,说明它们无法加载该类。这个异常会被捕获,但不会立即抛出,程序流程会继续。c
仍然是 null
(即所有父加载器都无法加载),则调用当前类加载器的 findClass(name)
方法。这个方法是留给子类去实现的,负责查找类的字节码并调用 defineClass
将其转换为 Class
对象。如果 findClass
也找不到类,它应该抛出 ClassNotFoundException
。resolve
参数为 true
,则在类加载成功后调用 resolveClass(c)
进行链接的解析阶段。loadClass
方法被 synchronized
包裹,使用 getClassLoadingLock(name)
获取与类名相关的锁,保证多线程环境下同一个类只会被加载一次。这个实现清晰地体现了“向上委派,失败后向下尝试”的双亲委派流程。
保证 Java 核心库的类型安全:
java.lang.Object
,最终都会委派给启动类加载器加载。这确保了 Java 程序中使用的 Object
类始终是同一个类,防止了核心 API 库被随意篡改。试想一下,如果用户可以自己写一个 java.lang.Object
类并成功加载,那整个 Java 体系都会崩溃。避免类的重复加载:
保证类的唯一性:
instanceof
、类型转换等操作能够正确进行。清晰的类加载器层次结构:
虽然双亲委派模型是 Java 推荐的类加载方式,并且能解决很多问题,但它并非万能钥匙。在某些特定场景下,双亲委派模型反而会成为障碍,需要被“打破”。
双亲委派模型有一个基本的设计缺陷:父加载器无法访问子加载器加载的类。
这个模型的核心思想是向上委派,请求总是从下往上传递。但是,如果 基础类(由父加载器加载)需要回调用户代码(由子加载器加载),该怎么办呢?
典型的例子就是 SPI (Service Provider Interface) 机制:
rt.jar
,由启动类加载器加载)中定义了标准的接口(例如 java.sql.Driver
)。java.sql.DriverManager
)需要去加载并使用这些第三方厂商提供的实现类。按照双亲委派模型,DriverManager
(由启动类加载器加载)无法“看到”并加载 com.mysql.jdbc.Driver
(由应用程序类加载器加载),因为它不能向下委派。
为了解决这类问题,Java 设计者引入了一些机制来“打破”或绕过双亲委派模型。
其他需要打破双亲委派模型的场景:
WebAppClassLoader
。WebAppClassLoader
就重写了 loadClass
方法,优先加载 Web 应用目录下的类,加载不到再向上委派。lib
目录下的),这又需要一定的委派机制。.class
文件加密,然后通过自定义类加载器在加载时进行解密。这种自定义加载器需要在 findClass
中实现解密逻辑,并调用 defineClass
。主要有两种方式可以打破双亲委派模型:
loadClass()
方法最直接但也最“暴力”的方式就是继承 java.lang.ClassLoader
,然后 重写 loadClass(String name, boolean resolve)
方法。
通过重写 loadClass
,我们可以完全控制类的加载流程,不再遵循默认的向上委派逻辑。例如,可以实现先尝试自己加载,失败后再委派给父加载器,或者完全不委派。
示例(Tomcat 的 WebAppClassLoader
简化逻辑):
// 伪代码,仅示意 Tomcat 的部分逻辑
public class WebAppClassLoader extends ClassLoader {
// ... 省略其他代码 ...
@Override
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> clazz = null;
// 1. 先检查本地缓存
clazz = findLoadedClass(name);
if (clazz != null) {
if (resolve) resolveClass(clazz);
return clazz;
}
// 2. 检查 JVM 系统类(不能被 WebApp 覆盖的)
// 例如 java.*, javax.* 等包中的类,必须委派给父加载器
if (isSystemClass(name)) {
try {
clazz = getParent().loadClass(name, resolve);
if (clazz != null) return clazz;
} catch (ClassNotFoundException e) { /*忽略*/ }
}
// 3. 尝试在 Web 应用自身的目录下加载 (优先加载自己的类)
try {
clazz = findClass(name); // 调用自己的 findClass
if (clazz != null) {
if (resolve) resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) { /*忽略*/ }
// 4. 如果自己加载不到,再尝试委派给父加载器加载 (兜底)
// 这与标准双亲委派顺序相反
try {
clazz = getParent().loadClass(name, resolve);
if (clazz != null) return clazz;
} catch (ClassNotFoundException e) { /*忽略*/ }
// 5. 如果都找不到,抛出异常
throw new ClassNotFoundException(name);
}
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 在 Web 应用的 /WEB-INF/classes 和 /WEB-INF/lib/*.jar 中查找类的字节码
// ... 实现查找逻辑 ...
byte[] classBytes = findClassBytes(name);
if (classBytes == null) {
throw new ClassNotFoundException(name);
}
return defineClass(name, classBytes, 0, classBytes.length);
}
// ... 其他辅助方法,如 isSystemClass(), findClassBytes() ...
}
注意:重写 loadClass
方法需要非常小心,因为它改变了类加载的核心行为。除非明确需要改变委派顺序(如 Tomcat),否则 强烈建议只重写 findClass
方法,以保持双亲委派模型的基本结构。
这是一种更优雅、更常用的打破双亲委派模型的方式。
Java 在 java.lang.Thread
类中提供了一个 contextClassLoader
字段,可以通过 Thread.currentThread().getContextClassLoader()
获取,并通过 Thread.currentThread().setContextClassLoader(ClassLoader cl)
来设置。
工作原理:
理解帮助:
线程上下文类加载器就像一个“信使”。高层代码(父加载器加载的类)想让低层代码(子加载器加载的类)帮忙做事(加载类),但不能直接指挥。于是,高层代码通过当前线程这个“信使”,告诉它:“你去用那个能看到目标类的加载器(通常是应用程序类加载器)来加载它”。
默认情况下,线程的上下文类加载器是从父线程继承来的。对于程序启动时的初始线程,其上下文类加载器通常是 应用程序类加载器 (Application ClassLoader)。
SPI 机制就是利用线程上下文类加载器来打破双亲委派的典型例子。
我们再来看 SPI (Service Provider Interface) 如何利用线程上下文类加载器工作:
以 JDBC 为例:
java.sql.DriverManager
(核心库类,由启动类加载器加载)com.mysql.cj.jdbc.Driver
(MySQL 驱动实现类,通常在 ClassPath 下,由应用程序类加载器加载)DriverManager
类中有一个静态代码块,它在类初始化时会尝试加载所有注册的 JDBC 驱动。其核心方法是 ServiceLoader.load(Driver.class)
。
ServiceLoader.load()
方法内部的简化逻辑:
public static <S> ServiceLoader<S> load(Class<S> service) {
// 关键点:获取当前线程的上下文类加载器
ClassLoader cl = Thread.currentThread().getContextClassLoader();
// 使用上下文类加载器去加载服务提供者
return ServiceLoader.load(service, cl);
}
public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader) {
// ...
// 在 loader 的类路径下查找 META-INF/services/java.sql.Driver 文件
// 读取文件内容(例如 com.mysql.cj.jdbc.Driver)
// 使用 loader (即上下文类加载器) 去加载 com.mysql.cj.jdbc.Driver 类
// ...
}
流程梳理:
DriverManager
初始化时调用 ServiceLoader.load(Driver.class)
。ServiceLoader.load()
获取当前线程的上下文类加载器(通常是 AppClassLoader)。ServiceLoader
使用 AppClassLoader 去查找 META-INF/services/java.sql.Driver
配置文件,并读取实现类的全限定名(如 com.mysql.cj.jdbc.Driver
)。ServiceLoader
最终调用 Class.forName("com.mysql.cj.jdbc.Driver", false, cl)
,这里的 cl
就是 AppClassLoader。这样,启动类加载器加载的 DriverManager
就成功地加载并使用了应用程序类加载器加载的 Driver
实现类,巧妙地绕过了双亲委派模型的限制。
JNDI、JAXP (XML 解析)、JCE (加解密) 等许多 Java 基础服务都广泛使用了 SPI 和线程上下文类加载器。
除了 JVM 自带的和 Java 核心库提供的类加载器,我们还可以根据需要创建自己的类加载器。
自定义类加载器的主要应用场景包括:
实现类的隔离:
WebAppClassLoader
)。这样,即使两个应用依赖了同一个库的不同版本,也不会相互干扰。应用 A 使用 log4j-1.2.jar
,应用 B 使用 log4j-2.0.jar
,它们可以共存在同一个 Tomcat 实例中,因为它们由不同的类加载器加载,处于不同的命名空间。热部署与热替换:
加载非标准来源的类:
代码加密与保护:
.class
文件进行加密处理,防止反编译。findClass
方法中进行解密,最后调用 defineClass
将解密后的字节码转换成 Class
对象。实现一个自定义类加载器通常遵循以下步骤:
java.lang.ClassLoader
类。super(ClassLoader parent)
构造函数来指定父加载器。ClassLoader.getSystemClassLoader()
)。findClass(String name)
方法:
name
,找到或生成对应的字节码 byte[] classBytes
。defineClass(String name, byte[] b, int off, int len)
方法将字节码数组转换为 Class
对象。ClassNotFoundException
。loadClass(String name, boolean resolve)
方法:
loadClass
,只需重写 findClass
即可保持双亲委派。示例:一个从指定目录加载类的简单自定义加载器
import java.io.*;
public class FileSystemClassLoader extends ClassLoader {
private String rootDir;
// 构造函数,传入类文件所在的根目录
public FileSystemClassLoader(String rootDir) {
// 如果不指定父加载器,默认使用 AppClassLoader
this.rootDir = rootDir;
}
// 构造函数,允许指定父加载器
public FileSystemClassLoader(String rootDir, ClassLoader parent) {
super(parent); // 调用父类构造函数设置父加载器
this.rootDir = rootDir;
}
/**
* 重写 findClass 方法,实现从文件系统加载类的逻辑
* @param name 类的全限定名 (例如 com.example.MyClass)
* @return 加载后的 Class 对象
* @throws ClassNotFoundException 如果找不到类文件
*/
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 1. 将全限定名转换为文件路径 (com.example.MyClass -> com/example/MyClass.class)
String filePath = rootDir + File.separator + name.replace('.', File.separatorChar) + ".class";
File classFile = new File(filePath);
if (!classFile.exists()) {
// 如果文件不存在,抛出异常,loadClass 方法会继续尝试父加载器(如果之前没找到)
throw new ClassNotFoundException("Class file not found: " + filePath);
}
// 2. 读取类文件的字节码
try (InputStream is = new FileInputStream(classFile);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[4096]; // 缓冲区大小
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
baos.write(buffer, 0, bytesRead);
}
byte[] classBytes = baos.toByteArray(); // 获取完整的字节码数组
// 3. 调用 defineClass 将字节码转换为 Class 对象
// defineClass 是 ClassLoader 提供的受保护方法,用于将字节数组定义成 Class 实例
// 第一个参数是类名,第二个是字节数组,第三个是起始偏移,第四个是长度
Class<?> clazz = defineClass(name, classBytes, 0, classBytes.length);
if (clazz == null) {
// defineClass 可能会因为格式错误等原因失败并返回 null
throw new ClassNotFoundException("Failed to define class from bytes: " + name);
}
System.out.println("Custom loader [" + this + "] found and defined class: " + name);
return clazz; // 返回加载成功的 Class 对象
} catch (IOException e) {
// 处理 IO 异常
throw new ClassNotFoundException("Failed to load class bytes: " + name, e);
} catch (ClassFormatError e) {
// 处理类格式错误
throw new ClassNotFoundException("Class format error for: " + name, e);
}
}
// main 方法用于测试
public static void main(String[] args) throws Exception {
// 假设 D:/myclasses/ 目录下有 com/example/Hello.class
// Hello.class 内容:
// package com.example;
// public class Hello {
// public void greet() { System.out.println("Hello from " + this.getClass().getClassLoader()); }
// }
FileSystemClassLoader loader = new FileSystemClassLoader("D:/myclasses");
System.out.println("Custom loader parent: " + loader.getParent()); // 输出 AppClassLoader
// 使用自定义加载器加载类
Class<?> helloClass = loader.loadClass("com.example.Hello");
// 创建实例并调用方法
Object helloInstance = helloClass.getDeclaredConstructor().newInstance();
helloClass.getMethod("greet").invoke(helloInstance); // 输出 Hello from FileSystemClassLoader@...
System.out.println("\nTrying to load java.lang.String with custom loader:");
// 尝试加载核心类库的类,会委派给父加载器
Class<?> stringClass = loader.loadClass("java.lang.String");
System.out.println("String class loader: " + stringClass.getClassLoader()); // 输出 null (Bootstrap ClassLoader)
}
}
ClassLoader.findClass()
findClass(String name)
方法在 java.lang.ClassLoader
中的默认实现非常简单:
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 直接抛出异常,强制子类必须重写此方法来实现自己的查找逻辑
throw new ClassNotFoundException(name);
}
这个方法的设计意图就是让子类去填充具体的类查找和定义逻辑。当我们自定义类加载器时,主要的工作就是在这个方法里完成:
name
,从特定的来源(文件系统、网络、数据库、内存等)查找对应的类的二进制字节流。byte[]
数组。defineClass
:使用 defineClass(String name, byte[] b, int off, int len)
将字节码数组转换为 java.lang.Class
对象。defineClass
是一个 native
方法(或最终调用 native
方法),它负责在 JVM 内部完成字节码的验证、解析(部分)以及在方法区创建对应的运行时数据结构和 Class
对象。在实现自定义类加载器时,需要注意以下几点:
findClass
而不是 loadClass
,以保持 Java 类加载体系的稳定性和一致性。ClassCastException
)。Class
对象被长期引用(例如,存储在静态集合中),即使代码不再使用,类也无法卸载,可能导致方法区(元空间)内存泄漏。特别是在热部署场景下,需要小心管理类加载器的生命周期。loadClass
方法内部默认是线程安全的(通过 synchronized (getClassLoadingLock(name))
实现)。但如果你重写了 loadClass
,或者在 findClass
中有复杂的状态操作,需要自行确保线程安全。defineClass
方法本身也是线程安全的。findLoadedClass
就是一种缓存),避免重复查找和定义同一个类。getResource(String name)
和 getResources(String name)
方法。自定义类加载器时,通常也需要重写 findResource(String name)
和 findResources(String name)
方法,以确保能从自定义的来源加载资源文件,并且委派逻辑与类加载保持一致。defineClass
方法会进行一些基本的安全检查,但如果加载的字节码来源不可信,自定义加载器本身可能成为安全漏洞的入口。在需要高安全性的场景下,可能需要配合 Java 的安全管理器 (SecurityManager
) 和权限 (Permission
) 机制,重写 getPermissions(CodeSource codesource)
方法来为加载的代码授予合适的权限。类加载过程与 JVM 的内存区域,特别是 方法区 (Method Area) 和 运行时常量池 (Runtime Constant Pool) 密切相关。
关系梳理:
方法区 (Method Area):
public
, abstract
, final
等)、直接接口的有序列表、字段信息(名称、类型、修饰符)、方法信息(名称、返回类型、参数数量和类型、修饰符、方法字节码)等。OutOfMemoryError: PermGen space
。.class
文件中的静态结构信息解析后,存放到方法区中,形成运行时的内部表示。运行时常量池 (Runtime Constant Pool):
.class
文件中 constant_pool
表的 运行时表示。.class
文件常量池 (Class File Constant Pool) 存放编译器生成的各种 字面量 (Literals) 和 符号引用 (Symbolic References)。
final
的常量值等。.class
文件常量池的内容会被存放到方法区的运行时常量池中。String
类的 intern()
方法。当调用 intern()
方法时,如果字符串常量池中已经包含一个等于此 String
对象的字符串,则返回池中的那个字符串的引用;否则,会将此 String
对象添加到池中,并返回此 String
对象的引用。总结:类加载过程是将静态的 .class
文件信息转化为 JVM 运行时内存结构的过程。加载阶段将类的元数据和常量池信息读入方法区,形成运行时常量池;连接的解析阶段则将常量池中的符号引用解析为直接引用,使得代码可以真正执行。理解它们的关系有助于我们认识到类数据在 JVM 内部是如何存储和管理的。
在 Java 开发中,与类加载相关的问题非常常见,理解类加载机制是解决这些问题的关键。
ClassNotFoundException
vs NoClassDefFoundError
这两个都是常见的类加载相关错误,但原因和发生阶段不同:
ClassNotFoundException
(异常 - Exception):
Class.forName()
)、类加载器(如 ClassLoader.loadClass()
、ClassLoader.findSystemClass()
)等方式 动态加载 一个类时,在当前的 ClassPath 或指定的加载路径中 找不到 对应的 .class
文件。NoClassDefFoundError
(错误 - Error):
.class
文件,但在 链接 或 初始化 过程中失败了。static {}
) 或静态变量赋值时抛出了未捕获的异常。一旦初始化失败,后续任何尝试使用该类的操作都会直接抛出 NoClassDefFoundError
。简单区分:ClassNotFoundException
是“找不到 .class 文件”,发生在加载阶段;NoClassDefFoundError
是“找到了 .class 文件,但加载(链接或初始化)失败了”,通常发生在第一次使用时。
LinkageError
LinkageError
是一个更通用的错误,发生在链接阶段(验证、准备、解析),表示类与类之间的依赖关系出现了问题。NoClassDefFoundError
是 LinkageError
的一个常见子类。
其他 LinkageError
的子类包括:
ClassCircularityError
:类加载过程中检测到循环依赖(例如,类 A 继承 B,类 B 又继承 A)。IncompatibleClassChangeError
:检测到不兼容的类更改。例如:
public
变 private
)。VerifyError
:验证阶段失败,字节码不符合 JVM 规范或存在安全风险。NoSuchFieldError
/ NoSuchMethodError
:解析阶段,找不到引用的字段或方法。通常也是因为编译后类定义发生变化导致。解决 LinkageError
的思路:
mvn dependency:tree
) 或 Gradle (gradle dependencies
) 等工具分析依赖树,排除冲突的版本。虽然 JDK 8 后使用元空间替换了永久代,减少了固定大小限制带来的 OOM,但如果类及其加载器无法被回收,仍然可能耗尽本地内存。
-XX:MetaspaceSize
(初始大小)和 -XX:MaxMetaspaceSize
(最大大小)进行调整。设置一个合理的最大值有助于防止耗尽系统内存。对于永久代 (JDK 7-),通过 -XX:PermSize
和 -XX:MaxPermSize
调整。这是大型项目中非常常见的问题,本质上是类加载路径 (ClassPath) 中出现了同一个类的不同版本,或者相关联的库版本不兼容。
NoSuchMethodError
、NoSuchFieldError
、AbstractMethodError
、IllegalAccessError
等 LinkageError
或其子类的形式出现。有时也可能表现为奇怪的行为或难以预料的异常。mvn dependency:tree -Dverbose
) 或 Gradle (gradle dependencies
) 详细查看项目的依赖树,找出冲突的库和版本。pom.xml
或 build.gradle
)中,明确排除掉不需要的、引起冲突的传递性依赖。
或 Gradle 的 constraints
中统一管理项目及其子模块使用的核心依赖版本,强制使用特定版本。-verbose:class
这是一个非常有用的 JVM 参数,可以在排查类加载问题时提供大量信息。
当使用 java -verbose:class ...
启动应用时,JVM 会在控制台输出详细的类加载信息,包括:
示例输出:
[0.104s][info][class,load] java.lang.Object source: jrt:/java.base
[0.104s][info][class,load] java.io.Serializable source: jrt:/java.base
[0.104s][info][class,load] java.lang.Comparable source: jrt:/java.base
...
[0.201s][info][class,load] sun.launcher.LauncherHelper source: jrt:/java.base
[0.201s][info][class,load] java.lang.ClassLoaderHelper source: jrt:/java.base
[0.205s][info][class,load] com.example.MyApplication source: file:/D:/myapp/target/classes/
...
[0.210s][info][class,load] org.apache.commons.logging.LogFactory source: file:/C:/Users/user/.m2/repository/commons-logging/commons-logging/1.2/commons-logging-1.2.jar
[0.211s][info][class,load] org.apache.commons.logging.Log source: file:/C:/Users/user/.m2/repository/commons-logging/commons-logging/1.2/commons-logging-1.2.jar
通过分析 -verbose:class
的输出,你可以:
Java 类加载机制是 JVM 的核心组成部分,它负责将静态的 .class
文件转化为运行时可以使用的 Class
对象。
总结内容如下:
Class
对象。()
方法(静态变量赋值和静态块),具有严格的触发时机和线程安全保障。loadClass
源码实现、优势(安全、避免重复加载)。loadClass
、线程上下文类加载器)。ClassLoader
,重写 findClass
)。ClassNotFoundException
vs NoClassDefFoundError
,LinkageError
,内存溢出,依赖冲突,以及调试工具 -verbose:class
。Happy coding!