前言
如果跟同事谈“双亲委派”,难免显得很八股了,但是这个“双亲委派”却是JVM在类加载环节必不可少的一个操作,充分的理解它,能够使我们更加良好的理解JVM在加载类的时候背后细节。不仅如此,在学习了解一些其他的技术,例如:SPI、OSGI等等,也能相辅相成,融会贯通,可谓“两仪生四象,四象生八卦”。
类加载器概述
在《深入理解JAVA虚拟机》中有这么一段话,“Java虚拟机设计团队有意将类加载阶段中,通过一个类的全限定名来获取描述该类的二进制字节流。这个动作放到java虚拟机外部去实现,以便于让应用程序自己决定如何去获取所需的类,实现这个动作的代码称作为类加载器(Class Loader)”
类加载器分类
- BootstrapClassLoader[启动类加载器]:主要负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class或Xbootclassoath选项指定的jar包,也就是Java的核心的类库(java.lang.*等),需要注意的是,它底层由C++实现,所以他并不是ClassLoader子类。
- ExtensionClassLoader[扩展类加载器]:主要负责加载扩展功能的一些jar包,包括%JRE_HOME%\lib\ext目录下的jar包和class文件以及-Djava.ext.dirs指定目录下的jar包。
- AppClassLoader[应用类加载器]: 主要负责加载应用的classpath下的类以及-Djava.class.path所指定目录下的类。
- CustomClassLoader[自定义类加载器]: 主要负责用户通过实现java.lang.ClassLoader的子类自定义加载class,属于应用程序根据自身需要加载指定路径的class等。
为什么有这么多的类加载器?
其实在jdk1.2之前是只有一个类加载器的,也就是现在的启动类加载器(BootstrapClassLoader),但是jdk1.2以后引入了这么多类加载器,肯定是为了解决什么样的问题?那这个问题是什么呢?先卖个关子,直接上代码。
package java.lang;
/**
* 此类是自定义的java.lang.String
*
* @author Duansg
* @date 2022-11-02 1:07 上午
*/
public class String {
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
/**
* Constructor
*
* @param original
*/
public String(String original) {
this.value = ("篡改"+ original).value;
this.hash = original.hash;
}
}
可以看到,这是我自定义的java.lang.String类,全限定名也是一样的,因为Java语言本身并没有阻止这种行为。所以,该类可以访问和改变java.lang包下其他类的默认访问修饰符的属性和方法的能力。但问题是,我们其他的类使用java.lang.String时也会调用这个类,那么如何判定到底加载哪个呢?
我们从开发者角度可以设想一下,如何解决此类问题呢?
比如我们可以将Java核心的API中的java.lang.String设置一个version=1的版本标识,并且全局不能有两个全限定名相同且版本相同的类,那么我自定义的java.lang.String只能是version>1,在加载类的时候,先加载version=1的核心类,其后在加载verion>1的,至于version还可以在细分。
所以JVM在类加载的时候,也是通过不同的类加载器,对Class的加载信任级别进行了层级的划分,信任级别最高的当然是核心的类库,然后就是依赖的扩展类,其次就是本地路径下的应用类,只不过JVM的类加载机制更为复杂,这里只是引导如果去理解多层级的类加载器存在的意义与区别。
类加载器的特性
在理解了BootstrapClassLoader[启动类加载器]、ExtensionClassLoader[扩展类加载器]、AppClassLoader[应用类加载器]、CustomClassLoader[自定义类加载器]的定义以后,我们还需要理解它们都有哪些重要的特性与功能,总结就是以下三点。
- 缓存机制:此机制会保证加载过的class在内存中缓存一份,每当程序中需要使用某个class的时候,类加载器会首先从缓存中寻找class,只有当缓存中不存在此class的时候,系统才会读取该类其对应的二进制数据,并将其转换成class对象,存入缓存。所以说,为什么我们在修改了class后,就必须重启JVM实例的原因,因为只有这样,对应的修改才会生效,而对于一个类加载器来说,相同全限定名的类只加载一次,也就是说相同全限定名的类,它的loadClass方法永远不会被重复调用。
- 全盘负责:此机制是指,每当类加载器加载某个class时,该class所依赖、引用的其他class也由该类加载器负责加载,除非你通过硬编码的方式,显示使用另外一个类加载器来进行加载。需要注意的是,这里的全盘负责,只是代表了当前的类加载器是其加载的入口类,它没有真正的去通过字节码文件生成class对象,其真正实现加载的类的机制,是由"双亲委派"机制完成的。
- 双亲委派:此机制是指,子类加载器如果没有加载过该类,就先委托父类加载器加载该类,只有在父类加载器找不到字节码文件的情况下才从自己的类路径中查找并加载该类,所以它也叫父类委托。
双亲委派又是何物?
"双亲委派"是父类委派的别名,为什么这么命名,我认为是有点"高大上的感觉",从传统意义上来讲,双亲不应该是父母亲么?扯远了。那什么是双亲委派呢?用山东大白话来讲就是:当JVM加载类的时候,负责加载这个类的类加载器收到了请求,这时它不会直接去加载指定的类,而是把这个加载请求"委派"给自己的父类加载器去加载。如果父类加载器加载器无法加载这个类的时候,才会由当前这个加载器来负责类的加载,如下图。
据上图,用文字来描述可拆分为以下几个步骤:
- 负责加载此类的入口ClassLoader加载器(AppClassLoader)先判断该Class是否已加载,如果已加载,则返回Class对象,如果没有则委托给父类加载器。
- 父类加载器则判断是否加载过该Class,如果已加载,则返回Class对象,如果没有则继续委托给祖父类加载器。
- 按此上步骤类推,直到始祖类加载器(启动类加载器)。
- 始祖类加载器判断是否加载过该Class,如果已加载,则返回Class对象。如果没有则尝试从其对应的类路径下寻找Class字节码文件并载入,如果载入成功,则返回Class对象。如果载入失败,则委托给始祖类加载器的子类加载器(扩展类加载器)。
- 始祖类加载器的子类加载器(扩展类加载器)尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象。如果载入失败,则委托给始祖类加载器的孙类加载器(应用类加载器)。
- 类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象。如果载入失败,则委托给始祖类加载器的孙类加载器(应用类加载器)。
- 按此上步骤类推,直到负责加载此类的入口ClassLoader加载器(AppClassLoader),如果载入失败,则抛出ClassNotFoundException
小结
在JVM装载(Load)阶段,其中的通过类的全限定名获取其定义的二进制字节流,需要依赖类装载器完成,顾名思义,类加载器就是用来装载Class文件的。它主要是负责读取Java字节文件,并转换成对应的java.lang.Class 类的一个实例的代码的功能模块。类加载器除了用于加载类外,还可用于确定类在Java虚拟机中的唯一性,它依赖双亲委派机制能够保证在多加载器加载某个类时,最终都是某一个加载器加载,确保最终加载结果的相同。