上节学习回顾
上一节,我们深入到类文件去了解其结构细节,也大概对类文件的编写规则略知一二了,解析来我们就得学习这个类文件是如何被加载到Java虚拟机的,看看有什么引人入胜的奥秘。
本节学习重点
大部分计算机类科生都应该有接触过C语言,C语言的编译过程会有预处理、编译、汇编、链接四个步骤,经过了这四个步骤就生成了可以执行文件(二进制机器码)。Java同样如此,
有Java使用经验的伙伴都应该知道,类加载有加载、连接、初始化三个阶段,这就是虚拟机类加载的三大步骤,只有经过了这三大步骤才能在Java虚拟机上跑。唯一不同的是类文件是在程序运行期间执行的,而我们本章学习的重点就是深入这三大步骤,去发掘每一步骤的详细操作和原理。
概述
在Java语言里面,类的加载、连接、初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。
类加载时机
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期如下图所示:
如上图,加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。这一点会在“解析”阶段深入学习并说明。
在Java虚拟机规范中并没有进行强制约束,至于什么时候开始实行类加载的“加载”阶段,这都就由虚拟机的具体实现来决定,但虚拟机规范严格规定了有且只有5种情况必须对类进行“初始化”,当然初始化前的三个阶段(加载、验证、准备)就必须在此之前开始执行了。关于这5种必须初始化的场景如下:
1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有初始化,则需要先触发其初始化;这4条指令对应的的常见场景分别是:使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
注:静态内容是跟类关联的而不是类的对象。
2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
注:反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为java语言的反射机制,这相对好理解为什么需要初始化类。
3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
注:子类执行构造函数前需先执行父类构造函数。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
注:main方法是程序的执行入口
5)当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化。则需要先触发其初始化。
注:JDK1.7的一种新增的反射机制,都是对类的一种动态操作。
在虚拟机规范中使用了一个很强烈的限定语:“有且仅有”,这5种场景中的行为称为对类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。看看以下三个例子:
演示一:通过子类引用父类的静态字段,不会导致子类初始化
通过运行以上代码可以看到,此测试代码只会输出“SuperClass init!”而不会输出“SubClass init!”。对于静态字段,只有直接定义这个字段的类才会被初始化。
演示二:通过数组定义来引用类,不会触发此类的初始化
通过运行以上代码,发现没有任何输出,说明并没有触发SuperClass的初始化。但是这段代码触发了另一个名为“[SuperClass”的类初始化阶段,对于用户代码来说,这并不是一个合法的类名称,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令newarray触发。这个类代表了一个元素类型为SuperClass的一维数组,数组中应该有的属性和方法(如length属性和clone()方法)都实现在这个类里。
演示三:常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
运行以上代码后并没有输出“SuperClass init!”,这是因为虽然在Java源码中引用了SuperClass类中的常量value,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello world!”存储到Test类的常量池中,以后Test类对常量SuperClass.value的引用实际上都转化为Test类对自身常量池的引用了。也就是说,实际上Test的class文件之中并没有SuperClass类的符号引用入口,这两个类在编译成Class之后就不存在任何联系了。
类加载的过程:加载
“加载”和“类加载”是不同概念的,“加载”是“类加载”过程的一个阶段,最好不要被字眼迷惑。在加载阶段,虚拟机需要完成以下3件事情:
“加载”和“类加载”是不同概念的,“加载”是“类加载”过程的一个阶段,最好不要被字眼迷惑。在加载阶段,虚拟机需要完成以下3件事情:
1)通过一个类的全限定名来获取此类的二进制字节流;
注:虚拟机规范并没有明确说明类的二进制字节流从何而来,所以这里可以有非常灵活的实现空间,例如可以用过ZIP包(如JAR、EAR、WAR格式)读取,从网络中获取,运行时计算生成(如ASM框架),从数据库中读取等等。例如我常用的一个Websphere中间件跟tomcat中间件的类加载器就有所不同。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
注:回顾本书“2.2.5方法区”章节介绍:“方法区域Java堆一样,是各线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据”。而方法区中的数据存储结构格式虚拟机自行定义。
3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
注:加载阶段完成后,虚拟机在内存中实例化一个java.lang.Class类的对象(Class是一个实实在在的对象,是记录着类成员、接口等信息的对象)。还有一点是,我们都知道对象肯定是存放在堆中的,但Class对象比较特殊,对于HotSpot虚拟机而言,Class对象是存放在方法区中的。
非数组类和数据类的加载阶段有有所不同,从以上“被动引用例子3”我们就知道,数组类的应用是不会对该类进行初始化,而是虚拟机通过字节码指令“newarray”去创建一个“[Object”对象。“初始化阶段”是在“加载阶段”之后,但不代表该类不会被加载。接下来,看看数组类加载过程要遵循的规则:
1)如果数组的组件类型是引用类型(非基础类型),那就递归去加载这个组件类型(本章后续学习笔记会学习到类与类加载器的相关知识)。
2)如果数组组件类型不是引用类型(例如int[]数组),Java虚拟机将会把该数组标记为与引导类加载器关联。
3)数组类的可见性与他的组件类型可见性一致,如果组件类型不是引用类型,那数组的可见性将默认为public。
加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序,也就是必须先加载才能验证。
类加载的过程:验证
加载阶段可以说是字节码进入Java虚拟机的入口,换个角度去想,如果我们的API接受外来请求数据时,我应该要做些什么样的事情,那当然是对入参数据的验证了。没做,类加载过程的“验证阶段”同样是对类文件的字节码进行验证,才能确保Java虚拟机不受恶意代码的攻击。从性能上讲,这无疑是给虚拟机带来额外的性能消耗,但这也是无可厚非要付出的代价。一开始《Java虚拟机规范(第2版)》对这个阶段的限制、指导还是比较笼统而粗糙的,直到《Java虚拟机规范(Java SE 7版)》才大幅细化了验证过程的篇幅。从整体上看,验证阶段大致上会完成下面4个阶段的检查动作:
1)验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。细节如下:
l 是否以魔数0xCAFEBABE开头;
l 主、次版本号是否在当前虚拟机处理范围内;
l 常量池的常量中是否有不被支持的常量类型(检查常量tag标志);
l 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量;
l CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据
l Class文件中各个部分及文件是否有被删除的或附加的其他信息;
l ……
2)元数据验证,保证不存在不符合Java语言规范的元数据信息
l 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类);
l 这个类的父类是否继承了不允许被继承的类(被final修饰的类);
l 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法;
l 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
l ……
3)字节码验证,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件
l 保证任意时刻操作栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个int类型的数据,使用时却按long类型来加载如本地变量中;
l 保证跳转指令不会跳转到方法体以外的字节码指令上;
l 保证方法体中的类型转换是有效的;
l ……
“字节码验证”是整个验证阶段最消耗时间的,虽然如此但也不能保证绝对安全。
4)符号引用验证,确保在后续的“解析”阶段能正常执行
l 符号引用中通过字符串描述的全限定名是否能找到对应的类;
l 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段;
l 在符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问;
l ……
其实我们的IDE也虚拟机规范的检查,所以我们的代码加载几乎没有不通过的。
类加载的过程:准备
准备阶段是正式为类变量分配内存设置类变量初始化值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下。首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:
public static int value = 123;
那变量value在准备阶段过后的初始化值为0而不是123,因为这是尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后存放在类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。以下表格列出了所有基本数据类型的零值:
数据类型 |
零值 |
int |
0 |
long |
0L |
short |
(short)0 |
char |
‘u0000’ |
byte |
(byte)0 |
boolean |
false |
float |
0.0f |
double |
0.0d |
reference |
null |
上面提到的在“通常情况”下初始值为零值,但还是会有一些特殊情况,如下:
public static final int value = 123;
类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化微ConstantValue属性所指定的值。编译时Javac将会为vaue生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
类加载的过程:解析
解析阶段是虚拟机将常量池的符号引用直接替换为直接引用的过程,看看前一章节的常量池例子:
看看截图红色框框的就是常量池的符号引用,再来解释一下常量引用和符号引用的区别:
l 符号引用(Symbolic References):
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
l 直接引用(Direct References):
直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接点位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
就拿以上截图的红色框框的例子来举例吧,框住的常量池语意大概是常量池中的第三个常量为类或接口的符号引用,这个符号的值为第四个常量池的值,也就是“java/lang/Object;”这是我们熟知的Object类的全限定名。解析阶段就是要把这个“class”的字符引用换成直接指向这个Object类在内存中的地址(如指针 )。那就说明,这个Object类必须同时也需要加载到内存中来。
对同一个符号引用进行多次解析请求是很常见的事情,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)从而避免解析动作重复进行。但对于invokedynamic指令,上面规则则不成立。当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对其他invokedynamic指令也同样生效。因为invokedynamic指令是JDK1.7新加入的指令,目的用于动态语言支持,它所对应的引用称为“动态调用点限定符”(Dynamic Call Site Specifier),这里“动态”的含义就是必须等到程序实际运行到这条指令的时候,解析动作才能进行。相对的,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有执行代码时就进行解析。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号进行引用,下面只对前4种引用的解析过程进行介绍,对于后面3种与JDK1.7新增的动态语言支持息息相关,后续章节将会学习到:
类或接口的解析:
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用M解析为 一个类或接口C的直接引用,那虚拟机完成整个解析过程需要一个3个步骤:
1)如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现接口。一旦这个加载过程出现了任何异常,解析过程宣布失败。
2)如果C是一个数组类型,并且数组的元素类型是对象,也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述如前面所假设的形式,需要加载的元素类型就是“Java.lang.Integer”,接着有虚拟机生成一个代表此数组维度和元素的数组对象:“[Ljava/lang/Integer”(数组引用可回顾上文“类加载时机-被动引用演示二”)。
3)如果上述步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。
字段解析:
要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。看看以下例子可能会更明白:
我们对javap工具打印出Test.class的常量池看看:
行解析(也就是上图的#14),那首先对t2字段所属的Class进行解析,也就是#15的Test2。如果我们在解析这个Test2类都失败的话,那么对Test的字段t2解析同样失败。如果解析Test2成功了那么以上截图红色框框部分就是Test对Test2.t2字段的符号引用。如果我们要对Test2.t2字段进我们将这个这段所属的类或接口用C(也就是以上例子的Test2)表示。虚拟机规范要求按照如下步骤对C进行后续字段的搜索:
1)如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
2)否则,如果在C中实现了接口,将会安装继承关系从上往下递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标匹配的字段,则返回这个字段的直接引用,查找结束。
3)否则,如果C不是java.lang.Object的话,将会按照继承关系从上往下递归搜索其父类,如果在父类中包含了简单名称和字段描述都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
4)否则,查找失败,抛出java.lang.NoSuchFieldError异常。
类方法解析:
类方法解析的第一个步骤与字段解析一样,也需要解析出类方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功我们依然用C标识这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索:
1)类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,那直接抛出java.lang.IncompatibleClassChangeError异常。
2)如果通过了第1步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
3)否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
4)否则,在类C实现的接口列表及它们的父类接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配方法,说明类C是一个抽象类,这是查找结束,抛出java.lang.AbstractMethodError异常。
5)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
接口方法解析:
接口方法也需要先解析出接口方法表的class_index项中索引的方法属性的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索。
1)与类方法解析不同,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
2)否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
3)否则,在接口C的父接口中递归查找,知道java.lang.Object类(查找范围会包Object类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
4)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
类加载的过程:初始化
类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器之外,其余动作完全是由虚拟机主导和控制。在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划其余初始化变量和其他资源,或者从另一个角度来表达,初始化阶段是执行类构造器<clinit>()方法的过程:
■<clinit>()方法是有编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是有语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问,如下例子所示:
变量声明前赋值例子
变量声明前访问例子
■<clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕;
■由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作;
■<clinit>()方法对于类和接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法;
■接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
■虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞。
注:我通过javap工具把class文件反编译但找不到<clinit>()方法,只看到<init>()方法,我暂且偷懒通过网上资料得到比较靠谱的答案是“因为这个特殊初始化方法是不能被Java代码调用的,没有任何一条invoke-*字节码可以调用它。它只能作为类加载过程的一部分由JVM直接调用”,具体实现可以参考JVM源码。
类加载器
提到Java虚拟机加载器,肯定会联想到它的双亲委派机制,具体如下图所示(因为懒得画所以就网上借了个图):
双亲委派机制图
先来大概的解释一下各个加载器的情况:
■启动类加载器(Bootstrap ClassLoader):这个类加载器负责将<JAVA_HOME>lib目录中的,或被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(如rt.jar)类库加载到虚拟机内存中。
Bootstrap ClassLoader是JVM系统级别的类加载器,应用是无法使用的,例如Object类是由这个类加载器加载的,我们尝试去打印Object类的类加载器,得到结果如下:
这就是JVM为了保护Bootstrap ClassLoader所做的限制。
■扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现的,它负责加载<JAVA_HOME>libext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器,如下示例:
图(1)是我的ClassLoaderTest类在classpath下打印的结果:
图(1)
图(2)是我的ClassLoaderTest类打包放在<JAVA_HOME>/lib/ext目录下的打印结果:
图(2)
从以上实验可以看出,Extension ClassLoader确实可以被用户利用。
■应用程序类加载器(Application ClassLoader):从上面的测试可以看到,这个类加载器由sun.misc.Launcher$AppClassLoader实现,由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义自己的类加载器,一般情况下这个就是程序中默认的类加载器。
■自定义类加载器(User ClassLoader):所有自定义的类加载器必须继承ClassLoader抽象类(严格说所有类加载器都继承于它,除了Bootstrap ClassLoader,因它是由C/C++实现的),那先来看看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类的实例。这个方法被声明为 final的。 |
resolveClass(Class<?> c) |
链接指定的 Java 类,调用的是本地方法。 |
除了以上ClassLoader抽象类的一些主要方法介绍,在自己写自定义类加载器前还是非常有必要讲解一下类加载器的“双亲委派机制”。就如以上的“双亲委派机制图”所示,它的工作过程是这样的:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载器请求最终都是应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需要的类)时,子加载器才会尝试自己去加载。如ClassLoader类的loadClass方法所示:
以上ClassLoader的loadClass方法的实现就是“双亲委派机制”的原型。除了“双亲委派机制”外,我们还需要知道一点的是:对于任意一个类,都需要由它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。也就是说,同一个class文件,由不同的加载器去加载,都不相等。所以“双亲委派机制”有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,例如java.lang.Object,它存放于rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类去加载,因此Object类在程序的各种类加载器环境中都是同一个类。除此之外,这种设计模式的其它优缺点需要各自脑补了。当然,这种设计默认并不是必须的(后面会提到)。学习以上的知识,那么就可以自定义动手写一个属于自己的类加载器了,如下所示:
1 public class MyClassLoader extends ClassLoader{
2
3 public MyClassLoader(){}
4
5 public MyClassLoader(ClassLoader parent){
6 super(parent);
7 }
8
9 @Override
10 protected Class<?> findClass(String name) throws ClassNotFoundException{
11
12 byte[] bytes = null;
13
14 /*获取类字节,自定义类我默认在G盘*/
15 FileInputStream fis = null;
16 try {
17 fis = new FileInputStream("G:\"+name+".class");
18 ByteArrayOutputStream baos = new ByteArrayOutputStream();
19 byte[] buff = new byte[100];
20 int rc = 0;
21 while ((rc = fis.read(buff, 0, 100)) > 0) {
22 baos.write(buff, 0, rc);
23 }
24 bytes = baos.toByteArray();
25
26 } catch (Exception e) {
27 e.printStackTrace();
28
29 }finally{
30 if(fis != null){
31 try {
32 fis.close();
33 } catch (IOException e) {
34 e.printStackTrace();
35 }
36 }
37 }
38
39 /*生产Class对象*/
40 try{
41 Class<?> c = this.defineClass(name, bytes, 0, bytes.length);
42 return c;
43 }catch (Exception e){
44 e.printStackTrace();
45 }
46
47 return super.findClass(name);
48 }
49
50 public static void main(String[] args) throws Exception{
51 MyClassLoader mlc = new MyClassLoader(MyClassLoader.getSystemClassLoader());
52 Class c = mlc.loadClass("MyClassLoaderTest");
53 System.out.println(c.newInstance().toString());
54 }
55
56 }
自定义一个类加载器的原因有很多,例如应用需要加载不在ClassPath路径下的类(重写findClass方法),又或者不同插件容器需要不同加载器加载同一个类文件(重写loadClass方法)等等。就像以上例子,我自定义了一个MyClassLoader重写了findClass方法专门去加载我本地G盘的类。其实,只要得到类文件的二进制流(甚至可以通过ASM字节码操作框架动态生成class二进制),就可以初始化类对象,所以无论本地还是远程,都可以通过实现类加载。
总结
类加载确实是Java虚拟机的一大亮点,在本章也学习了类加载器委托、可见性以及单一性原理特性,许多人可能还是会把类加载器跟“双亲委派机制”紧关联甚至画上等号,“双亲委派机制”是一种设计模式(代理模式),这种模式带来的好处显而易见,但是不同场景可能会有不同的场景需求而去破坏这种设计模式,例如许多WEB容器都有自己的类加载器,如Tomcat,它的自定义加载器首先会尝试自己加载应用的类文件再交给父类加载器尝试加载,这一点已经打破了双亲委派模型,但有些WEB容器又有自己的自定义规则,例如Websphere,所有本章重点在于理解类加载器原理,才能更好的掌控“格局”。