【JAVA】深入理解虚拟机之虚拟机类加载机制
Java程序运行时,数据会分区存放,JavaStack(Java栈)、 heap(堆)、method(方法区)。
1、Java栈
Java栈的区域很小,只有1M,特点是存取速度很快,所以在stack中存放的都是快速执行的任务,基本数据类型的数据,和对象的引用(reference)。
驻留于常规RAM(随机访问存储器)区域。但可通过它的“栈指针”获取处理的直接支持。栈指针若向下移,会创建新的内存;若向上移,则会释放那些内存。这是一种特别快、特别有效的数据保存方式,仅次于寄存器。创建程序时,Java编译器必须准确地知道堆栈内保存的所有数据的“长度”以及“存在时间”。这是由于它必须生成相应的代码,以便向上和向下移动指针。这一限制无疑影响了程序的灵活性,所以尽管有些Java数据要保存在栈里——特别是对象句柄,但Java对象并不放到其中。
JVM只会直接对JavaStack(Java栈)执行两种操作:①以帧为单位的压栈或出栈;②通过-Xss来设置, 若不够会抛出StackOverflowError异常。
1.每个线程包含一个栈区,栈中只保存基本数据类型的数据和自定义对象的引用(不是对象),对象都存放在堆区中
2.每个栈中的数据(原始类型和对象引用)都是私有的,其他栈不能访问。
3.栈分为3个部分:基本数据类型的变量区、执行环境上下文、操作指令区(存放操作指令)。
栈是存放线程调用方法时存储局部变量表,操作,方法出口等与方法执行相关的信息,Java栈所占内存的大小由Xss来调节,方法调用层次太多会撑爆这个区域。
2、程序计数器(ProgramCounter)寄存器
PC寄存器( PC register ):每个线程启动的时候,都会创建一个PC(Program Counter,程序计数器)寄存器。PC寄存器里保存有当前正在执行的JVM指令的地址。 每一个线程都有它自己的PC寄存器,也是该线程启动时创建的。保存下一条将要执行的指令地址的寄存器是 :PC寄存器。PC寄存器的内容总是指向下一条将被执行指令的地址,这里的地址可以是一个本地指针,也可以是在方法区中相对应于该方法起始指令的偏移量。
3、本地方法栈
Nativemethodstack(本地方法栈):保存native方法进入区域的地址。
4、堆
类的对象放在heap(堆)中,所有的类对象都是通过new方法创建,创建后,在stack(栈)会创建类对象的引用(内存地址)。
一种常规用途的内存池(也在RAM(随机存取存储器 )区域),其中保存了Java对象。和栈不同:“内存堆”或“堆”最吸引人的地方在于编译器不必知道要从堆里分配多少存储空间,也不必知道存储的数据要在堆里停留多长的时间。因此,用堆保存数据时会得到更大的灵活性。要求创建一个对象时,只需用new命令编辑相应的代码即可。执行这些代码时,会在堆里自动进行数据的保存。当然,为达到这种灵活性,必然会付出一定的代价:在堆里分配存储空间时会花掉更长的时间。
JVM将所有对象的实例(即用new创建的对象)(对应于对象的引用(引用就是内存地址))的内存都分配在堆上,堆所占内存的大小由-Xmx指令和-Xms指令来调节,sample如下所示:
public class HeapOOM {
static class OOMObject{}
/**
* @param args
*/
public static void main(String[] args) {
List list = new ArrayList();// List类和ArrayList类都是集合类,
// 但是ArrayList可以理解为顺序表,
// 属于线性表。
while (true) {
list.add(new OOMObject());
}
}
}
加上JVM参数-verbose:gc -Xms10M -Xmx10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+HeapDumpOnOutOfMemoryError,就能很快报出OOM:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
并且能自动生成Dump。
5、方法区
method(方法区)又叫静态区,存放所有的①类(class),②静态变量(static变量),③静态方法,④常量和⑤成员方法。
1.又叫静态区,跟堆一样,被所有的线程共享。
2.方法区中存放的都是在整个程序中永远唯一的元素。这也是方法区被所有的线程共享的原因。
(顺便展开静态变量和常量的区别: 静态变量本质是变量,是整个类所有对象共享的一个变量,其值一旦改变对这个类的所有对象都有影响;常量一旦赋值后不能修改其引用,其中基本数据类型的常量不能修改其值。)
Java里面是没有静态变量这个概念的,不信你自己在某个成员方法里面定义一个static int i = 0;Java里只有静态成员变量。它属于类的属性。至于他放哪里?楼上说的是静态区。我不知道到底有没有这个翻译。但是深入JVM里是翻译为方法区的。虚拟机的体系结构:①Java栈,② 堆,③PC寄存器,④方法区,⑤本地方法栈,⑥运行常量池。而方法区保存的就是一个类的模板,堆是放类的实例(即对象)的。栈是一般来用来函数计算的。随便找本计算机底层的书都知道了。栈里的数据,函数执行完就不会存储了。这就是为什么局部变量每一次都是一样的。就算给他加一后,下次执行函数的时候还是原来的样子。
方法区的大小由-XX:PermSize和-XX:MaxPermSize来调节,类太多有可能撑爆永久代。静态变量或常量也有可能撑爆方法区。
6、运行常量池
这儿的“静态”是指“位于固定位置”。程序运行期间,静态存储的数据将随时等候调用。可用static关键字指出一个对象的特定元素是静态的。但Java对象本身永远都不会置入静态存储空间。
这个区域属于方法区。该区域存放类和接口的常量,除此之外,它还存放成员变量和成员方法的所有引用。当一个成员变量或者成员方法被引用的时候,JVM就通过运行常量池中的这些引用来查找成员变量和成员方法在内存中的的实际地址。
7、举例分析
例子如下:
为了更清楚地搞明白程序运行时,数据区里的情况,我们来准备2个小道具(2个非常简单的小程序)。
// AppMain.java
public class AppMain { //运行时,JVM把AppMain的信息都放入方法区
public static void main(String[] args) { //main成员方法本身放入方法区。
Sample test1 = new Sample( " 测试1 " ); //test1是引用,所以放到栈区里,Sample是自定义对象应该放到堆里面
Sample test2 = new Sample( " 测试2 " );
test1.printName();
test2.printName();
}
}
// Sample.java
public class Sample { //运行时,JVM把appmain的信息都放入方法区。
private name; //new Sample实例后,name引用放入栈区里,name对象放入堆里。
public Sample(String name) {
this .name = name;
}
public void printName() {// printName()成员方法本身放入方法区里。
System.out.println(name);
}
}
OK,让我们开始行动吧,出发指令就是:“java AppMain”,包包里带好我们的行动向导图。
屏幕快照 2018-05-14 下午7.12.19.png系统收到了我们发出的指令,启动了一个Java虚拟机进程,这个进程首先从classpath中找到AppMain.class文件,读取这个文件中的二进制数据,然后把Appmain类的类信息存放到运行时数据区的方法区中。这一过程称为AppMain类的加载过程。
接着,JVM定位到方法区中AppMain类的Main()方法的字节码,开始执行它的指令。这个main()方法的第一条语句就是:
Sample test1 = new Sample("测试1");
语句很简单啦,就是让JVM创建一个Sample实例,并且呢,使引用变量test1引用这个实例。貌似小case一桩哦,就让我们来跟踪一下JVM,看看它究竟是怎么来执行这个任务的:
1、Java虚拟机一看,不就是建立一个Sample类的实例吗,简单,于是就直奔方法区(方法区存放已经加载的类的相关信息,如类、静态变量和常量)而去,先找到Sample类的类型信息再说。结果呢,嘿嘿,没找到@@,这会儿的方法区里还没有Sample类呢(即Sample类的类信息还没有进入方法区中)。可JVM也不是一根筋的笨蛋,于是,它发扬“自己动手,丰衣足食”的作风,立马加载了Sample类, 把Sample类的相关信息存放在了方法区中。
2、Sample类的相关信息加载完成后。Java虚拟机做的第一件事情就是在堆中为一个新的Sample类的实例分配内存,这个Sample类的实例持有着指向方法区的Sample类的类型信息的引用(Java中引用就是内存地址)。这里所说的引用,实际上指的是Sample类的类型信息在方法区中的内存地址,其实,就是有点类似于C语言里的指针啦~~,而这个地址呢,就存放了在Sample类的实例的数据区中。
3、在JVM中的一个进程中,每个线程都会拥有一个方法调用栈,用来跟踪线程运行中一系列的方法调用过程,栈中的每一个元素被称为栈帧,每当线程调用一个方法的时候就会向方法栈中压入一个新栈帧。这里的帧用来存储方法的参数、局部变量和运算过程中的临时数据。OK,原理讲完了,就让我们来继续我们的跟踪行动!位于“=”前的test1是一个在main()方法中定义的变量,可见,它是一个局部变量,因此,test1这个局部变量会被JVM添加到执行main()方法的主线程的Java方法调用栈中。而“=”将把这个test1变量指向堆区中的Sample实例,也就是说,test1这个局部变量持有指向Sample类的实例的引用(即内存地址)。
OK,到这里为止呢,JVM就完成了这个简单语句的执行任务。参考我们的行动向导图,我们终于初步摸清了JVM的一点点底细了,COOL!
接下来,JVM将继续执行后续指令,在堆区里继续创建另一个Sample类的实例,然后依次执行它们的printName()方法。当JVM执行test1.printName()方法时,JVM根据局部变量test1持有的引用,定位到堆中的Sample类的实例,再根据Sample类的实例持有的引用,定位到方法区中Sample类的类型信息(包括①类,②静态变量,③静态方法,④常量和⑤成员方法),从而获取printName()成员方法的字节码,接着执行printName()成员方法包含的指令。
虚拟机栈
栈区:栈中分配的是基本类型和自定义对象的引用。
每个线程包含一个栈区,栈中只保存基础数据类型和自定义对象的引用(不是对象),对象都存放在堆区中
每个栈中的数据(原始类型和对象引用)都是私有的,其他栈不能访问。
栈分为3个部分:基本类型变量区、执行环境上下文、操作指令区(存放操作指令)。
栈是存放线程调用方法时存储局部变量表,操作,方法出口等与方法执行相关的信息,栈大小由Xss来调节,方法调用层次太多会撑爆这个区域。
栈溢出一般只会出现无限循环的递归中,另外,线程太多也会占满栈区域
栈帧: 一个完整的栈帧包含:局部变量表(基本数据类型变量),操作数栈,动态连接信息,方法完成和异常完成信息。
局部变量表概念和特征:
由若干个Slot组成,长度由编译期决定。
单个Slot可以存储一个类型为boolean ,byte,char, short, float, reference和returnAddress的数据,两个Slot可以存储一个类型为long或double的数据。
局部变量表用于方法间参数传递,以及方法执行过程中存储基础数据类型的值和对象的引用。
本地方法栈:
本地方法栈的特征:
线程私有
后进先出栈
作用是支撑Native方法的调用,执行和退出
可能出现OutOfMemoryError异常和StackOverflowError异常
java虚拟机栈和本地方法栈可能发生如下异常情况:
如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量时,Java虚拟机将会抛出一个StackOverflowError异常。
如果Java虚拟机可以动态扩展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError异常。
2)方法区
方法区是存放虚拟机加载类的相关信息,如类、静态变量和常量,大小由-XX:PermSize和-XX:MaxPermSize来调节,类太多有可能撑爆永久带:
public class MethodAreaOOM {
static class OOMOjbect{}
/**
* @param args
*/
public static void main(String[] args) {
// TODO Auto-generated method stub
while(true){
Enhancer eh = new Enhancer();
eh.setSuperclass(OOMOjbect.class);
eh.setUseCache(false);
eh.setCallback(new MethodInterceptor(){
@Override
public Object intercept(Object arg0, Method arg1,
Object[] arg2, MethodProxy arg3) throws Throwable {
// TODO Auto-generated method stub
return arg3.invokeSuper(arg0, arg2);
}
});
eh.create();
}
}
}
加上永久带的JVM参数:-XX:PermSize=10M -XX:MaxPermSize=10M,运行后会报如下异常:
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
静态变量或常量也会有可能撑爆方法区:
public class ConstantOOM {
/**
* @param args
*/
public static void main(String[] args) {
// TODO Auto-generated method stub
List<String> list = new ArrayList<String>();
int i=0;
while(true){
list.add(String.valueOf(i++).intern());
}
}
}
3)Java栈和本地方法栈
简单说说类加载过程,里面执行了哪些操作?
对类加载器有了解吗?
什么是双亲委派模型?
双亲委派模型的工作过程以及使用它的好处。
虚拟机类加载机制的概念
虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化。最终形成可以被虚拟机最直接使用的java类型的过程就是虚拟机的类加载机制。
Java语言的动态加载和动态连接
另外需要注意的很重要的一点是:java语言中类型的加载连接以及初始化过程都是在程序运行期间完成的,这种策略虽然会使类加载时稍微增加一些性能开销,但是会为java应用程序提供高度的灵活性。java里天生就可以动态扩展语言特性就是依赖运行期间动态加载和动态连接这个特点实现的。比如,如果编写一个面向接口的程序,可以等到运行时再指定其具体实现类。
类加载时机
类从被加载到虚拟机内存到卸出内存为止,它的整个生命周期包括:
屏幕快照 2018-05-05 下午7.35.28.png
虚拟机规范严格规定了有且只有五种情况必须立即对类进行“初始化”:
使用new关键字实例化对象的时候、读取或设置一个类的静态字段的时候,已经调用一个类的静态方法的时候。
使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有初始化,则需要先触发其初始化。
当初始化一个类的时候,如果发现其父类没有被初始化就会先初始化它的父类。
当虚拟机启动的时候,用户需要指定一个要执行的主类(就是包含main()方法的那个类),虚拟机会先初始化这个类;
使用Jdk1.7动态语言支持的时候的一些情况。
而对于接口,当一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口时(如引用父接口中定义的常量)才会初始化。
所有引用类的方式都不会触发初始化称为被动引用,下面是3个被动引用例子:
①通过子类引用父类静态字段,不会导致子类初始化;②通过数组定义引用类,不会触发此类的初始化
public class SuperClass {
static {
System.out.println("SuperClass(父类)被初始化了。。。");
}
public static int value = 66;
}
public class Subclass extends SuperClass {
static {
System.out.println("Subclass(子类)被初始化了。。。");
}
}
public class Test1 {
public static void main(String[] args) {
// 1:通过子类调用父类的静态字段不会导致子类初始化
// System.out.println(Subclass.value);//SuperClass(父类)被初始化了。。。66
// 2:通过数组定义引用类,不会触发此类的初始化
SuperClass[] superClasses = new SuperClass[3];
// 3:通过new 创建对象,可以实现类初始化,必须把1下面的代码注释掉才有效果不然经过1的时候类已经初始化了,下面这条语句也就没用了。
//SuperClass superClass = new SuperClass();
}
}
③常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用定义常量的类,因此不会触发定义常量的类的初始化
public class ConstClass {
static {
System.out.println("ConstClass被初始化了。。。");
}
public static final String HELLO = "hello world";
}
public class Test2 {
public static void main(String[] args) {
System.out.println(ConstClass.HELLO);//输出结果:hello world
}
}
类加载过程
下面我们详细的说一下java虚拟机中类加载的全过程:加载、验证、准备、解析和初始化这5个阶段锁执行的具体工作。
1.加载,“加载” 是 “类加载” 过程的一个阶段,切不可将二者混淆。
加载阶段由三个基本动作组成:
通过类型的完全限定名,产生一个代表该类型的二进制数据流(根本没有指明从哪里获取、怎样获取,可以说一个非常开放的平台了)
解析这个二进制数据流为方法区内的运行时数据结构
创建一个表示该类型的java.lang.Class类的实例,作为方法区这个类的各种数据的访问入口。
通过类型的完全限定名,产生一个代表该类型的二进制数据流的几种常见形式:
从zip包中读取,成为日后JAR、EAR、WAR格式的基础;
从网络中获取,这种场景最典型的应用就是Applet;
运行时计算生成,这种场景最常用的就是动态代理技术了;
由其他文件生成,比如我们的JSP;
注意: 非数组类加载阶段既可以使用系统提供的类加载器来完成,也可以由用户自定义的类加载器去完成。(即重写一个类加载器的loadClass()方法)
2.验证
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
虚拟机如果不检查输入的字节流,并对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。这个阶段是否严谨,直接决定了java虚拟机是否能承受恶意代码的攻击。
从整体上看,验证阶段大致上会完成4个阶段的校验工作:文件格式、元数据、字节码、符号引用。
2.1文件格式验证
验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。这个阶段验证是基于二进制字节流进行的,只有通过这个阶段的验证后,字节流才会进入内存的方法区进行存储,所以后面的3个阶段的全部是基于方法区的存储结构进行的,不会再直接操作字节流。
2.2元数据验证
该阶段对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,目的是保证不存在不符合Java语言规范的元数据信息。
2.3字节码验证
该阶段主要工作时进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。 例如,保证跳转指令不会跳转到方法体以外的字节码指令上、保证方法体中的类型转换是有效的等等。
由于数据流校验的高复杂性,耗时较大,所以JDK1.6之后,在Javac中引入一项优化方法(可以通过参数关闭):在方法体的Code属性的属性表中增加一项“StackMapTable”属性,该属性描述了方法体中所有基本块开始时本地变量表和操作栈应有的状态,从而将字节码验证的类型推导转变为类型检查从而节省一些时间。
注意: 如果一个方法体通过了字节码验证,也不能说明其一定是安全的,因为校验程序逻辑无法做到绝对精确。
2.4符号引用验证
最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三个阶段——解析阶段中发生。符号引用验证的目的是确保解析动作能正常执行。
验证的内容主要有:
符号引用中通过字符串描述的全限定名是否能找到对应的类;
在指定类中是否存在符号方法的字段描述及简单名称所描述的方法和字段;
符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问。
3.准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。(备注:这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中)。
初始值通常是数据类型的零值:
对于:public static int value = 123;,那么变量value在准备阶段过后的初始值为0而不是123,这时候尚未开始执行任何java方法,把value赋值为123的动作将在初始化阶段才会被执行。
一些特殊情况:
对于:public static final int value = 123;编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
-
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
那么符号引用与直接引用有什么关联呢?
4.1 看两者的概念。
符号引用(Symbolic References): 符号引用以一组符号来描述所引用的目标,符号可以是符合约定的任何形式的字面量,符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
直接引用(Direct References): 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用与虚拟机实现的内存布局相关,引用的目标必定已经在内存中存在。
虚拟机规范没有规定解析阶段发生的具体时间,虚拟机实现可以根据需要来判断到底是在类被加载时解析还是等到一个符号引用将要被使用前才去解析。
4.2 对解析结果进行缓存
同一符号引用进行多次解析请求是很常见的,除invokedynamic指令以外,虚拟机实现可以对第一次解析结果进行缓存,来避免解析动作重复进行。无论是否真正执行了多次解析动作,虚拟机需要保证的是在同一个实体中,如果一个引用符号之前已经被成功解析过,那么后续的引用解析请求就应当一直成功;同样的,如果 第一次解析失败,那么其他指令对这个符号的解析请求也应该收到相同的异常。
4.3 解析动作的目标
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。前面四种引用的解析过程,对于后面三种,与JDK1.7新增的动态语言支持息息相关,由于java语言是一门静态类型语言,因此没有介绍invokedynamic指令的语义之前,没有办法将他们和现在的java语言对应上。 -
初始化
类初始化阶段是类加载的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的java程序代码(或者说是字节码)。
类加载器
1.类与类加载器
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。如果两个类来源于同一个Class文件,只要加载它们的类加载器不同,那么这两个类就必定不相等。
2.类加载器介绍
从Java虚拟机的角度分为两种不同的类加载器:启动类加载器(Bootstrap ClassLoader) 和其他类加载器。其中启动类加载器,使用C++语言实现,是虚拟机自身的一部分;其余的类加载器都由Java语言实现,独立于虚拟机之外,并且全都继承自java.lang.ClassLoader类。(这里只限于HotSpot虚拟机)。
从Java开发人员的角度来看,绝大部分Java程序都会使用到以下3种系统提供的类加载器。
启动类加载器(Bootstrap ClassLoader):
这个类加载器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。
扩展类加载器(Extension ClassLoader):
这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
应用程序类加载器(Application ClassLoader):
这个类加载器由sun.misc.Launcher$AppClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
我们的应用程序都是由这3种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。
3.双亲委派模型
双亲委派模型(Pattern Delegation Model),要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器。这里父子关系通常是子类通过组合关系而不是继承关系来复用父加载器的代码。
屏幕快照 2018-05-05 下午8.02.32.png
双亲委派模型的工作过程: 如果一个类加载器收到了类加载的请求,先把这个请求委派给父类加载器去完成(所以所有的加载请求最终都应该传送到顶层的启动类加载器中),只有当父加载器反馈自己无法完成加载请求时,子加载器才会尝试自己去加载。
使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是java类随着它的类加载器一起具备了一种带有优先级的层次关系。
注意:双亲委派模型是Java设计者们推荐给开发者们的一种类加载器实现方式,并不是一个强制性 的约束模型。在java的世界中大部分的类加载器都遵循这个模型,但也有例外。
4.破坏双亲委派模型
双亲委派模型主要出现过3次较大规模“被破坏”的情况。
第一次破坏是因为类加载器和抽象类java.lang.ClassLoader在JDK1.0就存在的,而双亲委派模型在JDK1.2之后才被引入,为了兼容已经存在的用户自定义类加载器,引入双亲委派模型时做了一定的妥协:在java.lang.ClassLoader中引入了一个findClass()方法,在此之前,用户去继承java.lang.Classloader的唯一目的就是重写loadClass()方法。JDK1.2之后不提倡用户去覆盖loadClass()方法,而是把自己的类加载逻辑写到findClass()方法中,如果loadClass()方法中如果父类加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型规则的。
第二次破坏是因为模型自身的缺陷,现实中存在这样的场景:基础的类加载器需要求调用用户的代码,而基础的类加载器可能不认识用户的代码。为此,Java设计团队引入的设计时“线程上下文类加载器(Thread Context ClassLoader)”。这样可以通过父类加载器请求子类加载器去完成类加载动作。已经违背了双亲委派模型的一般性原则。
第三次破坏 是由于用户对程序动态性的追求导致的。这里所说的动态性是指:“代码热替换”、“模块热部署”等等比较热门的词。说白了就是希望应用程序能够像我们的计算机外设一样,接上鼠标、U盘不用重启机器就能立即使用。OSGi是当前业界“事实上”的Java模块化标准,OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现。每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构。