title: 浅析 Java 类加载器
date: 2020/12/29 16:13
引言
ClassNotFoundException 和 NoClassDefFoundError 的区别?
ClassNotFoundException 是一个异常,我们可以从异常中恢复程序,而 NoClassDefFoundError 是一个错误,是由 JVM 抛出的,所以我们不应该恢复。
ClassNotFoundException 产生原因:
Java支持使用 Class.forName
方法来动态地加载类,任意一个类的类名如果被作为参数传递给这个方法都将导致该类被加载到 JVM 内存中,如果这个类在类路径(应用类加载器的默认路径)中没有被找到,那么此时就会在运行时抛出 ClassNotFoundException 异常。
解决这个问题很容易,唯一需要做的就是要确保所需的类连同它依赖的包存在于类路径中。当 Class.forName 被调用的时候,类加载器会查找类路径中的类,如果找到了那么这个类就会被成功加载,如果没找到,那么就会抛出 ClassNotFountException,除了 Class.forName
方法,ClassLoader.loadClass
、ClassLoader.findSystemClass
在动态加载类到内存中的时候也可能会抛出这个异常。
由于类的动态加载在某种程度上是被开发者所控制的,所以他可以选择 catch 这个异常然后采取相应的补救措施。有些程序可能希望忽略这个异常而采取其他方法。还有一些程序则会终止程序然后让用户再次尝试前做点事情。
网上说还有一个导致 ClassNotFoundException 的原因:当一个类已经某个类加载器加载到内存中了,此时另一个类加载器又尝试着动态地从同一个包中加载这个类。
但是我没测试,所以存疑吧!
NoClassDefFoundError 产生原因:
这个错误往往是你使用 new 操作符来创建一个新的对象但却找不到该对象对应的类(要查找的类在编译的时候是存在的,运行的时候却找不到了)。这个时候就会导致 NoClassDefFoundError。
LinkageError 的子类表明一个类对另一个类有某种依赖性;但是,后一类在前一类编译之后已经发生了不兼容的变化。(画外音:所以嘛,根据这个定义 ExceptionInInitializerError 就不应该是他的子类啊)
项目中遇到 NoClassDefFoundError 常见原因:
- 类路径下缺少部分 jar 包或者类文件。
- jar 包文件名发生变更,因为部分可执行 jar 包中的 MANIFEST.MF 中对 jar 包引用方式如下:
所以当 jar 包名称改变之后就会找不到。但是 Tomcat 会加载 lib 目录下的所有 jar 包,所以不会存在这个问题。
- NoClassDefFoundError也可能由于类的静态初始化模块错误导致,当你的类执行一些静态初始化模块操作,如果初始化模块抛出异常,哪些依赖这个类的其他类会抛出NoClassDefFoundError的错误。如果你查看程序日志,会发现一些java.lang.ExceptionInInitializerError的错误日志,ExceptionInInitializerError 的错误会导致java.lang.NoClassDefFoundError: Could not initialize class。
public class NoClassDefFoundErrorDueToStaticInitFailure {
public static void main(String[] args) {
try {
//java.lang.ExceptionInInitializerError
// at cn.x5456.NoClassDefFoundErrorDueToStaticInitFailure.main(NoClassDefFoundErrorDueToStaticInitFailure.java:7)
//Caused by: java.lang.RuntimeException: UserId Not found
// at cn.x5456.User.getUserId(NoClassDefFoundErrorDueToStaticInitFailure.java:19)
// at cn.x5456.User.<clinit>(NoClassDefFoundErrorDueToStaticInitFailure.java:16)
// ... 1 more
User user1 = new User();
} catch (Throwable e) {
e.printStackTrace();
}
//Exception in thread "main" java.lang.NoClassDefFoundError: Could not initialize class cn.x5456.User
// at cn.x5456.NoClassDefFoundErrorDueToStaticInitFailure.main(NoClassDefFoundErrorDueToStaticInitFailure.java:11)
User user2 = new User();
}
}
对此我有两个疑问:
1)ExceptionInInitializerError 表示的是初始化阶段出现了错误,但他为啥继承了 LinkageError
ExceptionInInitializerError 表示在静态初始化程序中发生了意外的异常。 抛出 ExceptionInInitializerError 表示在评估静态初始化程序或静态变量的初始化程序期间发生了异常
2)为啥第二次会抛出 NoClassDefFoundError
-
由于 NoClassDefFoundError 是 LinkageError 的子类,而 LinkageError 的错误在依赖其他的类时会发生,所以如果你的程序依赖原生的类库和需要的dll不存在时,有可能出现 java.lang.NoClassDefFoundError。这种错误也可能抛出
java.lang.UnsatisfiedLinkError: no dll in java.library.path Exception Java
这样的异常。解决的办法是把依赖的类库和 dll 跟你的 jar 包放在一起。 -
如果你使用 Ant 构建脚本来生成 jar 文件和 manifest 文件,要确保 Ant 脚本获取的是正确的 classpath 值写入到 manifest.mf 文件
-
Jar 文件的权限问题也可能导致 NoClassDefFoundError,如果你的程序运行在像 linux 这样多用户的操作系统种,你需要把你应用相关的资源文件,如 Jar 文件,类库文件,配置文件的权限单独分配给程序所属用户组,如果你使用了多个用户不同程序共享的 jar 包时,很容易出现权限问题。比如其他用户应用所属权限的 jar 包你的程序没有权限访问,会导致 java.lang.NoClassDefFoundError 的错误。
-
Maven项目为什么会产生NoClassDefFoundError的jar包冲突?、**重新看待Jar包冲突问题及解决方案
扩展
众所周知,类从加载到虚拟机内存开始,到卸载出内存,它的**整个生命周期如下:
- 加载(loading):查找并加载类的二进制数据【byte[]】(最常见的情景下是将磁盘上的class文件加载到内存中)
- 连接(linking):将二进制class数据合并到JRE中(即把单一的 Class 加入到有继承关系的类树中),类的连接又分为三个阶段
- 验证(verification):确保被加载的类的正确性【验证模数,版本号等】
- 准备(preparation):为类的静态变量分配内存并赋默认值。因为此时还没有对象,所以是不会为普通变量赋值。
- 解析(resolution):将类中的符号引用(符号占位)转换为直接引用(通过指针直接指向内存中的位置) -> JVM 规定要求,遇到 anewarray(数组)、checkcast(泛型) 等操作符号引用的字节码指令之前,必须解析其符号引用。
- 初始化(initialization):为类的静态变量赋予正确的初始值
- 使用(using):类加载到内存之后,我们就可以使用它来创建对象。
- 卸载(unloading)
其中加载时从外存储器找不到需要的 class 就出现 ClassNotFoundException,连接时从内存找不到需要的 class 就出现 NoClassDefFoundError。
JVM规范规定有且仅有下面几种情况会触发类的初始化(加载、验证、准备在这之前完成):
- new
- 访问(助记符:getstatic)/赋值(putstatic)某个类或接口的静态变量
- 调用类的静态方法(invokestatic)
- 反射,Class.forName("xx")
- 初始化一个类的子类,也会对父类主动使用
- 启动类
注:上面几种情况都属于主动使用,除上面几种情况下,都是被动使用,不会对类进行初始化(initialization);例如:A.class
接口的初始化与类初始化类似,只有 5 不同,父类接口只在用到时候才会初始化。
ClassLoader
为什么需要类加载器
类加载器是 Java 语言的一个创新,也是 Java 语言流行的重要原因之一。它使得 Java 类可以被动态加载到 Java 虚拟机中并执行。类加载器从 JDK 1.0 就出现了,最初是为了满足 Java Applet 的需要而开发出来的。Java Applet 需要从远程下载 Java 类文件到浏览器中并执行。现在类加载器在 Web 容器和 OSGi 中得到了广泛的使用。
类加载器的主要的作用
- 负责将 Class 加载到 JVM 中;
- 审查每个类应该由谁加载;
- 将 Class 字节码重新解析成JVM统一要求的对象格式;
ClassLoader 的几个主要方法
方法 | 含义 |
---|---|
defineClass(byte[], int, int) | 将byte字节流解析成 JVM 能够识别的Class对象 |
resolveClass(Class<?>) | 用来连接(linking)类,把单一的 Class 加入到有继承关系的类树中。 |
findClass(String) | 留给子类实现的方法,用于子类自定义类的查找规则,从而取得加载类的字节流,然后可以结合 defineClass 方法生成类的 Class 对象。 |
loadClass(String) | 它会调用 findClass(String) 方法,默认不调用 resolveClass(Class<?>) 方法,可以同过重载方法 loadClass(String,boolean) 决定在类加载到 JVM 是被链接,不然将在初始化(initialization)之前才进行链接 |
ClassLoader 的源码
三个类加载器
类加载器 | 介绍 |
---|---|
启动类加载器(Bootstrap ClassLoader) | 在HotSpot虚拟机中,Bootstrap ClassLoader 是用 C++ 语言编写并嵌入 JVM 内部,主要负载加载<code>JAVA_HOME/lib</code>目录中的所有类,或者加载由选项<code>-Xbootcalsspath</code>指定的路径下的类; |
拓展类加载器(ExtClasLoader) | ExtClassLoader 继承自 URLClassLoader类,是 sun.misc.Launcher 的内部类,负载加载<code>JAVA_HOME/lib/ext</code>目录中的所有类,或者由参数<code>-Xbootclasspath</code>指定路径中的所有类; |
应用/系统类加载器(AppClassLoader) | AppClassLoader 继承自 URLClassLoader类,是 sun.misc.Launcher 的内部类,负责加载用户类路径<code>ClassPath</code>下的所有类型,一般情况下为程序的默认类加载器; |
类图:
为什么 Java 中有三种基本的类加载器?
在 jdk1.2 版本的 JVM 中,只有一个类加载器,就是现在的 Bootstrap 类加载器。
Java 中有三种基础的类加载器主要为了安全。只有同一个类加载器加载的类 JVM 才会保证其的包访问级别(如果不指明private/public或protected,则方法和属性具有包访问级别),因此,如果全部都使用一个类加载器,假如用户调用他编写的 java.lang.MyClass 类。理论上该类可以访问和改变 java.lang 包下其他类的默认访问修饰符的属性和方法的能力。Java语言本身并没有阻止这种行为。但是 JVM 则会阻止这种行为,因为 java 核心类库的 java.lang 包下的类是由 bootstrap 类加载器加载的。不是同一个类加载器加载的类等于不具有包级别的访问权限。类加载器中的其他安全特性也会阻止这种类型侵入。
所以为什么有三种基础的类加载器?是因为他们代表三种不同的信任级别。最可信的级别是 java 核心 API 类。然后是安装的拓展类,最后才是在类路径中的类(属于你本机的类)。
类加载机制/使用自定义类加载器带来了什么好处?
- 首先,是为了区分同名的类:假定存在一个应用服务器,上面部署着许多独立的应用,同时他们拥有许多同名却不同版本的类库。试想,这时候jvm该怎么加载这些类同时能尽可能的避免掉类加载时对同名类的差异检测呢?当然是不同的应用都拥有自己独立的类加载器了。
- 其次,是为了更方便的加强类的能力:类加载器可以在load class时对class进行重写和覆盖,在此期间就可以对类进行功能性的增强。比如添加面向切面编程时用到的动态代理,以及debug等原理。怎么样达到仅修改一个类库而不对其他类库产生影响的效果呢? 一个比较方便的模式就是每个类库都可以使用独立的类加载器
AOP的实现机制
双亲委派机制
如果一个类加载器收到了加载类的请求,首先检查这个类是否已经被当前加载器加载过,如果没加载过它首先不会自己去尝试加载这个类,而是把这个请求委托给上级类加载器去完成,每一个层次的类加载器都是如此,所有加载器的加载请求最终都应该传送至最顶层的类加载器中(扩展类加载器),只有当上级类加载器反馈自己无法完成这个加载请求(它的类加载范围中没有找到所需的类)时,下级类加载器才会去尝试自己加载这个类,这便是类加载器的双亲委托机制(Parents Delegation Model)
为啥需要这个机制?
因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子 ClassLoader 再加载一次。从安全角度考虑,如果不使用这种委托模式,我们自己定义一个 Map 来动态替代 java 核心 api 中定义的类型,这样会存在非常大的安全隐患,而双亲委托的方式,就可以避免这种情况,因为 Map 在启动时就被引导类加载器(Bootstrcp ClassLoader)加载,所以用户自定义的 ClassLoader 永远也无法加载一个自己写的 Map,除非你改变 JDK 中 ClassLoader 搜索类的默认算法。
JVM 在搜索类的时候,又是如何判定两个 class 是相同的呢?
JVM 在判定两个 class 是否相同时,不仅要判断两个类名是否相同,而且要判断是否由同一个类加载器实例加载的。只有两者同时满足的情况下,JVM 才认为这两个 class是 相同的。就算两个 class 是同一份 class 字节码,如果被两个不同的 ClassLoader 实例所加载,JVM 也会认为它们是两个不同的 class。
一个类,由不同的类加载器实例加载的话,会在方法区产生两个不同的类,彼此不可见,并且在堆中生成不同Class实例。
当使用 equals 方法比较这两个实例生成的对象的时候,会出现ClassCastException的异常,因为两个不同类加载器加载的类无法进行比较。
双亲委派的缺陷
我们知道 jdbc 为第三方数据库厂商定义了一系列接口让他们实现,当我们需要连接某个数据库的时候只需要引入对应厂商的 jar 包,执行下面代码:
Class.forName("com.mysql.jdbc.Driver");
目的是向 DriverManager 中注册这个驱动:
之后执行:
Connection conn = DriverManager.getConnection(url, username, password);
就可以获取到连接了,我们接下来看下这句话里面是如何执行的:
线程上下文类加载器是什么呢?
为啥要传入线程上下文中的类加载器呢?
因为第三方厂商的 jar 包是在类路径下的,当前类的加载器是 Bootstrap ClassLoader,由于双亲委托机制不能向下委托,那么只能通过线程上下文获取到应用类加载器(AppClassLoader)进行加载
**建议看下这篇文章,他是从 spi 角度讲的
思维扩展
不知道你们看到这种父子类加载器的设计想到了什么,反正我感觉 Spring 父子容器的设计是模仿这个来设计的,那么 Spring 这样设计的目的是什么呢?
这个答案和上面类加载机制/使用自定义类加载器带来了什么好处?
问题的答案很相似:
***Spring系列第24篇:父子容器
本文参考
ClassNotFoundException和NoClassDefFoundError的区别
Maven项目为什么会产生NoClassDefFoundError的jar包冲突?
**重新看待Jar包冲突问题及解决方案
**类加载(四):五阶段详解
JVM类加载过程 & 双亲委派模型
为什么Java中有三种基本的类加载器?
深入探讨 Java 类加载器
ClassLoader
JAVA类:我是如何被ClassLoader加载到内存的
**怎么解决java.lang.NoClassDefFoundError错误
**浅谈双亲委派机制的缺陷及打破双亲委派机制
JAVA为什么要有多个类加载器,1个不行吗
***Spring系列第24篇:父子容器