阅读 488

java三大类加载器

如果有不懂或者发现作者错误处,欢迎积极留言,作者保证有问必答!

最近在整理总结java类加载器的相关知识,发现我们一般的开发者在没有使用自定义类加载器的前提下只会用到三种类加载器,分别是赤犬、黄猿和青雉... ̄□ ̄||。我们就要像路飞一样将这三种类加载器一一打败,彻底搞懂ヾ(◍°∇°◍)ノ゙

类加载器的定义

类加载器基本职责就是根据类的二进制名(binary name)读取java编译器编译好的字节码文件(.class文件),并且转化生成一个java.lang.Class类的一个实例。这样的每个实例用来表示一个Java类,jvm就是用这些实例来生成java对象的。比如new一个String对象;反射生成一个String对象,都会用到String.class 这个java.lang.Class类的对象。基本上所有的类加载器都是java.lang.ClassLoader 类的一个实例。下面介绍这个类加载器的一些核心方法:

方法名 说明
getParent() 返回该类加载器的父类加载器
loadClass(String name) 加载名为name的类,返回java.lang.Class类的实例
findClass(String name) 查找名字为name的类,返回的结果是java.lang.Class类的实例
findLoadedClass(String name) 查找名字为name的已经被加载过的类,返回的结果是java.lang.Class类的实例
defineClass(String name,byte[] b,int off,int len) 根据字节数组b中的数据转化成Java类,返回的结果是java.lang.Class类的实例

上述方法的name参数都是binary name(类的二进制名字)如:

  • java.lang.String <包名>.<类名>
  • java.concurrent.locks.AbstractQueuedSynchronizer$Node <包名>.<类名>$<内部类名>
  • java.net.URLClassLoader$1 <包名>.<类名>.<匿名内部类名>

类加载器的实例:

即便一个最简单的helloworld程序:

public class SayHello{
    public void justSayHello(){
        String str = "hello world!";
        System.out.println(str);
    }

    public static void main(String[] args){
        SayHello instance = new SayHello();
        instance.justSayHello();
    }
}
复制代码

也会用到至少3个类加载器实例:

  • 引导类加载器(Bootstrap ClassLoader)
  • 拓展类加载器(Extension ClassLoader)
  • 应用类加载器(Application ClassLoader)

引导类加载器

引导类加载器是jvm在运行时,内嵌在jvm中的一段特殊的用来加载java核心类库的C++代码。String.class 对象就是由引导类加载器加载的,引导类加载器具体加载哪些核心代码可以通过获取值为 "sun.boot.class.path" 的系统属性获得。引导类加载器不是java原生代码编写的,所以其也不是java.lang.ClassLoader类的实例,其没有getParent方法。

拓展类加载器

拓展类加载器用来加载jvm实现的一个拓展目录,该目录下的所有java类都由此类加载器加载。此路径可以通过获取"java.ext.dirs"的系统属性获得。拓展类加载器就是java.lang.ClassLoader类的一个实例,其getParent方法返回的是引导类加载器(在 HotSpot虚拟机中用null表示引导类加载)。

应用类加载器

应用类加载器又称为系统类加载器,开发者可用通过 java.lang.ClassLoader.getSystemClassLoader()方法获得此类加载器的实例,系统类加载器也因此得名。其主要负责加载程序开发者自己编写的java类。一般来说,java应用都是用此类加载器完成加载的,可以通过获取"java.class.path"的系统属性(也就是我们常说的classpath)来获取应用类加载器加载的类路径。应用类加载器是java.lang.ClassLoader类的一个实例,其getParent方法返回的是拓展类加载器。

拓展类加载器和应用类加载器的类图如下:

ClassLoader Constructor

其中的AppClassLoader和ExtClassLoader分别是系统类加载器实例的定义类和拓展类加载器实例的定义类,当然它们也是ClassLoader的一个实例了。

类加载器的加载机制

当jvm要加载某个类时,jvm会先指定一个类加载器,负责加载此类。而此指定的类加载器在尝试自己去根据某个类的二进制名字查找其相应的字节码文件并定义之前,会首先委托给其父亲(getParent方法返回的类加载器)尝试加载,如果加载失败,就会由自己来尝试加载此类。一般情况下,这个由jvm指定的类加载器就是应用类加载器,jvm会自动调用其loadClass(String name)方法来开启类的加载过程,具体细节如下图:

类加载器机制

以上面的SayHello类为例,jvm首先调用系统类加载器的loadClass方法(String name)来获得SayHello.class对象,系统类加载器委托给拓展类加载器代劳,拓展类加载器委托给引导类加载器代劳,引导类加载器是jvm的根加载器,其没有委托对象,尝试自己加载SayHello.class对象但是没有成功,其将结果(null)返回给拓展类加载器,拓展类加载器根据结果发现引导类加载器没有加载成功,其自己尝试加载SayHello.class对象,并将结果(null)返回给应用类加载器,应用类加载器根据加载结果发现拓展类加载器也没有加载成功,那么其就自己尝试加载SayHello.class对象,并且将最终的结果(SayHello.class)对象返回给jvm,jvm就是根据这个SayHello.class对象来创建SayHello对象的实例的。而这个加载机制就称为类加载的双亲委托模型,这个机制的优缺点我们会在下文会论述。

定义加载器和初始加载器

通过 类加载器的加载机制 小节我们可以得出这样一个结论:真正完成一个类的加载工作和启动这个类的加载过程的类加载器可能不是同一个。真正完成类加载工作的是通过调用defineClass方法来实现的;而启动类的加载过程是调用loadClass方法实现的。前者称为此类的定义加载器(defining loader),后者称为此类的初始加载器(initiating loader)。如SayHello这个例子中,SayHello类的定义加载器和初始加载器都是应用类加载器;而String类的定义加载器是引导类加载器(java.lang.String类时java核心类库中的类由引导类加载器完成真正的加载工作),初始加载器是应用类加载器。每一个java.lang.Class类的实例都可以通过getClassLoader()方法返回其定义加载器的引用。通过执行下面的代码,我们可以验证上述结论:

System.out.println(SayHello.class.getClassLoader());
System.out.println(String.class.getClassLoader());
复制代码

返回的结果如下:

sun.misc.Launcher$AppClassLoader@18b4aac2
null
复制代码

定义加载器和初始加载器具有如下关系:

一个类的定义加载器是这个类中引用的其它类的初始加载器

如果com.demo.Outer 引用了 com.demo.Inner,那么定义了com.demo.Outer类的定义加载器是com.demo.Inner的初始加载器。

类的命名空间

在程序运行过程中,一个类并不是简单由其二进制名字(binary name)定义的,而是通过其二进制名和其定义加载器所确定的命名空间(run-time package)所共同确定的。所以同一个二进制名的类由不同的定义加载器加载时,其返回的Class对象不是同一个,那么由不同的Class对象所创建的对象,其类型也不是相同的。类似 Test cannot be cast to Test 的java.lang.ClassCastException 的奇怪错误很多情况下都是类的二进制名相同,而定义加载器不同造成的。具体演示代码如下:

package jvm.classloader;

import sun.misc.Launcher;

public class RunTimePackageDemo {
    

    public static void main(String[] args) throws Exception {
        ClassLoader classLoader = new Launcher().getClassLoader(); //1 new一个新的类加载器
        Class<?> aClass = classLoader.loadClass("jvm.classloader.RunTimePackageDemo");
        RunTimePackageDemo runTimePackageDemo  = (RunTimePackageDemo)aClass.newInstance(); //2
    }
}
复制代码

运行上述代码,会在 //2处报如下异常:

Exception in thread "main" java.lang.ClassCastException: jvm.classloader.RunTimePackageDemo cannot be cast to jvm.classloader.RunTimePackageDemo
	at jvm.classloader.RunTimePackageDemo.main(RunTimePackageDemo.java:19)
复制代码

这是因为 //1 处获取的应用类加载器a和jvm用来加载器RunTimePackageDemo.class对象的应用类加载器b不是同一个实例,那么构成这两个类的run-time package也就是不同的。所以即使它们的二进制名字相同,但是由a定义的RunTimePackageDemo类所创建的对象显然不能转化为由b定义的RunTimePackageDemo类的实例。这种情况下jvm就会抛出ClassCastException。

双亲委托机制的优缺点

类的命名空间 小节我们得知,即使是相同的二进制名字的类如果其定义加载器不同,那么其也算是不同的两个类。这种机制有如下好处:

  • 可以保证java核心类库的安全,即保证由引导类加载器加载的类不能被用户随便替换,用户不能自己随便定义一个二进制名也为 java.lang.String 的类来替换java核心类库的java.lang.String类,否则会抛出ClassCastException。

  • 使得一个类的不同版本可以共存在jvm中,带来了极大的灵活性,OSGi技术的实现就是得益于此。

而根据一个类的定义加载器是这个类中引用的其它类的初始加载器可知,java核心类库中定义的类是不能使用系统类加载器定义的类。而java提供了很多服务提供者接口(Service Provider Interface SPI),许可第三方来实现这些类的接口。第三方开发的类通常是由应用类加载器在类路径下(classpath)来找到并且定义的。引导类加载器是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库。它也不能代理给系统类加载器,因为它是系统类加载器的祖先类加载器。也就是说,类加载器的双亲委托模型无法解决这个问题,这是双亲委托模型的缺点。

线程上下文类加载器

线程上下文类加载器就是用来解决类的双亲委托模型的缺点的。如java核心类库中的javax.xml.parsers.DocumentBuilderFactory类就是java提供的一个服务提供者接口,其newInstance()方法用来创建此接口的一个实现类,并返回该实现类。如果仅仅依赖类的双亲委托模型,这就不可能完成的任务。通过查看源码得知newInstance方法中会调用java.util.ServiceLoader.load(Class<s> service)方法,来获取DocumentBuilderFactory类的实现类,而java.util.ServiceLoader.load(Class<s> service)方法正是通过线程上下文类加载器来完成对DocumentBuilderFactory类的实现类的加载工作的。而在默认情况下线程上下文类加载器就是应用类加载器。由应用类加载器负责加classpath中的javax.xml.parsers.DocumentBuilderFactory类的实现类,这样newInstance接口就能正常返回一个实现类。

总结

java类加载器是java语言的一个创新,它使得动态安装和更新软件组件成为可能。而java类加载器又是用户编写应用代码和JVM虚拟机实现的交界处。只有先搞懂java类加载器才能为搞懂JVM迈出坚实的一步。如果有不懂或者发现作者错误处,欢迎积极留言,作者保证有问必答!