深入理解Java虚拟机学习笔记(三)-----类文件结构/虚拟机类加载机制

时间:2022-02-24 09:57:56

第6章 类文件结构

1. 无关性

  各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(即扩展名为 .class 的文件) 是构成平台无关性的基石。

  字节码(即扩展名为 .class 的文件)不面向任何特定的处理器,只面向虚拟机。

  实现语言无关性的基础仍是虚拟机和字节码存储格式。Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联。Class文件包含了java虚拟机指令集和符号表以及若干其他辅助信息。任一门功能性语言都可以表示为能被Java虚拟机所接受的有效的Class文件,虚拟机并不关心Class的来源是何种语言。

2. Class类文件的结构

  任何一个Class文件都对应着唯一一个类或接口的定义信息,但类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。

  Class文件是一组以8字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分割符。 当遇到需要占用8字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储。

  Class文件格式采用类似于C语言结构体的伪结构体来存储数据,这种伪结构中只有两种数据类型:无符号数和表。

  • 无符号数:属于基本的数据类型,以u1、u2、u3、u4、u8分别代表1个字节、2个字节、3个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值、或者按照UTF-8编码构成字符串值。
  • :表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表习惯以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。

  无论是无符号数还是表,当需要描述同一个类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式。(这时称一系列连续的某一类型的数据为某一类型的集合)

  Class文件字节码结构组织示意图:

深入理解Java虚拟机学习笔记(三)-----类文件结构/虚拟机类加载机制

(1) 魔数与Class文件的版本

  每个Class文件的头4个字节称为魔数(magic number),它的唯一作用是确认这个文件是否为一个能够被虚拟机接受的Class文件,Class文件的魔数值为:0XCAFEBABY。

  紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节时次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。

  高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致。

(2)常量池      

   紧接着主次版本号之后的是常量池。常量池的数量是 constant_pool_count-1(常量池计数器是从1开始计数的,将第0项常量空出来是有特殊考虑的,索引值为0代表“不引用任何一个常量池项”)。

   常量池主要存放两大常量:字面量符号引用。字面量比较接近于 Java 语言层面的的常量概念,如文本字符串、声明为 final 的常量值等。而符号引用则属于编译原理方面的概念。包括下面三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

  Java代码在进行Javac编译的时候,并没有C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存之中

  常量池中每一项常量都是一个表,JDK1.7后共有14种结构各不相同的表结构,这14种表有一个共同的特点:开始的第一位是一个 u1 类型的标志位(-tag),代表当前这个常量属于哪种常量类型

类型 标志(tag) 描述
CONSTANT_utf8_info 1 UTF-8编码的字符串
CONSTANT_Integer_info 3 整形字面量
CONSTANT_Float_info 4 浮点型字面量
CONSTANT_Long_info 长整型字面量
CONSTANT_Double_info 双精度浮点型字面量
CONSTANT_Class_info 类或接口的符号引用
CONSTANT_String_info 字符串类型字面量
CONSTANT_Fieldref_info 字段的符号引用
CONSTANT_Methodref_info 10 类中方法的符号引用
CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
CONSTANT_NameAndType_info 12 字段或方法的符号引用
CONSTANT_MothodType_info 16 标志方法类型
CONSTANT_MethodHandle_info 15 表示方法句柄
CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点

 (3)访问标志      

   在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口,是否为 public 或者 abstract 类型,如果是类的话是否声明为 final 等等。

深入理解Java虚拟机学习笔记(三)-----类文件结构/虚拟机类加载机制

【注】.class 文件可以通过javap -v class类名 指令来看一下其常量池中的信息(javap -v class类名-> temp.txt :将结果输出到 temp.txt 文件)。

   通过javap -v class类名 指令来看一下类的访问标志:

   深入理解Java虚拟机学习笔记(三)-----类文件结构/虚拟机类加载机制

4)当前类索引,父类索引与接口索引集合

u2             this_class;//当前类
u2             super_class;//父类
u2             interfaces_count;//接口
u2             interfaces[interfaces_count];//一个类可以实现多个接口
  • 类索引用于确定这个类的全限定名。
  • 父类索引用于确定这个类的父类的全限定名。由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0
  • 接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按implents(如果这个类本身是接口的话则是extends) 后的接口顺序从左到右排列在接口索引集合中

(5)字段表集合    

  字段表(field info)用于描述接口或类中声明的变量字段包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量

  字段表(field info) 的结构:

    深入理解Java虚拟机学习笔记(三)-----类文件结构/虚拟机类加载机制

  • access_flags:字段的作用域(publicprivateprotected修饰符),是实例变量还是类变量(static修饰符),可否被序列化(transient 修饰符),可变性(final),可见性(volatile 修饰符,是否强制从主内存读写)。
  • name_index:对常量池的引用,表示的字段的名称;
  • descriptor_index:对常量池的引用,表示字段和方法的描述符;
  • attributes_count: 一个字段还会拥有一些额外的属性,attributes_count 存放属性的个数;
  • attributes[attributes_count]:存放具体属性具体内容。

  上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型这些都是无法固定的,只能引用常量池中常量来描述。

   字段的acces_flags取值(字段访问标志):

深入理解Java虚拟机学习笔记(三)-----类文件结构/虚拟机类加载机制

(6)方法表集合    

   Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。

  method_info(方法表的) 结构:

     深入理解Java虚拟机学习笔记(三)-----类文件结构/虚拟机类加载机制

  因为volatile修饰符和transient修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronizednativeabstract等关键字修饰方法,所以也就多了这些关键字对应的标志。

 (7)属性表集合  

  在 Class 文件,字段表、方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息Java 虚拟机运行时会忽略掉它不认识的属性

 第七章 虚拟机类加载机制

   虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

  在Java语言里,类型的加载、连接和初始化过程都是在程序运行期间完成的。Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。例如,如果编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类。

1. 类加载的过程

  类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载验证准备解析初始化使用卸载7个阶段。其中验证、准备、解析3个部分统称为连接。(系统加载 Class 类型的文件主要三步:加载->连接->初始化。)

  其中加载、验证、准备、初始化和卸载这个五个阶段的顺序是固定的,而解析则未必。为了支持动态绑定,解析这个过程可以发生在初始化阶段之后

深入理解Java虚拟机学习笔记(三)-----类文件结构/虚拟机类加载机制

(1)加载  

   “加载”是“类加载”过程的一个阶段(是类加载的第一步),注意不要混淆这两个名词。

  在加载阶段,虚拟机需要完成以下3件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

   虚拟机规范多上面这3点并不具体,因此是非常灵活的。比如:"通过全类名获取定义此类的二进制字节流" 并没有指明具体从哪里获取、怎样获取。比如:比较常见的就是从 ZIP 包中读取(日后出现的JAR、EAR、WAR格式的基础)、其他文件生成(典型应用就是JSP)等等虚拟机规范多上面这3点并不具体,因此是非常灵活的。比如:"通过全类名获取定义此类的二进制字节流" 并没有指明具体从哪里获取、怎样获取。比如:比较常见的就是从 ZIP 包中读取(日后出现的JAR、EAR、WAR格式的基础)、其他文件生成(典型应用就是JSP)等等

   相对于类加载过程的其他阶段,一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass()方法)

  数组类型不通过类加载器创建,它由 Java 虚拟机直接创建。但数组类与类加载器仍有很密切的关系,因为数组类的元素类型最终是要靠类加载器去创建,一个数组类创建过程遵循以下规则:

  • 如果数组的组件类型是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组类将在加载该组件类型的类加载器的类名称空间上被标识。(一个类必须与类加载器一起确定唯一性)
  • 如果数组的组件类型不是引用类型(如int[]数组),Java虚拟机将会把数组C标记为与引导类加载器关联。
  • 数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public。

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

  加载阶段和连接阶段的部分内容是交叉进行的(如一部分字节码文件格式验证动作),加载阶段尚未结束,连接阶段可能就已经开始了

(2)验证  

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

  从整体上看,验证阶段大致上会完成下面4个阶段的校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证

深入理解Java虚拟机学习笔记(三)-----类文件结构/虚拟机类加载机制

文件格式验证这一阶段验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理包括以下验证点:

  • 是否以魔数0xCAFEBABE开头。
  • 主、次版本号是否在当前虚拟机处理范围之内。
  • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
  • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
  • ......
  • 【注】:文件格式验证这一验证阶段是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面3个验证阶段(元数据验证、字节码验证、符号引用验证)全部是基于方法区的存储结构进行的,不会再直接操作字节流

元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,这个阶段可能包括的验证点如下:

  • 这个类是否有父类(除了java.lang.Object外,所有的类都应当有父类)。
  • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
  • 类中的字段、方法是否与父类产生矛盾(加入覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法餐宿都一致,但返回值类型却不同等)。
  • ......

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

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

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

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

(3)准备

   准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

  【注】:

  1. 这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中
  2. 这里所设置的初始值"通常情况"下是数据类型默认的零值(如0、0L、null、false等),比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是111把value赋值为111的动作在初始化阶段才会执行)。特殊情况:比如给 value 变量加上了 fianl 关键字public static final int value=111 ,那么准备阶段 value 的值就被复制为 111

 基本数据类型的零值:

深入理解Java虚拟机学习笔记(三)-----类文件结构/虚拟机类加载机制

(4)解析

   解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程

  • 符号引用
    • 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
    • 符号引用与虚拟机实现的内存布局无关引用的目标并不一定已经加载到内存中各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
  • 直接引用
    • 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄
    • 直接引用是和虚拟机实现的内存布局相关的同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同如果有了直接引用,那引用的目标必定已经在内存中存在

   在程序实际运行时,只有符号引用是不够的,举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方发表中的偏移量就可以直接调用该方法了通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用

  综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量

 (5)初始化

   类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才开始真正执行类中定义的Java程序代码(或者说是字节码)

  在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的专管计划去初始化类变量和其他资源。 初始化阶段是执行类构造器<clinit>()方法的过程

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
    • 静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以复赋值,但是不能访问。如:
    • public class Test {
          static {
              i = 0;              //给变量赋值可以正常编译通过
              System.out.print(i);//这句编译器会提示“非法向前引用”
          }
          static int i = 1;  //定义在静态代码块之后的变量(前面的静态语句可以赋值,但不能访问)
      }
  • <clinit>()方法与类的构造函数不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕
  • 由于父类的<clinit>()方法先执行,也就意味着父类定义的静态语句块要优先于子类的变量赋值操作。例如下面代码字段B的值将是2而不是1.
    • static class Parent {
          public static int A = 1;
          static {
             A = 2;
          }
      }
      
      static class Sub extends Parent {
          public static int B = A;
      }
      
      public static void main(String[] args) {
          System.out.print(Sub.B);  //输出2而不是1
      }
  • <clinit>()方法对于类或接口来说并不是必需的如果一个类中没有静态代码块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法
  • 接口中不能使用静态语句块,但仍有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量使用时,父接口才会初始化。 另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞。
    • 因为 <clinit>()方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起死锁,并且这种死锁很难被发现。
    • 【注】:其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出<clinit>()方法后,其他线程唤醒之后不会再次进入<clinit>()方法。同一个类加载器下,一个类只会初始化一次。

 

  对于初始化阶段,虚拟机严格规范了有且只有5种情况下,必须对类进行初始化:

  1. 当遇到 new 、getstatic、putstatic或invokestatic 这4条直接码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用时 ,如果类没初始化,需要触发其初始化。
  3. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
  4. 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
  5. 当使用 JDK1.7 的动态动态语言时,如果一个 MethodHandle 实例的最后解析结构为 REF_getStatic、REF_putStatic、REF_invokeStatic、的方法句柄,并且这个句柄没有初始化,则需要先触发器初始化。
  1. 为一个类型创建一个新的对象实例时(比如new、反射、序列化)
  2. 调用一个类型的静态方法时(即在字节码中执行invokestatic指令)
  3. 调用一个类型或接口的静态字段,或者对这些静态字段执行赋值操作时(即在字节码中,执行getstatic或者putstatic指令),不过用final修饰的静态字段除外,它被初始化为一个编译时常量表达式
  4. 调用JavaAPI中的反射方法时(比如调用java.lang.Class中的方法,或者java.lang.reflect包中其他类的方法)
  5. 初始化一个类的派生类时(Java虚拟机规范明确要求初始化一个类时,它的超类必须提前完成初始化操作,接口例外)
  6. JVM启动包含main方法的启动类时。

2. 类加载器

2.1 类与类加载器

  通过一个类的全限定名来获取描述此类的二进制字节流,实现这个动作的代码模块称为“类加载器”。

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

  这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。

示例:

public class ClassLoaderTest {
    public static void main(String[] args) {

        ClassLoader myLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try{
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream is =  getClasss().getResourceAsStream(fileName);
                    if(is == null) {
                        return super.loadClass(name);
                    }
                    byte[] b = new bytr[is.available()];
                    is.read(b);
                    return defineClass(name, b, 0, b.length);
                }catch(IOException e){
                    throw new ClassNotFoundException(name);
                }
            }
        };
        Object obj = myLoader.loadClass("org.fenixsoft.classloading.ClassLoaderTest").newInstance();
        System.out.println(obj.getClass());
        System.out.println(obj instanceof org.fenixsoft.classloading.ClassLoaderTest);
    }
}

运行结果:
org.fenixsoft.classloading.ClassLoaderTest
false

  上面构造了一个简单的类加载器(myLoader),它可以加载与自己同一路径下的Class文件。我们使用这个类加载器去加载了一个名为“org.fenixsoft.classloading.ClassLoaderTest”的类,并实例化了这个类的对象。两行输出结果中,从第一句可以看出,这个对象确实是类org.fenixsoft.classloading.ClassLoaderTest实例化出来的对象,但从第二句可以发现,这个对象与类org.fenixsoft.classloading.ClassLoaderTest做所属类型检查的时候却返回了false,这是因为虚拟机中存在了两个ClassLoaderTest类:一个是由系统应用程序类加载器加载的,另一个是由我们自定义的类加载器加载的。虽然都来自同一个Class文件,但依然是两个独立的类。

2.2  双亲委派模型

  JVM 中内置了三个重要的类加载器(ClassLoader),除了启动类加载器(BootstrapClassLoader),其他类加载器均由Java 实现且全部继承自java.lang.ClassLoader

  • 启动类加载器(Bootstrap ClassLoader):最顶层的加载类,由c++实现,负责加载%JAVA_HOME%/lib/ext目录下的jar包和类 或者-Xbootclasspath参数指定的路径中的所有类。启动类加载器无法被java程序直接调用。
  • 扩展类加载器(Extension ClassLoader)主要负责加载%JRE_HOME%/lib/ext目录下的jar包和类,或者被java.ext.dirs系统变量所指定的路径下的jar包
  • 应用程序类加载器(Application ClassLoader):面向我们用户的加载器,负责加载当前应用classpath下的所有jar包和类如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

  每一个类都有一个对应它的类加载器。系统中的类加载器在协同工作的时候会默认使用双亲委派模型。双亲委派模型要求除了的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。

  双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,因此所有的加载请求最终都应该传送到顶层的启动类加载器(Bootstrap ClassLoader)中。只有当父加载器无法处理(它的搜索范围中没有找到所需的类)时,子下载器才会尝试自己去加载。 当父类加载器为null时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。

深入理解Java虚拟机学习笔记(三)-----类文件结构/虚拟机类加载机制

  每个类加载都有一个父类加载器,我们通过下面的程序来验证:

public class ClassLoaderDemo {
    public static void main(String[] args) {
        System.out.println("ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader());
        System.out.println("The Parent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent());
        System.out.println("The GrandParent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent().getParent());
    }
}

输出:
ClassLodarDemo's ClassLoader is sun.misc.Launcher$AppClassLoader@18b4aac2 The Parent of ClassLodarDemo's ClassLoader is sun.misc.Launcher$ExtClassLoader@1b6d3586 The GrandParent of ClassLodarDemo's ClassLoader is null

【注】:Application ClassLoader的父类加载器为Extension ClassLoaderExtension ClassLoader的父类加载器为null。null并不代表Extension ClassLoader没有父类加载器,而是 Bootstrap ClassLoader 。

 

双亲委派模型实现源码分析

  双亲委派模型的实现代码都集中在 java.lang.ClassLoader 的 loadClass() 中,相关代码如下所示:

 

private final ClassLoader parent; 
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先,检查请求的类是否已经被加载过
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {//若父加载器不为空,调用父加载器的loadClass()方法处理
                        c = parent.loadClass(name, false);
                    } else {//若父加载器为空,使用启动类加载器 BootstrapClassLoader加载
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                   //抛出异常说明父类加载器无法完成加载请求
                }
                
                if (c == null) {
                    //在父类加载器无法加载的时候,在调用本身的findClass方法来进行加载
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

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

 

双亲委派模型的好处?

  双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果不用没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。

如果我们不想用双亲委派模型怎么办?

  为了避免双亲委托机制,我们可以自己定义一个类加载器,然后重载 loadClass() 即可。

自定义类加载器

  除了 BootstrapClassLoader,其他类加载器均由Java实现且全部继承自java.lang.ClassLoader。如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader

 

参考:https://github.com/Snailclimb/JavaGuide/blob/master/docs/java/jvm/%E7%B1%BB%E5%8A%A0%E8%BD%BD%E5%99%A8.md