Java的类加载器和类加载机制详解

1,148 阅读12分钟

「这是我参与11月更文挑战的第7天,活动详情查看:2021最后一次更文挑战」。

Java的Class(类)加载器,主要工作在Class加载的“加载阶段”,其主要作用是从系统外部获取Class的二进制数据流。

关于Class(类)的文件结构:Java的 Class(类)文件结构详解;关于Class的加载过程:Java的Class(类)加载过程详解

1 类加载机制

类加载机制主要有如下3种:

  1. 全盘负责:所谓全盘负责,就是当一个类加载器负责加载某个Class时,该Class所依赖和引用其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
  2. 双亲委派:所谓的双亲委派,则是先让父类加载器试图加载该Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有父加载器无法完成此加载任务时,才自己去加载。
  3. 缓存机制:缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。这就是为很么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。

Java的JVM在加载类时默认采用的是双亲委派机制和缓存机制。

2 类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机的唯一性。每个类加载器都拥有一个独立的类名称空间。也就是说:比较两个类是否「相等」,只要在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

这里所指的"相等",包括代表类的Class对象的equals()方法、isAssignableFrom()方法、islnstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。如果没有注意到类加载器的影响,在某些情况下可能会产生具有迷惑性的结果,下面代码演示了不同的类加载器对instanceof关键字运算的结果的影响。

如下案例:

public class ClassLoaderTest {
    public static void main(String[] args) throws Exception {
        //自定义类加载器
        ClassLoader myLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name)
                    throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1)
                            + ".class";
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if (is == null) {
                        return super.loadClass(name);
                    }
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    return defineClass(name, b, 0, b.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };
        //使用自定义类加载器来加载类ClassLoaderTest
        Class newClass = myLoader.loadClass("com.ikang.JVM.classloader.ClassLoaderTest");
        //反射获取实例
        Object obj1 = newClass.newInstance();
        //获取实例所属的类全路径名
        System.out.println(obj1.getClass());


        //原生的类加载器来加载类ClassLoaderTest
        Class<?> oldClass = ClassLoaderTest.class.getClassLoader().loadClass("com.ikang.JVM.classloader.ClassLoaderTest");
        //反射获取实例
        Object obj2 = oldClass.newInstance();
        //获取实利所属的类全路径名
        System.out.println(obj2.getClass());


        //直接获取ClassLoaderTest类的类全路径名
        System.out.println(ClassLoaderTest.class);


        /*从上面的上个输出中,开起来它们属于同一个类,但是实际上并不是,如下判断*/
        System.out.println(obj1 instanceof ClassLoaderTest);
        System.out.println(obj2 instanceof ClassLoaderTest);
    }
}

3 类加载器种类

3.1 虚拟机规范的角度

根据《Java虚拟机规范 javaSE8》,Java虚拟机支持两种不同的类加载器:

  1. 引导类加载器(Bootstrap ClassLoader):它使用C++实现(这里仅限于HotSpot,也就是JDK1.5之后默认的虚拟机,有很多其他的虚拟机是用Java语言实现的),是虚拟机自身的一部分。
  2. 所有其他的类加载器:这些类加载器都由Java语言实现,独立于虚拟机之外,并且全部继承自抽象类java.lang.ClassLoader,这些类加载器需要由引导类加载器加载到内存中之后才能去加载其他的类。

3.1 开发人员的角度

站在Java开发人员的角度来看,类加载器可以大致划分为以下三类:

3.1.1 启动类加载器

Bootstrap ClassLoader,也称根类加载器/引导类加载器。它是在Java虚拟机启动后初始化的,用来加载 Java 的核心类库, 例如< JAVA_HOME >/jre/lib/rt.jar里的所有class,比如java.time.、java.util.、java.nio.、java.lang.、java.text.、java.sql.、java.math、JUC包等各种自带的类库。

启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,那直接使用null代替即可。

下面程序可以获得启动加载器所加载的核心类库,并会看到本机安装的Java环境变量指定的JDK中提供的核心jar包路径:

URL[] urls = Launcher.getBootstrapClassPath().getURLs();
Arrays.stream(urls).forEach(System.out::println);
/*for (URL url : urls) {
    System.out.println(url.toExternalForm());
}*/

本人机器输出如下:

file:/C:/Program%20Files/Java/jdk1.8.0_144/jre/lib/resources.jar
file:/C:/Program%20Files/Java/jdk1.8.0_144/jre/lib/rt.jar
file:/C:/Program%20Files/Java/jdk1.8.0_144/jre/lib/sunrsasign.jar
file:/C:/Program%20Files/Java/jdk1.8.0_144/jre/lib/jsse.jar
file:/C:/Program%20Files/Java/jdk1.8.0_144/jre/lib/jce.jar
file:/C:/Program%20Files/Java/jdk1.8.0_144/jre/lib/charsets.jar
file:/C:/Program%20Files/Java/jdk1.8.0_144/jre/lib/jfr.jar
file:/C:/Program%20Files/Java/jdk1.8.0_144/jre/classes

3.1.2 扩展类加载器

Extension ClassLoader,它用来加载 Java 的扩展库。该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载< JAVA_HOME >\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。它的父加载器为null,并且是使用java语言实现的。

获取扩展类加载器加载的jar包:

public static void main(String[] args) {
    File[] dirs = getExtClassLoaderFile();
    //递归展示jar包
    for (File dir : dirs) {
        showFile(dir.getPath());
    }
}

/**
 * ExtClassLoader类中获取路径的代码
 * 加载<JAVA_HOME>/lib/ext目录中的类库目录
 */
static File[] getExtClassLoaderFile() {
    String s = System.getProperty("java.ext.dirs");
    File[] dirs;
    if (s != null) {
        StringTokenizer st =
                new StringTokenizer(s, File.pathSeparator);
        int count = st.countTokens();
        dirs = new File[count];
        for (int i = 0; i < count; i++) {
            dirs[i] = new File(st.nextToken());
        }
    } else {
        dirs = new File[0];
    }
    return dirs;
}

/**
 * 递归展示jar包
 */
static void showFile(String path) {
    File file = new File(path);
    if (file.exists()) {
        if (file.isFile()) {
            if (file.getName().endsWith(".jar")) {
                System.out.println(file.getAbsolutePath());
                return;
            }
        }
        File[] files = file.listFiles();
        for (File file1 : files) {
            if (file1.isFile()) {
                if (file1.getName().endsWith(".jar")) {
                    System.out.println(file1.getAbsolutePath());
                }
            } else {
                showFile(file1.getPath());
            }
        }
    }
}

3.1.3 应用类加载器

Application ClassLoader,也称系统类加载器。这个类加载器由 sun.misc.Launcher$AppClassLoader 实现。

这个类加载器是 ClassLoader 中的getSystemClassLoader() 方法的返回值,所以一般称它为系统类加载器。它负责加载用户路径(ClassPath)上所指定的类库,开发者可以使用这个类加载器,如果应用程序没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。它的父加载器是 ExtClassLoader。

ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);

3.1.4 自定义类加载器

我们的应用程序都是由这3中类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。开发人员可以通过继承 java.lang.ClassLoader类,重写findClass()方法,调用defineClass()方法的方式实现自己的类加载器,以满足一些特殊的需求。

为什么会有自定义类加载器?

  1. 一方面是由于java代码很容易被反编译,如果需要对自己的代码加密的话,可以对编译后的代码进行加密,然后再通过实现自己的自定义类加载器进行解密,最后再加载。
  2. 另一方面也有可能从非标准的来源加载代码,比如从网络来源,那就需要自己实现一个类加载器,从指定源进行加载。

4 双亲委派模型

上面这些类加载器之间的关系一般如下图所示:

在这里插入图片描述

上图中的类加载器之间的这种层次关系,称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器,其余的类加载器都应该有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承关系来实现,而是使用组合关系来复用父加载器的代码。

双亲委托模型的工作过程是:如果一个类加载器收到了类加载器的请求,它首先不会自己尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类时,子加载类才会尝试自己去加载)。

4.1 双亲委派机制的优势

采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。

其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。

4.2 双亲委派模型的实现

双亲委派模型对于保证Java 程序的稳定性很重要,但它的实现却非常简单,实现双亲委派的代码都集中在java.lang.ClassLoader 的 loadClass() 方法中:先检查类是否被加载过,若没有则调用父加载器的loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父加载器失败,抛出 ClassNotFoundException 异常后,再调用自己的 finClass() 方法进行加载。

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 先检查是否被加载过
        Class<?> c = findLoadedClass(name);
        //如果没被加载过
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                //如果父加载器不为null,则首先让父加载器尝试加载该类
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    //否则,使用dBootstrapClassloader来加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {       
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }
            //父类没有加载成功
            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                long t1 = System.nanoTime();
                //尝试子类加载器加载
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        //根据条件判断是否进行链接操作
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

5 破坏双亲委派模型

双亲委派模型并不是一个强制性的约束模型,而是Java设计者推荐给开发者的类加载器实现方式。在Java的世界中大部分的类加载器都遵循这个模型,但也有例外,到目前为止,双亲委派模型主要出现过3较大规模的“被破坏”情况。

双亲委派模型的第一次“被破坏” 其实发生在双亲委派模型出现之前--即JDK1.2发布之前。由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则是JDK1.0时候就已经存在,面对已经存在 的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK1.2之后的java.lang.ClassLoader添加了一个新的proceted方法findClass(),在此之前,用户去继承java.lang.ClassLoader的唯一目的就是重写loadClass()方法,因为虚拟在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass()。JDK1.2之后已不再提倡用户再去覆盖loadClass()方法,应当把自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型的。

双亲委派模型的第二次“被破坏” 是这个模型自身的缺陷所导致的,双亲委派模型很好地解决了各个类加载器的基础类统一问题(越基础的类由越上层的加载器进行加载),基础类之所以被称为“基础”,是因为它们总是作为被调用代码调用的API。但是,如果基础类又要调用用户的代码,那该怎么办呢。

这并非是不可能的事情,一个典型的例子便是JNDI服务,它的代码由启动类加载器去加载(在JDK1.3时放进rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现部部署在应用程序的classpath下的JNDI接口提供者(SPI, Service Provider Interface)的代码,但启动类加载器不可能“认识”之些代码,该怎么办?

为了解决这个困境,Java设计团队只好引入了一个不太优雅的设计:线程上下文件类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。有了线程上下文类加载器,JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC,JCE,JAXB和JBI等。

双亲委派模型的第三次“被破坏” 是由于用户对程序的动态性的追求导致的,例如OSGi的出现。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构。

相关文章:

  1. 《Java虚拟机规范》
  2. 《深入理解Java虚拟机》

如果有什么不懂或者需要交流,可以留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!