深入理解Java虚拟机学习之三虚拟机类加载机制

写在前边的话


最近在看《深入理解Java虚拟机》这本书,学习Java虚拟机底层实现原理。通过写博客的方式记录自己的学习过程以及对知识的理解。如有总结不正确的地方,欢迎大家指出!


上一篇《深入理解Java虚拟机学习之二class文件内容详解》介绍了class文件结构。相信许多童靴已经对class文件有了详细的了解。那么我们都知道只有这个class文件被虚拟机加载到内存中,class文件中的内容才能得到执行。那么虚拟机何时会去加载class文件?以及class文件被加载的过程中又会做什么处理呢?虚拟机的类加载机制又是怎么样的呢?
相信大家看了这篇文章,上边的问题就会迎刃而解啦!

本篇文章包含下边几部分的内容:
1、类加载机制
2、类的加载时机
3、类的加载过程
4、类加载器

一、概述

首先我们来了解一下什么是虚拟机的类加载机制。
虚拟机把描述类信息的class文件从磁盘或者网络加载到内存,并对class文件中的数据进行验证,转换解析和初始化,最终形成可以被虚拟机直接使用的Java数据类型,这就是虚拟机的类加载机制。

在Java语言中,类的加载,链接,初始化过程都是在程序运行期间进行的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

二、类的加载时机

在了解类的加载时机之前,我们先来了解一下类从被虚拟机加载到内存到被虚拟机从内存中卸载,它的生命周期包括:
加载,验证,准备,解析,初始化,使用,卸载7个阶段。其中验证,准备,解析3个部分统称为连接阶段。这7个阶段的发生顺序如图:
在这里插入图片描述
其中,加载,验证,准备,初始化和卸载这5个阶段的顺序是确定,类的加载过程必须按照这中顺序按部就班的开始。而解析阶段可能在初始化之前也可能在初始化之后。而这里是按部就班的开始,也就是说这几个阶段也不是顺序执行,有可能是穿插进行的。在某个阶段执行的过程中,会调用或者激活另一个阶段。

这部分讲的是类的加载时机,其实类的加载时机是不确定的,能够确定的是类时何时进行初始化,那么在初始化阶段开始前加载,验证,准备,解析阶段的工作已经完成了。
虚拟机规定了有且只有下边的5种情况必须立即对类进行初始化工作。

1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。

例如:在类A中的某个方法中需要访问类B的静态方法,虚拟机就会先检查这个类是否已经被加载到内存并是否已经初始化完成,如果已经加载并初始化完成则直接使用,如果没有则立即初始化类B,自然类B的加载,验证,准备,解析工作都会在类B初始化前完成。

2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发类的初始化

例如:在类A的某个方法中使用java.lang.reflect包下的反射技术获取类B的方法,那么虚拟机就会立即对类B进行初始化,自然类B的加载,验证,准备,解析工作都会在类B初始化前完成。

3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

例如:在初始化类B的时候发现其父类A还没有进行初始化,那么就会立即对类A进行初始化。所以,拥有父类的子类初始化的过程为:父类的加载,验证,准备,初始化,子类的加载,验证,准备,初始化。

4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的那个类)虚拟机会优先初始化这个类。

public class ViewClassFile {
    private static int count = 9;
    public static void main(String[] args) {
        System.out.println(count);
    }
}

上边这个类中,包含了一个main方法,所以虚拟机启动后,会优先初始化这个类,从编译后的class文件中,也可以看到有初始化方法
在这里插入图片描述
5)当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

注意点:虚拟机当且仅当遇到这5种情况时才会对类进行初始化,自然类的加载,验证,准备都是在初始化之前进行的。而且,别的情况都不会触发类的初始化。

以上5中情况称为对类的主动引用。其余的对类的引用情况都属于被动引用。被动引用的例子:

1)通过子类引用父类的静态字段,不会导致子类初始化

public class SuperClass {
  static {
    System.out.println("SuperClass init!");
  }
    public static int value = 123;
}

public class SubClass extends SuperClass{
    static {
        System.out.println("SubClass init!");
    }
}

public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SuperClass.value);
    }
}

上述代码运行结果:

SuperClass init!
123

从运行结果来看,对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

2)通过数组定义来引用类,不会触发此类的初始化

public class NotInitialization {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];
    }
}

这段代码并没有触发类SuperClass的初始化阶段。但是这段代码里面触发了另外一个名为"[LSuperClass"的类的初始化阶段,对于用户代码来说,这并不是一个合法的类名称,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令newarray触发。

这个类代表了一个元素类型为SuperClass的一维数组,数组中应有的属性和方法(用户可直接使用的只有被修饰为public的length属性和clone()方法)都实现在这个类里。

3)常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

public class ConstValue {
  static {
    System.out.println("ConstValue init!");
  }
    public static final int VALUE = 123;
}

public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(ConstValue.VALUE);
    }
}

上诉代码的运行结果:

123

上述代码运行之后,也没有输出"ConstValue init!",这是因为虽然在Java源码中引用了ConstValue类中的常量VALUE,但其实在编译阶段通过常量传播优化,已经将此常量的值"123"存储到了NotInitialization类的常量池中,以后NotInitialization对常量ConstClass.VALUE的引用实际都被转化为NotInitialization类对自身常量池的引用了。也就是说,实际上NotInitialization的Class文件之中并没有ConstValue类的符号引用入口,这两个类在编译成class文件后就再也没有关系了。
在这里插入图片描述
通过jclasslib工具查看NotInitialization类的class文件也可以看到main方法的字节码是直接将常量值123压入操作数栈顶,所以输出时直接输出常量值,并不会去初始化ConstValue这个类

三、类加载过程

接下来我们详细讲解一下Java虚拟机中类加载的全过程,也就是加载、验证、准备、解析和初始化这5个阶段所执行的具体动作。

1. 加载

加载阶段虚拟机会做下边的三个工作:

  • 根据类的全限定名,将类的二进制文件加载到内存中
  • 将字节流所代表的静态存储数据结构转化为方法区的运行时数据结构
  • 在内存中创建一个java.lang.Class对象实例,作为方法区访问这个类的入口

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。

加载阶段没有完成时,连接阶段的工作也会开始做,但他们的开始顺序是确定的。

2. 验证

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载子系统中又占了相当大的一部分。

从整体上来看,验证阶段的主要工作为:文件格式验证,元数据验证,字节码验证,符号解析验证。

* 文件格式验证

这部分验证主要是检查字节流是否符合class文件格式规范,并且能被当前版本的虚拟机处理。主要包括的验证点:
是否以魔数0xCAFEBABE开头。
主、次版本号是否在当前虚拟机处理范围之内。
常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。
Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。

实际上验证的信息不止这些,这只是其中的一部分。该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。

* 元数据验证

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,这个阶段可能包括的验证点如下:
这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。

第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。

* 字节码验证

第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:

保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中。
保证跳转指令不会跳转到方法体以外的字节码指令上。
保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。

如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。即使字节码验证之中进行了大量的检查,也不能保证这一点。

由于数据流验证的高复杂性,虚拟机设计团队为了避免过多的时间消耗在字节码验证阶段,在JDK1.6之后的Javac编译器和Java虚拟机中进行了一项优化,给方法体的Code属性的属性表中增加了一项名为"StackMapTable"的属性,这项属性描述了方法体中所有的基本块(BasicBlock,按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,就不需要根据程序推导这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可。这样将字节码验证的类型推导转变为类型检查从而节省一些时间。

在JDK1.6的HotSpot虚拟机中提供了XX:UseSplitVerifier选项来关闭这项优化,或者使用参数XX:+FailOverToOldVerifier要求在类型校验失败的时候退回到旧的类型推导方式进行校验。而在JDK1.7之后,对于主版本号大于50的Class文件,使用类型检查来完成数据流分析校验则是唯一的选择,不允许再退回到类型推导的校验方式。

  • 符号引用验证
    最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验下列内容:

符号引用中通过字符串描述的全限定名是否能找到对应的类。
在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。

符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

对于虚拟机的类加载机制来说,验证阶段是一个非常重要的、但不是一定必要(因为对程序运行期没有影响)的阶段。如果所运行的全部代码(包括自己编写的及第三方包中的代码)都已经被反复使用和验证过,那么在实施阶段就可以考虑使用Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

3. 准备

为类变量分配内存空间并为其设置默认初始值的阶段。这些变量所使用的内存都将在方法区中进行分配。如果变量前边加了final修饰,则此变量相当于一个常量,会在准备阶段设置程序中指定的初始值。各个数据类类型的系统默认初始值如图:
在这里插入图片描述
如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,假设上面类变量value的定义变为:public static final int value=123;编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:public static int value=123;
那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。

4. 解析

解析阶段的主要工作就是将常量池中的符号引用转化为直接引用。

  • 符号引用:用一组符号来描述引用目标,符号可以是任何形式的字面量,只要能定位的引用目标即可。引用的目标可以是已经加载到内存中的也可以没有被加载到内存中的。
    直接引用:可以是指向引用目标的指针,也可以是相对偏移量或者是一个能间接定位到目标的句柄。如果有了直接引用那么引用目标必定是已经存在内存中了。

虚拟机规范之中并未规定解析阶段发生的具体时间,只要求了在执行anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield和putstatic这16个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进
行解析。所以虚拟机实现可以根据需要来判断到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。

对同一个符号引用进行多次解析请求时很常见的事情,虚拟机实现可能会对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标示为已解析状态),从而避免解析动作重复进行。

解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info四种常量类型。

1.类或者接口的解析步骤

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要以下3个步骤:

1)如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就宣告失败。
2)如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似"[Ljava/lang/Integer"的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是"java.lang.Integer",接着由虚拟机生成一个代表此数组维度和元素的数组对象。
3)如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。

2.字段解析

首先来写一个简单的例子

public interface InterfaceA {
    int value = 1234;
}
public interface InterfaceB extends InterfaceA{
    int value = 12345;
}
public class SuperClass {
    static {
       System.out.println("SuperClass init!");
    }

    public int value = 123;
}
public class SubClass extends SuperClass implements InterfaceB{
    static {
        System.out.println("SubClass init!");
    }

    public int value = 213;
}
public class ViewClassFile {
    public static void main(String[] args) {
        SubClass subClass = new SubClass();
        System.out.println(subClass.value);
    }
}

用jclasslib工具打开ViewClassFile.class文件,查看class文件中的方法表中的main方法的code属性,如图:
在这里插入图片描述
上边有描述当遇到getfield字节码指令的时候会去进行解析工作。那我们的例子中有如下字节码指令:
getfield #5

该字节码指令的含义是:获取实例对象的成员变量值,然后将值压入操作数栈的栈顶。后边的#5是getfield指令的参数,具体是将哪个对象实例的哪个成员变量值。
#5指的是常量池中的第5项,经查看常量池中的第五项是CONSTANT_Fieldref_info类型的常量,那虚拟机就会去解析这个常量。具体的解析步骤如下:
在这里插入图片描述
下边我们来运行一下我们的demo,看是否如上述步骤一致:
case1:子类中含有指定成员变量,输出的是子类的成员变量

SuperClass init!
SubClass init!
213

case2:子类中没有指定成员变量,但是子类实现了接口,接口中有指定成员变量

将子类中public int value = 213;这行注释掉,执行结果如下:

java: 对value的引用不明确
  SuperClass 中的变量 value 和 InterfaceB 中的变量 value 都匹配

此时会报错,这是因为父类和接口中都有符合条件的变量,或者在自己或父类的多个接口中都出现符合条件的变量,这在虚拟机中是不允许的,会爆出上边的错误

因此需要将父类的public int value = 123;这行也注释掉,这样运行结果如下:

SuperClass init!
SubClass init!
12345

case3:父类中含有符合条件的成员变量

SuperClass init!
SubClass init!
123

总结:字段解析就是根据常量池中的CONSTANT_Fieldref_info常量项的class_index项查找到字段所属类或接口,然后再根据CONSTANT_Fieldref_info常量项中的字段的简单名称和描述符去字段所属类或接口中查找,如果找到则解析成功,如果找不到则解析失败。

3、类方法解析

对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。

4、接口方法解析

与类方法解析步骤类似,只是接口不会有父类,因此,只递归向上搜索父接口就行了。

由于接口中的所有方法默认都是public的,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出java.lang.IllegalAccessError异常。

5. 初始化

初始化是类加载过程的最后一步。类的整个加载过程除了可以自定义类加载器加载指定的类之外,其余的验证,准备,解析阶段都是虚拟机主导和控制。到了类初始化阶段才是真正要开始执行类中定义的Java程序了。

在虚拟机层面,初始化阶段其实就是执行类构造器()方法。
关于类构造器()方法的几点说明:

  • 这个方法是编译器自动收集类中所有类变量或者静态语句以及静态语句块儿内语句合并产生的。编译器收集的顺序与源文件中定义的顺序一致。静态语句块儿中只能访问到定义在静态语句块儿之前的变量,不能访问定义在静态语句块儿后的变量。在访问静态变量之前可以给未声明的静态变量赋值不能访问为声明的静态变量。
  • <clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
    由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作
  • <clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

下面给出一个简单的例子,以便更清晰地说明如上规则:


class SuperClass{
	public static int a = 1;
	static{
		a = 2;
	}
}
 
class SubClass extends SuperClass{
	public static int b = a;
}
 
public class Test{
	public static void main(String[] args){
		System.out.println(SubClass.b);
	}

执行上面的代码,会打印出2,也就是说b的值被赋为了2。
我们来看得到该结果的步骤。首先在准备阶段为类变量分配内存并设置类变量初始值,这样A和B均被赋值为默认值0,而后再在调用()方法时给他们赋予程序中指定的值。当我们调用Child.b时,触发Child的()方法,根据规则2,在此之前,要先执行完其父类Father的()方法,又根据规则1,在执行()方法时,需要按static语句或static变量赋值操作等在代码中出现的顺序来执行相关的static语句,因此当触发执行Father的()方法时,会先将a赋值为1,再执行static语句块中语句,将a赋值为2,而后再执行Child类的()方法,这样便会将b的赋值为2.
如果我们颠倒一下Father类中“public static int a = 1;”语句和“static语句块”的顺序,程序执行后,则会打印出1。很明显是根据规则1,执行Father的()方法时,根据顺序先执行了static语句块中的内容,后执行了“public static int a = 1;”语句。
另外,在颠倒二者的顺序之后,如果在static语句块中对a进行访问(比如将a赋给某个变量),在编译时将会报错,因为根据规则1,它只能对a进行赋值,而不能访问。

至此,类的整个加载过程以及各个阶段的工作内容就已经介绍完了。在这个过程中,只有在加载阶段,程序员可以自定义类加载器加载指定的类外,其余的验证,准备,解析阶段都是由虚拟机自动完成,并没有开始执行我们在源文件中的程序,到初始化阶段,才会真正开始执行源文件中的代码。初始化阶段也是在对类的静态成员进行初始化。静态成员包括静态变量,静态代码块儿。他们的执行顺序是跟源文件中定义的顺序保持一致。

类加载过程中主要是将Class文件(准确地讲,应该是类的二进制字节流)加载到虚拟机内存中,真正执行字节码的操作,在加载完成后才真正开始。

四 类加载器

类加载器的主要工作就是通过一个类的全限定描述符获取描述这个类的二进制字节流。

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

  • 类加载器的分类

    • 从虚拟机角度分
      启动类加载器:这个加载器是由C++实现,存在于虚拟机内部。
      其他的类加载器:是由Java实现,独立于虚拟机外部,全部继承自抽象类java.lang.ClassLoader。
    • 从开发人员角度分
      从开发人员角度又可以将类加载器进行细分。
      启动类加载器(BootstrapClassLoader):这个类将器负责将存放在<JAVA_HOME>\lib目录中的,或者被Xbootclasspath参数所指定的路径中的,并且是虚拟机识别(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。
      启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,那直接使用null代替即可。
      扩展类加载器(ExtensionClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
      应用程序类加载器(ApplicationClassLoader):也叫系统类加载器,因为他是ClassLoader类getSystemClassLoader()方法的返回值。这个类加载器由
      sun.misc.Launcher.AppClassLoder实现。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
  • 双亲委派模型

虚拟机的类加载机制默认采用的是双亲委派模型。
类加载器之间的上下层级关系被称为双亲委派模型。
双亲委派模型要求除了顶级启动类加载器,其余的加载器都必须有自己的父类加载器。类加载器之间的父子关系不会以继承的关系来实现,而是以组合的关系来复用父加载器的代码。

双亲委派模型的工作流程:
如果一个类加载器收到了加载请求,它首先不会自己尝试去加载,而是把这个请求委派给自己的父类加载器去完成,每一个层次的类加载器都是如此。因此所有的类加载请求最终都会委派给根类加载器也就是启动类加载器。只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

双亲委派模型的好处:

双亲委派模型保证了Java虚拟机系统的稳定性。比如,java.lang.Object类被不同的类加载器进行加载,那么在虚拟机中会存在多个Object类实例,破坏了唯一性(一个类在虚拟机中的唯一性是由类本身和加载该类的类加载器确定的)。

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。如果读者有兴趣的话,可以尝试去编写一个与rt.jar类库中已有类重名的Java类,将会发现可以正常编译,但永远无法被加载运行。

双亲委派模型的实现:

先检查是否已经被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值