相关问题

  1. Java 类从 .class 文件到可以被 JVM 使用,中间经历哪些阶段?
  2. 类加载的五个阶段分别做什么?
    • 加载
    • 验证
    • 准备
    • 解析
    • 初始化
  3. “准备”和“初始化”有什么区别?
  4. static 变量在准备阶段和初始化阶段分别会发生什么?
  5. 类什么时候会触发初始化?
  6. 哪些情况不会触发类初始化?
  7. JVM 中常见的类加载器有哪些?
  8. 什么是双亲委派模型?
  9. 双亲委派的完整加载流程是什么?
  10. 为什么要使用双亲委派?
  11. 双亲委派怎么避免核心类被重复加载或篡改?
  12. 什么叫“同一个类”?只看类名一样就算同一个类吗?
  13. 不同 ClassLoader 加载同一个 .class,得到的 Class 是同一个吗?
  14. 哪些场景会打破双亲委派?
  15. SPI 为什么会涉及打破双亲委派?
  16. Tomcat 为什么要自定义 ClassLoader?
  17. 自定义 ClassLoader 一般怎么实现?
  18. Class.forName() 和 ClassLoader.loadClass() 有什么区别?
  19. 类加载器和类卸载有什么关系?
  20. 什么情况下类可以被卸载?

类加载流程

一个 .class 文件真正被 JVM 使用之前,通常会经历:

1
2
3
4
5
加载
→ 验证
→ 准备
→ 解析
→ 初始化

加载阶段就是把 .class 文件读进 JVM。

JVM 会根据 class 文件里的内容建立这个类对应的类元数据,并创建对应的 Class 对象。到这一步,JVM 已经知道这个类叫什么、有哪些字段、有哪些方法。

验证阶段主要检查这个 class 文件是否合法,比如字节码格式是否正确、类型使用是否安全,避免非法 class 文件破坏 JVM。

准备阶段主要处理 static 变量。

比如:

1
static int count = 10;

这时候并不会直接变成 10,而是先给这个静态变量准备空间,并赋默认值:

1
count = 0

解析阶段主要是把类、方法、字段这些符号引用,转换成 JVM 运行时真正可以使用的引用。

可以理解成:

1
2
3
4
5
原来只是知道:
User.getName

后来变成:
已经知道真正应该去哪里找到这个方法

最后是初始化阶段。

这时候才会真正执行类里的静态初始化逻辑,比如:

1
2
3
4
5
static int count = 10;

static {
    System.out.println("init");
}

于是:

1
2
count = 0
→ count = 10

并执行静态代码块。

需要注意,类初始化阶段主要处理的是静态变量和静态代码块。

实例变量是创建对象时才初始化,而局部变量则是在方法真正执行时才创建和赋值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
加载
→ 把类读进 JVM

验证
→ 检查 class 是否合法

准备
→ 给 static 变量先放默认值

解析
→ 把符号引用和真正目标关联起来

初始化
→ 执行 static 赋值和静态代码块

类什么时候会初始化

类并不是一被 JVM 加载进来就一定马上初始化。

只有在真正“主动使用”这个类时,JVM 才会触发初始化,执行 static 变量赋值和静态代码块。

比较常见的主动使用包括:

1
new User();

创建对象时,会先保证 User 类已经完成初始化。

直接访问类的静态变量或者调用静态方法时,也会触发初始化:

1
2
User.count;
User.getCount();

通过反射主动加载类时,也可能触发初始化,例如:

1
Class.forName("com.example.User");

另外,初始化一个子类之前,JVM 会先初始化它的父类。

例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Parent {
    static {
        System.out.println("Parent");
    }
}

class Child extends Parent {
    static {
        System.out.println("Child");
    }
}

第一次真正使用 Child 时,会先执行:

1
2
Parent 初始化
→ Child 初始化

不过,并不是所有“引用一个类”的行为都会触发初始化。

例如访问父类定义的静态字段:

1
Child.parentValue;

如果这个字段实际定义在 Parent 中,那么通常只会初始化 Parent,不会因为通过 Child 访问就初始化 Child。

再比如:

1
Child[] array = new Child[10];

这里只是创建了一个 Child 类型的数组,并没有真正创建 Child 对象,因此不会触发 Child 的初始化。

还有编译期就能确定的常量:

1
static final int MAX = 100;

其他类直接使用 MAX 时,值可能已经在编译阶段被放进调用方的常量池中,因此也不一定需要初始化定义它的类。

即类只有在真正需要执行它的静态逻辑时才会初始化,单纯“知道这个类存在”或者只是引用它的类型信息,并不一定会触发初始化。(类似懒加载)

类加载器

类加载器负责把 .class 文件加载进 JVM,并生成对应的 Class 对象。

Java 中常见的类加载器主要有三类:

Bootstrap ClassLoader 主要负责加载 Java 最核心的类库,例如 java.lang、java.util 这些基础类。它属于 JVM 自身的一部分。

Platform ClassLoader 负责加载 Java 平台相关的类库。

Application ClassLoader 主要负责加载我们自己项目中的类,也就是 classpath 下的代码。

1
2
3
4
5
6
7
8
Bootstrap ClassLoader
→ Java 核心类库

Platform ClassLoader
→ Java 平台扩展类库

Application ClassLoader
→ 项目自己的类

除此之外,开发者也可以自己实现 ClassLoader,用于加载一些特殊来源的类,比如插件、网络中的 class 文件,或者应用服务器中的不同模块。

不同类加载器加载出来的类,并不一定被 JVM 认为是同一个类。

JVM 判断两个类是否相同,不只是看完整类名,还要看:

类名 + 加载它的 ClassLoader。

所以即使是同一个 .class 文件,如果被两个不同的 ClassLoader 分别加载,也可能得到两个互不兼容的 Class 对象。

类加载器之间通常还会按照一定的父子关系协作,这就引出了后面的双亲委派机制。

双亲委派

双亲委派是 Java 类加载器之间的一种协作机制。

常见的类加载器关系可以理解成:

1
2
3
4
5
6
7
Bootstrap ClassLoader(爷爷👴)
        ↑
Platform ClassLoader(爸爸)
        ↑
Application ClassLoader(孙子👶)
        ↑
自定义 ClassLoader

这里的“父子”不是 Java 继承关系,而是类加载器之间的委派关系。

当一个类加载器收到类加载请求时,通常不会马上自己去加载,而是先把请求交给父加载器。父加载器继续向上委派,直到最顶层。

然后再从上往下尝试:

1
2
3
4
5
6
7
8
Bootstrap 能加载
→ 直接使用

Bootstrap 不能加载
→ Platform 尝试

Platform 也不能
→ Application 再尝试

所以双亲委派的核心就是:

优先让更上层的类加载器加载,父加载器解决不了,子加载器才自己处理。

这样设计首先可以避免核心类被重复加载。

因为 JVM 判断两个类是否相同,不只是看类名,还要看加载它们的 ClassLoader。如果不同加载器随意各自加载同一个类,JVM 可能把它们当成两个不同的类型。

双亲委派让核心类尽量由上层加载器统一加载,减少这种重复加载和类型不一致的问题。

另一个重要作用是保护 Java 核心类库。

例如应用自己定义一个:

1
java.lang.String

Application ClassLoader 收到请求以后,不会直接加载,而是先向上委派。最终 Bootstrap ClassLoader 会优先加载 JDK 自带的 java.lang.String,因此应用代码不能轻易替换核心类。

所以双亲委派主要解决的是:

保证核心类加载的统一性,同时防止应用代码随意覆盖 Java 核心类。

打破双亲委派

双亲委派默认采用的是父加载器优先的方式。

一个类加载器收到加载请求以后,会先把请求交给父加载器,只有父加载器加载不了,当前加载器才自己处理。

这种方式适合大多数场景,但有些场景需要更灵活的类加载策略,比如插件化、应用隔离,或者上层接口需要加载下层实现类。

在自定义 ClassLoader 中,如果只是想改变“去哪里找 class 文件”,通常只需要重写:

1
findClass()

例如从网络、指定目录或者其他位置读取 class 文件。

这并不会改变双亲委派流程,因为 loadClass() 仍然会先让父加载器尝试。

如果想真正改变“父优先”的加载顺序,比如改成:

1
2
3
先自己尝试加载
→ 自己加载不了
→ 再交给父加载器

那就需要重写:

1
loadClass()

因为默认的双亲委派逻辑本身就是写在 loadClass() 里的。

所以可以理解成:

1
2
3
4
5
6
7
重写 findClass()
→ 改变类从哪里加载
→ 不破坏双亲委派

重写 loadClass()
→ 改变类加载顺序
→ 可以打破双亲委派

Tomcat 这类应用服务器就是典型场景。

不同 Web 应用可能依赖同一个 jar 的不同版本,如果所有类都优先交给同一个父加载器加载,就容易发生版本冲突。

因此 Tomcat 会为不同应用创建独立的 ClassLoader,并对加载顺序进行控制,让某些应用类优先由自己的 ClassLoader 加载,从而实现类隔离。

SPI 则属于另一种情况。

JDK 中定义的是接口,但具体实现通常来自应用引入的第三方 jar。上层加载器能够看到接口,却看不到下层 ClassLoader 加载的实现类,所以这类场景通常会借助线程上下文类加载器,让上层代码能够使用应用层 ClassLoader 去加载具体实现。

因此,打破双亲委派并不是完全放弃父子关系,而是根据实际场景调整默认的父优先策略。

Class.forName 和 loadClass 的区别

Class.forName() 和 ClassLoader.loadClass() 都可以用来加载类,但最大的区别在于:是否会触发类初始化。

Class.forName() 默认不仅会加载类,还会触发类初始化。

例如:

1
Class.forName("com.example.User");

如果 User 中有静态代码块:

1
2
3
static {
    System.out.println("init");
}

通常会直接执行。

而:

1
classLoader.loadClass("com.example.User");

一般只是把类加载进 JVM,不会主动执行类初始化逻辑。

所以可以简单理解成:

1
2
3
4
5
6
7
Class.forName()
→ 加载类
→ 默认会初始化

loadClass()
→ 加载类
→ 默认不初始化

这也是两者最常见的使用区别。

比如 JDBC 早期经常写:

1
Class.forName("com.mysql.jdbc.Driver");

就是希望在加载驱动类的同时执行它的静态初始化逻辑,完成驱动注册。

而 loadClass() 更适合只想把类加载进 JVM,暂时还不希望执行静态初始化代码的场景。