类加载器及其加载原理

概述

在之前的文章"类的加载流程"讲了一个Class文件从加载到卸载整个生命周期的过程,并且提到"非数组类在加载阶段是可控性最强的"。而这个优点很大程度上都是类加载器所带了的,因而本篇文章就着重讲一下类加载器的加载机制与加载原理。

首先我们思考一个问题:什么是类加载器?

简单来说就是加载类的二进制字节流的工具,那它是如何找到所要加载类的具体位置呢?

答案就是通过类的全限定名

因而我们可以这样说,类加载器就是用来完成“通过一个类的全限定名来获取描述该类的二进制字节流”这一加载动作的代码。

类加载器的作用

顾名思义,类加载器的作用是实现类的加载动作。但它的作用就仅限于此吗?

答案必然是否定的。

我们首先看下边这一段代码:

public class ClassLoaderTest {

    public static void main(String[] args) throws Exception {
    	//创建自定义的类加载器并重写loadClass方法
        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);
                }
            }
        };
        //使用自定义类加载器加载对象
        Object obj = myLoader.loadClass("test.ClassLoaderTest").newInstance();

        System.out.println(obj.getClass());
        System.out.println(obj instanceof test.ClassLoaderTest);
    }
}

代码运行结果如下:

class test.ClassLoaderTest
false

上述代码基本上所做的事情就是,创建了一个自定义的类加载器,然后使用这个类加载器加载了ClassLoaderTest类,并以该类为模板创建了对象。

输出结果上看,新创建的对象obj确实是以test.ClassLoaderTest为类模板创建的,但为何在判断是否是test.ClassLoaderTest的实例对象时结果是false呢?

这是因为在Java中一个类的唯一性不仅和类本身相关而且和加载它的类加载器相关,也就是说:任何一个类都必须由加载它的类加载器这个类本身一起确定其在Java中的唯一性,每一个类加载器都有一个独立的类命名空间。

换句话说,如果想要比较两个类是否相等时,只有这两个类是同一个类加载器的前提下才有意义,否则,即使这两个类是同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,这两个类必然就不相等。

这里类的相等与否到底有何影响呢?

这里的相等包含了的Class对象的equals()方法、isAssignableForm()isInstance()方法返回结果,也包括了使用instanceof关键字进行从属判断的各种情况。

通过上面的解释,我们应该就懂了为何例子程序中,在使用instanceof判断的时候返回结果是false。因为在例子程序中,Java虚拟中同时存在两个test.ClassLoaderTest类,一个是由虚拟机的"应用程序类加载器"加载的,另一个是由自定义加载器加载的,但在Java虚拟机中仍然是两个独立的类,因而在做类型检查时返回结果是false。

类的加载机制

前边我们说了类加载器是参与类唯一性的判断的,并且我们的Java虚拟机是有多个类加载器的,因而这里就会有一个问题:多个类加载器在加载类的时候是如何进行协调的?它是如何解决重复加载这一问题的?

说到这里,我们不得不提一下,类加载器的一个加载机制--双亲委派机制。但在正式聊双亲委派机制之前,我们有必要了解一下类加载器的类别和它们之间的一个层次关系。

类加载器的种类与关系

类加载器的层次关系图,如下所示:

层次关系

从图中可以看到,系统提供的类加载器主要有三个:

  1. BootstrapClassLoader(启动类加载器) :用于加载系统类库中的类,是最顶层的加载类,由C++实现,负责加载 %JAVA_HOME%/lib目录下的jar包和类或者或被 -Xbootclasspath参数指定的路径中的所有类。注意该加载器无法被Java程序直接引用,若在自定义加载器中需要委派给启动类加载器加载,直接返回null即可。
  2. ExtensionClassLoader(扩展类加载器) :用于加载系统类库扩展类,主要负责加载目录 %JRE_HOME%/lib/ext 目录下的jar包和类,或被 java.ext.dirs 系统变量所指定的路径下的jar包。但在JDK9之后,这种扩展机制被模块化的天然扩展能力所取代。
  3. AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用classpath下的所有jar包和类。一般情况下,该加载器是默认加载器

最后是自定义类加载器(User Class Loader):这个加载器是需要在继承ClassLoader的自定义加载器类UserClassLoader中重写findClass()来实现的。

此处可能会有些疑问,既然应用程序类加载器都已经是默认的加载器了?那自定义类加载器还有什么意义呢?

主要原因是应用程序加载器它只能加载在classpath下的所有类,但不在classpath下的class文件是无法被加载的,比如通过网络远程传输过来的class文件(远程调用)或者我们在桌面上有一个class文件,希望在运行的过程中被加载和使用,在这两种情况下,应用程序类加载器是无法加载的,此时如果想要加载必须靠自定义类。

为了说明这一点,我们可以看一个例子:

首先我们定义一个待加载的普通类,放置在com.test包中:

package com.test;

public class Test {
    public void hello() {
        System.out.println("我是Test,由 " + getClass().getClassLoader().getClass()
                + " 加载进来的");
    }
}

将编译生成后的class文件移动到其他位置,非当前项目的classpath下面,本例位置如下:

image.png

注意:

如果你是直接在当前项目里面创建,待Test.java编译后,请把Test.class文件拷贝走,再将Test.java删除。因为如果Test.class存放在当前项目中,根据双亲委派模型可知,会通过sun.misc.Launcher$AppClassLoader 类加载器加载。为了让我们自定义的类加载器加载,我们把Test.class文件放入到其他目录。

如果此时我们想调用Test类,因为该类不在项目的classpath下,因而无法通过系统加载器进行加载,只能通过用户自定义加载器。

写一个用户自定义加载器,内容如下:

/**
 * 自定义类加载器,加载自定义位置下的class文件
 * @author vcjmhg
 *
 */
public class UserDefineClassLoader {
	 static class MyClassLoader extends ClassLoader {
	        private String classPath;

	        public MyClassLoader(String classPath) {
	            this.classPath = classPath;
	        }

	        private byte[] loadByte(String name) throws Exception {
	            name = name.replaceAll("\\.", "/");
	            FileInputStream fis = new FileInputStream(classPath + "/" + name
	                    + ".class");
	            int len = fis.available();
	            byte[] data = new byte[len];
	            fis.read(data);
	            fis.close();
	            return data;

	        }

	        protected Class<?> findClass(String name) throws ClassNotFoundException {
	            try {
	                byte[] data = loadByte(name);
	                return defineClass(name, data, 0, data.length);
	            } catch (Exception e) {
	                e.printStackTrace();
	                throw new ClassNotFoundException();
	            }
	        }

	    };

	    public static void main(String args[]) throws Exception {
	        MyClassLoader classLoader = new MyClassLoader("C:\\Users\\vcjmhg\\Desktop\\Test");
	        Class clazz = classLoader.loadClass("com.test.Test");
	        Object obj = clazz.newInstance();
	        Method helloMethod = clazz.getDeclaredMethod("hello", null);
	        helloMethod.invoke(obj, null);
	    }
}

上述代码,基本意思就是:定义了一个类加载器MyClassLoader可以对指定文件夹下的class文件进行加载,在加载完成之后通过反射创建了一个obj对象并调用了其hello()方法。关于自定义加载器定义方法可以参考后续小节的"如何自定义类加载器?"

运行结果如下:

我是Test,由 class com.test.UserDefineClassLoader$MyClassLoader 加载进来的

上边的例子,就解释了自定义类加载器的作用:它可以按照用户要求加载指定的class文件,无论class文件是通过网络传过来,还是本地的某个路径,都可以实现加载。

双亲委派机制

其实从前边那个类的层次关系图中,,我们就可以简单了解双亲委派模型加载机制:

当一个类加载器收到类加载请求时,首先不会自己尝试加载这个类,而是把这个请求委派父类加载器去完成,每一层的类加载器都是如此,因此所有的加载请求最终都会传送到顶层的启动类加载器中,只有父加载器无法完成这个加载请求(它的搜索范围内,找不到所需的类)时,子加载器才会尝试自己去完成加载。

结合自定义加载器,整个类的加载流程如下图所示:

image.png

  1. 当我们的自定义类加载器要加载一个类的时候,会首先判断给定的类是否被加载过,如果已经被加载过则不再加载,可以直接使用;如果没被加载,它也不会直接加载而是把加载任务委派给父加载器也就是AppClassLoader 加载器。
  2. AppClassLoader在收到委派的加载任务后,也不直接加载,也会做一个自己是否加载过的判断,,如果没有将将加载任务委派给ExtClassLoader
  3. ExtClassLoader收到委派的任务后,在自己没有加载过该类的情况下,会将加载任务委派给BootstrapClassLoader,由于BootstrapClassLoader是顶层加载器没有父加载器,因而BootstrapClassLoader会开始尝试自己加载,如果说需要加载的类位于其加载范围(比如-Xbootclasspath参数指定的加载类),则直接返回加载结果。否则下沉到子类加载器进行加载,直到底层的自定义类加载器
  4. 要注意,如果所有的类加载器最终都无法加载,会抛出一个ClassNotFoundException

到这里可能就会有小伙伴有疑问了:这玩意有什么用?为什么要这样设计呢?

双亲委派机制有什么用?

这种加载机制一个显而易见的好处就是Java中的类随着它的它的类加载器一起具备了具有优先级的层级结构。比如类java.lang.Object,它存放在rt.jar需要被顶层启动类加载器所加载,因而Object类在程序的各种类加载器的环境中都能保证是同一个类,避免了重复加载的情况发生,而这也避免了危险代码植入的风险(比如恶意替换java.lang.Object类)。

双亲委派模型对于Java程序的稳定性极其重要,但其实现却异常简单。

双亲委派机制如何实现的?

用以实现双亲委派的代码只有短短十多行,全部集中在java.lang.ClassLoaderloadClass()方法之中,具体实现代码如下:

//name:被加载类的全限定名
// resolove:是否连接了已加载的类
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 首先检查类是否已经被加载了
        Class<?> c = findLoadedClass(name);
	//未被加载的情况下,尝试用父类加载器进行加载
        if (c == null) {
            try {
		//存在父类加载器的情况下继续向上委托
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
		//使用顶层加载器BootstrapClassLoader进行加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父类加载器无法加载的情况下抛出ClassNotFoundException异常
                // 说明父类加载器无法完成加载请求
            }

            if (c == null) {
                // 父类加载器无法加载的情况下,调用本加载器的findClass()方法着手进行加载
                c = findClass(name);
            }
        }
	//如果resolve标记为true,则在加载操作完成后执行链接操作
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

这段代码逻辑非常简单:先检查类是否被加载,如果没有被加载则委托父类加载器进行加载,若父类加载器也无法加载则调用findClass()方法进行进行加载。

如何自定义类加载器?

实现一个自定义类加载器有两种情况:

第一种 不破坏“双亲委派机制”

loadClass()的代码中可以看到,类加载的最后一步就是调用findClass()方法,因而如果要实现一个自定义类加载器需要首先继承ClassLoader类,然后重写其findClass()方法。

我们可以首先看下findClass()的默认实现:

protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
}

可以看出,抽象类ClassLoaderfindClass()函数默认只是抛出异常的,因此要自定义类加载器必须要重写findClass()方法,根据传入的字符串(指定类文件路径)生成对应的Class对象

那如何生成一个Class对象呢?

很简单,Java提供了defineClass()方法,通过这个方法,我们可以把一个字节数组转为Class对象。

defineClass()方法的默认实现如下:

protected final Class<?> defineClass(String name, byte[] b, int off, int len)
        throws ClassFormatError  {
        return defineClass(name, b, off, len, null);
}

当重写findClass()首先要获取到Class文件的字节流数据,然后将字节流数据传递给defineClass()方法最终获取到一个Class对象,至此在不破坏双亲委派机制的情况下,就完成了自定义类加载器。具体实现可以参考前边的"例子",该自定义类加载器的基本思路就是重写了findClass()方法。

第二种 “破坏双亲委托机制”

可能在某些场景下,需要使用自定义加载器加载一些特殊的类文件,比如位于classpath路径下的一些类文件,如果直接重写findClass()方法,由于双亲委派机制该类必然会被AppClassLoader所加载。因而若要实现这样的类加载器,必须要重写loadClass()方法。

如何破坏双亲委托?

因为双亲委派机制并不是一个强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式,因而在"模块化"出现之前,双亲委托模型主要出现了3次被较大规模“被破坏”的情况:

第一次:为了保证兼容性

由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器这一概念java.lang.ClasssLoader这一概念在Java第一个版本中便存在了。因而为了保证向前兼容性,兼用JDK1.2之前已经存在的自定义类加载器代码,Java设计者在引入双亲委派模型的时候做了一些妥协,不直接以技术手段避免loadClass()被覆盖的可能性,而是将双亲委派模型的逻辑代码写在loadClass()方法中。并且引导用户在编写自定义类加载器时,尽量重写新添加的findClass()方法,而不是覆盖loadClass()方法。

第二次:为了实现SPI技术

某些情况下,基础类型需要调用用户的代码,比如JNDI技术(对资源几种查找和管理的技术),它需要调用其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口SPI的代码,但是启动类绝不可能认识和加载这些代码,那该怎么办呢?

为了解决该问题,Java设计团队设计了一个线程上下文加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextLoader()方法进行设计,如果创建线程时,没有设置它将从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那么这个类加载器默认就是应用程序类加载器。

有了这些上下文类加载器之后,JNDI服务使用这个线程上下文加载器去加载所需要的SPI代码,这是一种父加载器请求自类加载器的类加载行为。

第三次:用户对应用程序动态性的追求所导致的

为了追求应用程序的动态性,IBM在2008年提出了OSGI技术,用来实现模块化的热部署。

其实现热部署的原理如下:

OSGI实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(Bundle)都有一个自己的类加载器,当需要替换一个Bundle时,就把Bundle连同类加载器一起替换掉以实现代码的热替换。在OSGI环境下,类加载器不再是双亲委派模型推荐的树状结构,而逐步发展成了网状结构,当收到请求加载时,OSGI将按照如下顺序进行类搜索:

  1. 将以java.*开头的类,委派给父类加载器加载
  2. 否则,将委派列表名单的类,委派给父加载器进行加载
  3. 否则,将Import列表中的类委派给Export这个类的Bundle的类加载器进行加载
  4. 否则,查找当前Bundle的ClassPath,使用自己的类加载器进行加载
  5. 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器进行加载
  6. 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载
  7. 否则,类查找失败

从上边流程中我们可以看到,只有前两条符合双亲委派模型,其他的均不符合。

总结

本文主要讲了常用的类加载器,比如启动类加载器、扩展类加载器、应用类加载器以及自定义类加载器,详细介绍了类加载器在加载一个类时的原理以及加载所使用的双亲委派机制。以及使用双亲委派机制的好处以及破坏该机制的一些情况。

引用

  1. Java自定义类加载器与双亲委派模型
  2. 深入理解jvm虚拟机 第三版
  3. 类加载器
posted @ 2021-04-17 11:28  vcjmhg  阅读(733)  评论(0编辑  收藏  举报