【java虚拟机系列】JVM类加载器与ClassNotFoundException和NoClassDefFoundError

在我们日常的项目开发中,会经常碰到ClassNotFoundException和NoClassDefFoundError这两种异常,对于经验足够的工程师而言,可能很轻松的就可以解决,但是却不一定明白为何要去这么做,本博客将从java虚拟机类加载的角度让大家彻底理解ClassNotFoundException和NoClassDefFoundError这两种异常及一些重用的解决方案。


在博客中我们已经讲解过,程序员定义的java类要被java虚拟机运行首先要做的就是类加载过程,而类加载过程的第一步就是“加载”过程(注意加载只是类加载的一个一个阶段而已,不要混淆这两个不同的概念)。在”加载“阶段,虚拟机需要完成以下三件事:

1.通过一个类的全限定名来获取此类的二进制字节流。

2.将字节流代表的静态存储结构转换为方法区的运行时数据结构/

3.在内存中创建一个代表此类的java.lang.Class对象,作为方法区此类的'各种数据的访问入口。


其中完成第一步动作的代码模块就是java类加载器,顾名思义:类加载器(class loader)用来加载 Java 类到 Java 虚拟机中。下面对类加载器进行详细讲解。


一java.lang.ClassLoader类

基本上java中的类加载器都是继承自java.lang.ClassLoader类的,所以首先我们来介绍一下java.lang.ClassLoader类,然后再介绍java中的几种类加载器。

java.lang.ClassLoader类的作用就是根据一个指定的类的名称,找到或者生成其对应的字节代码,然后从这些字节代码中定义出一个 Java 类,即 java.lang.Class类的一个实例。除此之外,ClassLoader还负责加载 Java 应用所需的资源,如图像文件和配置文件等。我们在此只关心和类加载相关的功能,首先我们来看一下java.lang.ClassLoader类中和类加载相关的重要方法:


注意defineClass方法存在多个重载版本,其中一个会抛出NoClassDefFoundError异常,而loadClass()抛出的是 java.lang.ClassNotFoundException异常。


二类加载器的树状组织结构


Java 中的类加载器大致可以分成两类,一类是系统提供的,另外一类则是用户自定义的。系统提供的类加载器主要包括以下三个:

引导类加载器(bootstrap class loader):它用来加载 Java 的核心库,是用原生代码来实现的,不是继承自 java.lang.ClassLoader。
扩展类加载器(extensions class loader):即ExtClassLoader,它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找加载 Java 类,继承自 java.lang.ClassLoader。
系统类加载器(system class loader):即AppClassLoader,它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它,该类继承自 java.lang.ClassLoader。


除了引导类加载器之外,所有的类加载器都有一个父类加载器。通过  getParent()方法可以得到。对于系统提供的类加载器来说,系统类加载器的父类加载器是扩展类加载器,而扩展类加载器的父类加载器是引导类加载器;对于用户自定义的类加载器来说,其父类加载器是加载此类加载器 Java 类的类加载器。因为类加载器 Java 类如同其它的 Java 类一样,也是要由类加载器来加载的。一般来说,用户自定义的类加载器的父类加载器是系统类加载器。类加载器通过这种方式组织起来,形成树状结构。树的根节点就是引导类加载器。用图表示如下:


在上述图示中,给人的直观感觉是系统类加载器的父类加载器是标准扩展类加载器,标准扩展类加载器的父类加载器是启动类加载器,真的是这样吗?我们通过代码来测试一下:

public class LoaderTest {

    public static void main(String[] args) {
        try {
            System.out.println(ClassLoader.getSystemClassLoader());
            System.out.println(ClassLoader.getSystemClassLoader().getParent());
            System.out.println(ClassLoader.getSystemClassLoader().getParent().getParent());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
程序的运行结果如下:

sun.misc.Launcher$AppClassLoader@6d06d69c
sun.misc.Launcher$ExtClassLoader@70dea4e
null

我们知道通过java.lang.ClassLoader.getSystemClassLoader()可以直接获取到系统类加载器,那么从运行结果来看,系统类加载器的父加载器是标准扩展类加载器,但是我们试图获取标准扩展类加载器的父类加载器时确得到了null,不是预期的启动类加载器,这是为何呢?这就需要我们从java.lang.ClassLoader中的loadClass(String name)方法的源代码就找答案了:

    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

    protected synchronized Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException {

        // 首先判断该类型是否已经被加载
        Class c = findLoadedClass(name);
        if (c == null) {
            //如果没被加载,就委托给父类加载或者委派给启动类加载器加载
            try {
                if (parent != null) {
                    //如果存在父类加载器,就委派给父类加载器加载
                    c = parent.loadClass(name, false);
                } else {
                    //如果不存在父类加载器,就通过启动类加载器加载该类,
                    //通过调用本地方法native findBootstrapClass0(String name)
                    c = findBootstrapClass0(name);
                }
            } catch (ClassNotFoundException e) {
                // 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }

从上述代码可以很清楚的看到如果该加载器的父类为null则会调用启动类加载器类加载类,除非启动类加载器不能完成加载任务i,才会调用自定义的加载器,因此可知将扩展类的父加载器设置为null与将其父加载器设置为启动类加载器效果是一样的。


三类加载器的双亲委派规则(代理模式)

双亲委派规则指的是如果一个类加载器收到了类加载的请求,它首先不会自己尝试去加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都会传递到顶层的启动类加载器去加载,如果父加载器不能完成这个加载请求(在它的搜索范围内找不到所需的类),子加载器才会自己尝试去加载。


那么java中为何会采取双亲委派原则呢?(其实这种原则在各种程序设计中很常见,如安卓中的事件分派机制),要知道这个原因,首先我们需要知道在java中虚拟机是如何判断两个类为同一个类的,Java 虚拟机不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。当且仅当两者都相同的情况,才认为两个类是相同的。即便是同样的字节代码,被不同的类加载器加载之后所得到的类,也是不同的。比如一个 Java 类 com.example.Sample,编译之后生成了字节代码文件 Sample.class。两个不同的类加载器 ClassLoaderX和 ClassLoaderY分别读取了这个 Sample.class文件,然后定义出两个 java.lang.Class类的实例来表示这个类。这两个实例是不相同的。对于 Java 虚拟机来说,它们是不同的类。如果试图对这两个类的对象进行相互赋值,会抛出运行时异常 ClassCastException。


了解了这一点之后,就可以理解采用双亲委派规则的原因了。采用双亲委派规则是为了保证 Java 核心库的类型安全。所有 Java 应用都至少需要引用 java.lang.Object类,也就是说在运行的时候,java.lang.Object这个类需要被加载到 Java 虚拟机中。如果这个加载过程由 Java 应用自己的类加载器来完成的话,很可能就存在多个版本的 java.lang.Object类,而且这些类之间是不兼容的。通过采用双亲委派规则,对于 Java 核心库的类的加载工作由引导类加载器来统一完成,保证了 Java 应用所使用的都是同一个版本的 Java 核心库的类,是互相兼容的。


四类加载器与ClassNotFoundException和NoClassDefFoundError

通过前面的讲解我们已经知道类加载器采用的是双亲委派原则,类加载器会首先代理给其它类加载器来尝试加载某个类。这就意味着真正完成类的加载工作的类加载器和启动这个加载过程的类加载器,可能不是同一个。真正完成类的加载工作是通过调用 defineClass来实现的;而启动类的加载过程是通过调用 loadClass来实现的。前者称为一个类的定义加载器(defining loader),后者称为初始加载器(initiating loader)。在 Java 虚拟机判断两个类是否相同的时候,使用的是类的定义加载器。也就是说,哪个类加载器启动类的加载过程并不重要,重要的是最终定义这个类的加载器。两种类加载器的关联之处在于:一个类的定义加载器是它引用的其它类的初始加载器。如类 com.example.Outer引用了类 com.example.Inner,则由类 com.example.Outer的定义加载器负责启动类 com.example.Inner的加载过程。 java.lang.ClassNotFoundException是被loadClass()抛出的, java.lang.NoClassDefFoundError是被defineClass()抛出。

类加载器在成功加载某个类之后,会把得到的 java.lang.Class类的实例缓存起来。下次再请求加载该类的时候,类加载器会直接使用缓存的类的实例,而不会尝试再次加载。也就是说,对于一个类加载器实例来说,相同全名的类只加载一次,即 loadClass方法不会被重复调用。


通过前面的讲解我们知道loadClass()是来启动类的加载过程的,其源代码在前面我们已经分析过,即当父加载器不为null同时不能完成加载请求的情况下会抛出ClassNotFoundException异常,而导致父加载器无法完成加载的一个原因很简单就是找不到这个类,通常这种情况是传入的类的字符串名称书写错误,如调用class.forName(String name)或者loadClass(String name)时传入了一个错误的类的名称导致类加载器无法找到这个类。


defineClass是用来完成类的加载工作的,此时已经表明类加载的启动已经完成了,即当前执行的类已经编译,但运行时找不到它的定义时(class definition existed when the currently executing class was compiled, but the definition can no longer be found.)就会抛出NoClassDefFoundError异常,这种情况通常出现在创建一个对象实例的时候(creating a new instance),如在类X中定义了一个类Y,如在类X中定义如下语句ClassY y=new ClassY;程序运行成功之后(此时X与Y的字节码文件已经存在),如果将类Y的字节码文件删除了重新运行上述代码,则会在运行时候抛出NoClassDefFoundError异常。当然这只是为了说明抛出这种异常的原因,一般不会出现删除该类字节码情况,实际上是其它原因导致类似删除的效果导致的,如JAR重复引入,版本不一致导至。因为jar中都是一些已经编译好的Class文件,如果存在多个版本那么在加载的时候就不知道应该调用哪一个版本(相当于删除字节码的效果),此种情况一般出现在引入第三方SDK的时候。

再如类X引用类Y时,类Y初始化失败时也会导致以上的错误出现。其实上面说的重复引入第三方SDK就属于这种情况的一种,一般在我们自己的java文件中import第三方的java类,如果存在多个版本那么在加载的时候就不知道应该调用哪一个版本(相当与引入的类Y初始化失败),当然除了第三方SDK外,自己定义的java文件可能也会出现这种情况。



以上就是本博客的主要内容,如果读者觉得不错,记得小手一抖,点个赞哦!另外欢迎大家关注我的博客账号哦,将会不定期的为大家分享技术干货,福利多多哦!



原文地址:https://www.cnblogs.com/hainange/p/6334011.html