相关问题
- Java 类从
.class文件到可以被 JVM 使用,中间经历哪些阶段? - 类加载的五个阶段分别做什么?
- 加载
- 验证
- 准备
- 解析
- 初始化
- “准备”和“初始化”有什么区别?
- static 变量在准备阶段和初始化阶段分别会发生什么?
- 类什么时候会触发初始化?
- 哪些情况不会触发类初始化?
- JVM 中常见的类加载器有哪些?
- 什么是双亲委派模型?
- 双亲委派的完整加载流程是什么?
- 为什么要使用双亲委派?
- 双亲委派怎么避免核心类被重复加载或篡改?
- 什么叫“同一个类”?只看类名一样就算同一个类吗?
- 不同 ClassLoader 加载同一个
.class,得到的 Class 是同一个吗? - 哪些场景会打破双亲委派?
- SPI 为什么会涉及打破双亲委派?
- Tomcat 为什么要自定义 ClassLoader?
- 自定义 ClassLoader 一般怎么实现?
Class.forName()和ClassLoader.loadClass()有什么区别?- 类加载器和类卸载有什么关系?
- 什么情况下类可以被卸载?
类加载流程
一个 .class 文件真正被 JVM 使用之前,通常会经历:
|
|
加载阶段就是把 .class 文件读进 JVM。
JVM 会根据 class 文件里的内容建立这个类对应的类元数据,并创建对应的 Class 对象。到这一步,JVM 已经知道这个类叫什么、有哪些字段、有哪些方法。
验证阶段主要检查这个 class 文件是否合法,比如字节码格式是否正确、类型使用是否安全,避免非法 class 文件破坏 JVM。
准备阶段主要处理 static 变量。
比如:
|
|
这时候并不会直接变成 10,而是先给这个静态变量准备空间,并赋默认值:
|
|
解析阶段主要是把类、方法、字段这些符号引用,转换成 JVM 运行时真正可以使用的引用。
可以理解成:
|
|
最后是初始化阶段。
这时候才会真正执行类里的静态初始化逻辑,比如:
|
|
于是:
|
|
并执行静态代码块。
需要注意,类初始化阶段主要处理的是静态变量和静态代码块。
实例变量是创建对象时才初始化,而局部变量则是在方法真正执行时才创建和赋值。
|
|
类什么时候会初始化
类并不是一被 JVM 加载进来就一定马上初始化。
只有在真正“主动使用”这个类时,JVM 才会触发初始化,执行 static 变量赋值和静态代码块。
比较常见的主动使用包括:
|
|
创建对象时,会先保证 User 类已经完成初始化。
直接访问类的静态变量或者调用静态方法时,也会触发初始化:
|
|
通过反射主动加载类时,也可能触发初始化,例如:
|
|
另外,初始化一个子类之前,JVM 会先初始化它的父类。
例如:
|
|
第一次真正使用 Child 时,会先执行:
|
|
不过,并不是所有“引用一个类”的行为都会触发初始化。
例如访问父类定义的静态字段:
|
|
如果这个字段实际定义在 Parent 中,那么通常只会初始化 Parent,不会因为通过 Child 访问就初始化 Child。
再比如:
|
|
这里只是创建了一个 Child 类型的数组,并没有真正创建 Child 对象,因此不会触发 Child 的初始化。
还有编译期就能确定的常量:
|
|
其他类直接使用 MAX 时,值可能已经在编译阶段被放进调用方的常量池中,因此也不一定需要初始化定义它的类。
即类只有在真正需要执行它的静态逻辑时才会初始化,单纯“知道这个类存在”或者只是引用它的类型信息,并不一定会触发初始化。(类似懒加载)
类加载器
类加载器负责把 .class 文件加载进 JVM,并生成对应的 Class 对象。
Java 中常见的类加载器主要有三类:
Bootstrap ClassLoader 主要负责加载 Java 最核心的类库,例如 java.lang、java.util 这些基础类。它属于 JVM 自身的一部分。
Platform ClassLoader 负责加载 Java 平台相关的类库。
Application ClassLoader 主要负责加载我们自己项目中的类,也就是 classpath 下的代码。
|
|
除此之外,开发者也可以自己实现 ClassLoader,用于加载一些特殊来源的类,比如插件、网络中的 class 文件,或者应用服务器中的不同模块。
不同类加载器加载出来的类,并不一定被 JVM 认为是同一个类。
JVM 判断两个类是否相同,不只是看完整类名,还要看:
类名 + 加载它的 ClassLoader。
所以即使是同一个 .class 文件,如果被两个不同的 ClassLoader 分别加载,也可能得到两个互不兼容的 Class 对象。
类加载器之间通常还会按照一定的父子关系协作,这就引出了后面的双亲委派机制。
双亲委派
双亲委派是 Java 类加载器之间的一种协作机制。
常见的类加载器关系可以理解成:
|
|
这里的“父子”不是 Java 继承关系,而是类加载器之间的委派关系。
当一个类加载器收到类加载请求时,通常不会马上自己去加载,而是先把请求交给父加载器。父加载器继续向上委派,直到最顶层。
然后再从上往下尝试:
|
|
所以双亲委派的核心就是:
优先让更上层的类加载器加载,父加载器解决不了,子加载器才自己处理。
这样设计首先可以避免核心类被重复加载。
因为 JVM 判断两个类是否相同,不只是看类名,还要看加载它们的 ClassLoader。如果不同加载器随意各自加载同一个类,JVM 可能把它们当成两个不同的类型。
双亲委派让核心类尽量由上层加载器统一加载,减少这种重复加载和类型不一致的问题。
另一个重要作用是保护 Java 核心类库。
例如应用自己定义一个:
|
|
Application ClassLoader 收到请求以后,不会直接加载,而是先向上委派。最终 Bootstrap ClassLoader 会优先加载 JDK 自带的 java.lang.String,因此应用代码不能轻易替换核心类。
所以双亲委派主要解决的是:
保证核心类加载的统一性,同时防止应用代码随意覆盖 Java 核心类。
打破双亲委派
双亲委派默认采用的是父加载器优先的方式。
一个类加载器收到加载请求以后,会先把请求交给父加载器,只有父加载器加载不了,当前加载器才自己处理。
这种方式适合大多数场景,但有些场景需要更灵活的类加载策略,比如插件化、应用隔离,或者上层接口需要加载下层实现类。
在自定义 ClassLoader 中,如果只是想改变“去哪里找 class 文件”,通常只需要重写:
|
|
例如从网络、指定目录或者其他位置读取 class 文件。
这并不会改变双亲委派流程,因为 loadClass() 仍然会先让父加载器尝试。
如果想真正改变“父优先”的加载顺序,比如改成:
|
|
那就需要重写:
|
|
因为默认的双亲委派逻辑本身就是写在 loadClass() 里的。
所以可以理解成:
|
|
Tomcat 这类应用服务器就是典型场景。
不同 Web 应用可能依赖同一个 jar 的不同版本,如果所有类都优先交给同一个父加载器加载,就容易发生版本冲突。
因此 Tomcat 会为不同应用创建独立的 ClassLoader,并对加载顺序进行控制,让某些应用类优先由自己的 ClassLoader 加载,从而实现类隔离。
SPI 则属于另一种情况。
JDK 中定义的是接口,但具体实现通常来自应用引入的第三方 jar。上层加载器能够看到接口,却看不到下层 ClassLoader 加载的实现类,所以这类场景通常会借助线程上下文类加载器,让上层代码能够使用应用层 ClassLoader 去加载具体实现。
因此,打破双亲委派并不是完全放弃父子关系,而是根据实际场景调整默认的父优先策略。
Class.forName 和 loadClass 的区别
Class.forName() 和 ClassLoader.loadClass() 都可以用来加载类,但最大的区别在于:是否会触发类初始化。
Class.forName() 默认不仅会加载类,还会触发类初始化。
例如:
|
|
如果 User 中有静态代码块:
|
|
通常会直接执行。
而:
|
|
一般只是把类加载进 JVM,不会主动执行类初始化逻辑。
所以可以简单理解成:
|
|
这也是两者最常见的使用区别。
比如 JDBC 早期经常写:
|
|
就是希望在加载驱动类的同时执行它的静态初始化逻辑,完成驱动注册。
而 loadClass() 更适合只想把类加载进 JVM,暂时还不希望执行静态初始化代码的场景。