Java类加载机制
引言
从事Java开发的小伙伴,在工作中一定遇到过如ClassNotFoundException
、NoClassDefFoundError
、NoSuchMethodError
等等之类的异常或错误,大家肯定会想到类缺失了、Jar包有冲突了等原因。知其然更要知其所以然,本文将从Java类加载机制的原理层面来分析出现这些问题的原因,同时以一些基础框架为例来说明它们是如何规避的。以此来加深我们对Java类加载机制的了解,达到快速解决问题和开发基础工具时如何善用Java类加载器。
类加载过程
一个Class的生命周期包括加载、连接(验证、准备、解析)、初始化、使用和卸载。其中,系统寻找Class文件的过程涉及加载、连接和初始化。
加载:对应loadClass
主要是根据类路径读取二进制流,将静态信息加载到方法区,堆内存中创建Class对象来访问方法区的信息。这个里面在java实现里,涉及的调用链:
1 | this.parent.loadClass-> |
连接:对应resolveClass
- 验证阶段是验证文件格式、元数据、字节码、符号引用,比如魔数校验、主次版本号、常量池常量类型、是否符合Java语言规范;
- 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配,比如静态基本数据类型的默认值;
- 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。也就是得到类或者字段、方法在内存中的指针或者偏移量。
初始化
执行初始化方法 <clinit>()
方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。<clinit>()
方法是编译之后自动生成的,它是带锁线程安全(单例模式)。比如new
一个类,读取一个静态字段(未被final
修饰)、或调用一个类的静态方法时。
卸载
卸载类即该类的 Class 对象被 GC。由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。
双亲委派模型
AppClassLoader
的父类加载器为ExtClassLoader
,ExtClassLoader
的父类加载器为null,null并不代表ExtClassLoader
没有父类加载器,而是BootstrapClassLoader
。
加载的流程源码如下:
1 | protected Class<?> loadClass(String name, boolean resolve) |
加载的过程如下:
- 首先,在当前类加载中调用
loadClass
方法,通过findLoadedClass
来判断类是否已经被加载过。 - 如果还没有被记载:
- 若父类加载器存在,则通过调用父加载器
loadClass()
方法处理; - 否则,通过
findBootstrapClassOrNull
使用启动类加载器BootstrapClassLoader
加载。
- 若父类加载器存在,则通过调用父加载器
- 如果无法成功记载对应class文件,就通过当前类加载器的
findClass
方法,否则就会抛出ClassNotFoundException
; - 如果已经加载过,则调用
resolveClass
执行连接操作。
自定义加载器的话,需要继承 ClassLoader
。如果我们不想打破双亲委派模型,就重写 ClassLoader
类中的 findClass()
方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass()
方法。在findClass
一般自定义找到class文件二进制流的方式,同时还要调用defineClass
。部分源码如下:
1 | /** |
类加载异常分析
针对ClassNotFoundException
,从双亲委派模型中,我们可以看到在调用loadClass
方法过程中,如果一直无法成功加载class信息,就会抛出ClassNotFoundException
。这个时候我们就要检查class的类路径是否正确以及对应的类加载器是否设置正确,一般是出现在反射或类加载器loadClass
方法上,classpath中找不到类。
针对NoClassDefFoundError
,一般出现在new一个对象实例时,class文件中出现该代码说明已经通过了编译,但是在实际运行时发现该类在初始化时发生了错误,比如自身静态代码块初始化异常、依赖的其他类不存在,就会抛出NoClassDefFoundError
。
针对NoSuchMethodError
,一般是对象实例调用了某个方法,在编译时classpath中的类存在该方法,但在实际运行中,加载进来该类没有该方法说明了存在编译时和运行时该类的版本不同或者是存在多个版本的JAR冲突问题。
为了避免上述的一些异常,我们在编写业务代码的时候,要注意设置好classpath或者做好不同版本的class隔离。为此,我们一般会通过自定义类加载器,比如扩展URLClassLoader
重写findClass
来加载指定目录下的class文件,与AppClassLoader
读取的路径隔离开来。
线程上下文类加载器
作为基础框架来说,比如Java中提供了一个SPI的机制,SPI接口是由BootstrapClassLoader
来加载,SPI实现类需要AppClassLoader
来加载,然而按照双亲委派机制这是无法实现的。为此,JAVA当中引入了ThreadContextClassLoader
,破坏了“双亲委派模型”,可以在执行线程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器。
这里拿JDBC中的SPI使用来举例说明,代码如下:
1 | public class DriverManager { |
由上述代码可以看出ServiceLoader
由BootstrapClassLoader
加载,内部找到所有Driver的实现类要加载需要通过当前线程的上下文加载器才可以识别到。同时,如果我们下游有不同版本的实现的话,可以自定义各自的类加载器,通过Thread.currentThread().setContextClassLoader(ClassLoader cl)
设置进去,这样就可以做到隔离。
扩展阅读
- 类加载过程,JavaGuide
- 双亲委派模型,JavaGuide
- 理解ClassNotFoundException与NoClassDefFoundError的区别
- SOAFArk,Java轻量级隔离容器,蚂蚁金服开源
- 真正理解线程上下文类加载器(多案例分析)
- Java 多线程上下文传递在复杂场景下的实践,vivo
- 详解Apache Dubbo的SPI实现机制, vivo