JVM类加载机制

553 阅读34分钟

前言

本文主要介绍java类加载的流程,以及类加载器(classloader)的原理和作用。笔者将结合《深入理解java虚拟机》以及网上的博客进行归纳整理。本文即是一篇博客,也是一份个人笔记。

编写本文的目的是为了解决笔者的一个困惑,对于同一个类,不同的ClassLoader加载出来的对象是同一个吗?

本文不会讨论java的内存模型概念,若有不当或者错误之处欢迎各位指正。

本文大量的内容节选自《深入理解java虚拟机》一书,若有时间,笔者建议亲自去看一遍。

类文件结构

概念

在开始之前需要区别类(class)与对象(object)

区别:类指的是文件或二进制流(可实现远程类加载),将类加载到虚拟机中,分配内存空间并生成一个与之关联(这种关联可以是直接的引用,也可以间接的通过句柄池关联)的对象。

你创建了一个Peron文件,即为类,然后通过new的方式产生了一个Person,即为对象。

既然要讨论类的加载流程,就要先了解类文件的结构.(以下内容完全参考《深入理解java虚拟机》,并且强烈推荐阅读原书)

结构

不知各位是否想过,为何Java发展经历了十余个大版本、无数小更新,却依然能兼容历史版本?

是因为规范,早在JDK1.2版本的时候,就已经确定了class文件的格式,以后的《虚拟机规范》虽然有变更,但都只新增,未曾对历史的格式进行变更。java这种先定义规范,在进行支持的不仅体现在class文件。比如:JSR-250规范引入的@Resource,@PostConstruct 注解等,虽然java本身并没有实现,但是spring官方提供了支持。

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

无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值

是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视作是一张表,这张表由下图所示的数据项按严格顺序排列构成。

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

没有必要可以去记忆,知道大致无符号数和表的概念即可。

下面逐个介绍类文件的结构。

魔数和文件版本

每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件

不仅是Class文件,很多文件格式标准中都有使用魔数来进行身份识别的习惯,譬如图片格式,如GIF或者JPEG等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全考虑,因为文件扩展名可以随意改动。

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

常量池

常量池可以比喻为Class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一,另外,它还是在Class文件中第一个出现的表类型数据项目。

由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。

常量池中主要存放两大类常量:字面量(Literal)符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。

而符号引用则属于编译原理方面的概念,主要包括下面几类常量:

  • 被模块导出或者开放的包(Package)
  • 类和接口的全限定名(Fully Qualified Name)
  • 字段的名称和描述符(Descriptor)
  • 方法的名称和描述符
  • 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
  • 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)
访问标志

访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final;等等

类索引、父类索引与接口索引集合

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定该类型的继承关系

类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements关键字(如果这个Class文件表示的是一个接口,则应当是extends关键字)后的接口顺序从左到右排列在接口索引集合中。

字段表集合

字段表(field_info)用于描述接口或者类。中声明的变量。Java语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。

字段可以包括的修饰符有字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称。上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫做什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述

扩展两个概念:全限定名简单名称

“org/fenixsoft/clazz/TestClass”是TestClass的全限定名,仅仅是把类全名中的“.”替换成了“/”而已,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”号表示全限定名结束。

简单名称则就是指没有类型和参数修饰的方法或者字段名称,这个类中的inc()方法和m字段的简单名称分别就是“inc”和“m”。

方法表集合

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

这些数据项目的含义也与字段表中的非常类似,仅在访问标志和属性表集合的可选项中有所区别。主要体现在volatile关键字和transient关键字不能修饰方法,与之相对,synchronized、native、strictfp和abstract关键字可以修饰方法。

方法里的Java代码,经过Javac编译器编译成字节码指令之后,存放在方法属性表集合中一个名为“Code”的属性里面,属性表作为Class文件格式中最具扩展性的一种数据项目。

在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名。特征签名是指一个方法中各个参数在常量池中的字段符号引用的集合,也正是因为返回值不会包含在特征签名之中,所以Java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。但是在Class文件格式之中,特征签名的范围明显要更大一些,只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个Class文件中的。

类加载过程

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。与那些在编译时需要进行连接的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为Java应用提供了极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的

例如:java可以等到程序运行时在指定接口的实现类,这种在反射,泛型比较常见。又或是在运行时,从网络或其他途径获取一个二进制流作为程序的一部分。

前文也有说过,class文件是一种基于规范和约定的文件结构,意味着它具有文件的属性,可以通过二进制流的方式传输或加载,因此“Class文件”不应该局限的认为是仅是文件,符合规范的文件流也是可以的。

此外,本文沿用《深入理解java虚拟机》一书中的关于“类型”的描述,在没有特别说的情况下,代表类或接口。

类的加载周期:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段。

image-20210610161343576

这幅图相信各位都十分熟悉了,在各种类加载的博客里面基本都能看到,但很少人会告诉你,这幅图出自于《深入理解java虚拟机》。

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。

《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始)。

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:

    • 使用new关键字实例化对象的时候。

    • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。

    • 调用一个类型的静态方法的时候。

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

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

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

  5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

  6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

对于这六种会触发类型进行初始化的场景,《Java虚拟机规范》中使用了一个非常强烈的限定语——“有且只有”,这六种场景中的行为称为对一个类型进行主动引用

所有引用类型的方式都不会触发初始化,称为被动引用。例如:子类引用父类的静态变量,构建对象数组ClassA[10],直接引用类型中的静态变量等。

可能觉得这些都是概念,没有什么实际使用。真的是这样吗?做一个简单思考题。

public class ClassA extends ClassB {
    static {

        System.out.println("class A static code area");
    }

    {
        System.out.println("class A  code area");
    }

    public ClassA() {
        System.out.println("class A constructor");
    }
}

public class ClassB {

    public final static ClassC c = new ClassC();

    static {

        System.out.println("class B static code area");
    }

    {
        System.out.println("class B  code area");
    }

    public ClassB() {
        System.out.println("class B constructor");
    }
}

public class ClassC {
    static {

        System.out.println("class C static code area");
    }

    {
        System.out.println("class C  code area");
    }

    public ClassC() {
        System.out.println("class C constructor");
    }

}

可以考虑以下两种情况,输出的顺序:

//情况1
ClassA a = new ClassA();


//情况2
ClassC c = ClassA.c;

各位可能在一些面试题中见过类似的,感兴趣的可以自己去尝试一下输出结果。如果ClassA还同时实现了一个接口ClassD,D内部存在一个default方法,又会如何?

如果通过代理的方式去创建ClassA,如:Class.forName(),或者ClassA.class.newInstance()。是否又会不一样?

类加载

标题的“类加载”,是指整个加载流程,并非是其中的“加载”环节。

下文将对单个环节进行解析。

加载

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

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

《Java虚拟机规范》对这三点要求其实并不是特别具体,留给虚拟机实现与Java应用的灵活度都是相当大的。例如“通过一个类的全限定名来获取定义此类的二进制字节流”这条规则,它并没有指明二进制字节流必须得从某个Class文件中获取,确切地说是根本没有指明要从哪里获取、如何获取。比如可以从网络,压缩包,运行时织入等。

《深入理解jvm》中也介绍了java的内存模型,在这三条规则中也有提现内存的分配,读取后的字节流会保存在方法区(注意,方法区不等于永久代,而是为了管理方法区的内存,将分代设计扩展到方法区),随后会在堆中产生一个对象,作为与方法区的关联。

也有特殊情况,对于数组类而言,数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(Element Type,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载

加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证

目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证阶段:

  1. 文件格式的校验 验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这里就要结合前文中的类文件结构来说了,校验的内容部分如下:

    1. 是否以魔数0xCAFEBABE开头。

    2. 主、次版本号是否在当前Java虚拟机接受范围之内。

    3. 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。

    4. 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。

    5. Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。

      .........

  2. 元数据校验 对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求。 主要是语法的校验,包含以下部分:

    1. 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
    2. 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
    3. 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。 ......
  3. 字节码校验 整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。 对类内部的方法体或代码片段进行校验,包含以下部分:

    1. 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况。
    2. 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
    3. 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。 ......
  4. 符号引用验证 校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。

    1. 符号引用中通过字符串描述的全限定名是否能找到对应的类。

    2. 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段

      ......

    验证阶段并非是必要的,如果代码经过反复的校验,确认没有问题,可以通过**-Xverify:none**跳过大部分校验的流程,提高加载速度。

准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。

此时,分配内存的变量仅是“类变量”,而不包括实例变量(即实例对象),实例变量将会在对象实例化时随着对象一起分配在Java堆中。

并且在通常情况下准备阶段的初始化值并非代码中给定的,而是不同类型的默认值,比如int类型的默认0,long类型的默认0L等。

例外情况:

public static final int value = 123;

会被直接初始化为 123。

解析

Java虚拟机将常量池内符号引用替换为直接引用

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。

直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。

在《java虚拟机规范》中没有规定解析发生的时间,只要求在ane-warray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic这17个命令之前。所以虚拟机实现可以根据需要来自行判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。

对方法或者字段的访问,也会在解析阶段中对它们的可访问性(public、protected、private、)进行检查。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和CONSTANT_InvokeDynamic_info 8种常量类型

其中后4种需要动态语言支持,需要理解invokedynamic命令。java本身是一门静态类型语言。但支持动态语法(java的动态代理),因此本文暂时只讲解前4种。

解析分为几个维度:

  1. 类或接口的解析 假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用。

    class D {
      private C N = new C(); //N可能为对象
      private C[] N = new C[]{}; //N也可能为数组
    }
    

    以上代码为其中的一种可能性,这里的符号引用可能标识的是一个对象或者一个对象数组。

    结合类文件结构部分的内容可以知道,数组对于虚拟机来说是一种特殊的存在,加载和解析的过程与类都不太相同。

    • 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。出现错误,会终止解析。

      这里也说明了一个问题,若对象A同时被C、D引用,可能是由不同类加载器加载出来的。

    • 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,那将会按照第一点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表该数组维度和元素的数组对象。

    • 如果上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。

      访问权限不仅仅是常说的修饰符:public、private、protected、default。还包含JDK 9中引入的模块概念

      必须要访问类D与被访问类C在访问限制修饰符规则与模块访问规则都验证通过的情况下才能进行解析。例如:被访问类C是public的,并且与访问类D处于同一个模块。

  2. 字段解析 解析的过程是线性的,若类或接口的解析失败,则不会进行字段解析。此外解析的过程与编码时相似。 先在自身类中查找,若不存在,则尝试去父类(非Object类)进行查找。如果都不存在则会抛出异常。

  3. 方法解析 与字段解析类似,首先要确保类或接口的解析正确。其次从自身向上寻找,找到后确认方法不是抽象的即可。

  4. 接口方法解析 与类的方法解析类似。但接口允许多继承,这个要注意。

初始化

到此之前,都是由虚拟机来主导,主要是为了保障加载的类文件能够正确的被程序执行,之后将交给应用程序。

准备阶段,已经对类内部的属性进行过一次值的初始化,不过这时赋予的都是零值(并不是一定是0,而是类型对应的默认值,比如boolean的false,int的0)。而这里将会执行类构造器()方法 。这并非应用程序层面的init方法,而是Javac编译器的自动生成物。

关于这个方法可以通过idea简单的看到。这时通过idea的view->byte code即可查看。

image-20210619113636351

()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。

例如:

public class Test {
    static {
        i = 0;  //  给变量复制可以正常编译通过
        System.out.print(i);  // 这句编译器会提示“非法向前引用”
    }
    static int i = 1;
}

()方法与类的构造函数(即在虚拟机视角中的实例构造器()方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的()方法执行前,父类的()方法已经执行完毕。因此在Java虚拟机中第一个被执行的()方法的类型肯定是java.lang.Object

由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作

()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成()方法。

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成()方法。接口与类不同的是,执行接口的()方法不需要先执行父接口的()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的()方法

Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行完毕()方法。如果在一个类的()方法中有耗时很长的操作,那就可能造成多个进程阻塞。

类中的静态代码块或者属性的赋值不应该太过复杂,否则会阻塞初始化流程。

类加载器

Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。

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

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

这里也解释了上面上面的疑问,类加载器不同的情况下,类是不相同的。

项目中很少回去自定义类加载器,也不会过多的去了解,一般直接使用Class.getClassloader去调用,但是若你看过springboot启动流程的源码。就会发现,在加载bean之前,初始化运行环境时就已经获取了一个类加载器,并且这个类加载器会伴随整个启动流程,以及后续的bean加载。

双亲委派模型

概念

说道类加载,可能最让人熟悉的就是双亲委派原则了,这个概念在任何一个提到类加载的地方都被说道,原则要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器

“双亲”是“parents”翻译而来,并非父母双方,本质上来说就是我们理解的”父类“,但并非是继承关系,而是使用的组合模式,parent作为classloader的一个属性。可以通过构造函数进行赋值,若赋值为null,则会使用启动类加载器(Bootstrap ClassLoader)。

从虚拟机的角度来说,分为:启动类加载器(Bootstrap ClassLoader)和其他类加载器

从开发的角度来说,分为三层类加载器、双亲委派原则的类加载架构。从JDK 1.2到 JDK 8都保持了这种实现,从JDK 9的模块化开始,就有了一些变化,但主体保持不变。

先看一个类加载的结构图:

image-20210624144454891

启动类加载器(Bootstrap Class Loader):这个类加载器负责加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。

扩展类加载器(Extension Class Loader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。

应用程序类加载器(Application Class Loader):这个类加载器由sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystem-ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。

工作过程

​ 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

作用

具带有优先级层级性,例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。

双亲委派原则破坏

双亲委派原则并非是强制性的,而是Java设计者推荐给开发者们的类加载器实现方式。本文介绍3个破坏的情况。

  1. 在JDK1.2 之前的远古时代,在双亲委派原则制定之前。1.2之后为了兼容已经存在的自定义加载器,引入了findClass方法,引导后续的开发人员,重写此方法,而不是直接重写loadClass。这样如果父类调用失败,则会调用自身去加载。

  2. 模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢? 为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContext-ClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器扩展:

    ​ 说到这点笔者想到,之前在写springboot源码的时候发现,springboot早在启动初期,构建运行环境对象时已经早早的初始化了一个类加载器。

    获取类加载器的方法:

     public ClassLoader getClassLoader() {
     	//这里的resourceLoader为null
        return this.resourceLoader != null ? this.resourceLoader.getClassLoader() : ClassUtils.getDefaultClassLoader();
        }
        
     // 默认获取类加载方法
     public static ClassLoader getDefaultClassLoader() {
            ClassLoader cl = null;
    
            try {
                cl = Thread.currentThread().getContextClassLoader();
            } catch (Throwable var3) {
            }
    
            if (cl == null) {
                cl = ClassUtils.class.getClassLoader();
                if (cl == null) {
                    try {
                        cl = ClassLoader.getSystemClassLoader();
                    } catch (Throwable var2) {
                    }
                }
            }
    
            return cl;
      }
    
  3. 用户对程序动态性的追求而导致的,这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。简单的来说就是为了实现“热部署”

以上仅是3种被迫违反“双亲委派原则”的情况,日常开发中,我们不需要也不应该去主动破坏它,如果被问到如何破坏,其实上面也说到了,双亲委派原则的实现在基础的loadClass方法中实现,代码也非常简单:

 protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

一般情况下,自定义classloader的时候一般都是重写findClass方法即可,若你想破坏“双亲委派原则”,也仅需要覆盖loadClass方法即可,移除中间的parent判断。一般除了面试时,也不会有人问你这种问题。

小结

回到开始的问题,对于同一个类,不同的ClassLoader加载出来的对象是同一个吗?

答案:否。不同的类类加载器,加载出来的对象不相同。并且为了规避重复加载导致底层类不同的情况,使用了“双亲委派原则”,体现在ClassLoader#loadClass方法内。

此外,还介绍了类文件的结构,分为:魔数、版本号、常量池、访问标识、索引集合(类,父类,接口)、字段表、方法表。java的稳定性和良好的兼容性,得益于文件结构的相对稳定。所有的文件数据,都是由无符号数(多个符号数的集合)组成。

类的加载过程分为:加载、验证、准备、解析、初始化,五个主要阶段。整个流程不一定是完全线性的,比如:加载未完成的时候,可能已经进行了校验,此外解析过程可能发生在初始化之后。

加载:读取二进制字节流到虚拟机中,并非一定是真实存在的类文件。

验证:校验文件的格式,元数据校验,字节码校验,符号引用校验。主要为了确保加载的类,是符合虚拟机规范的,可被执行的。

准备:初始化类中的静态变量值,这里初始化的是各种类型的默认值,比如:int类型的0,boolean的false。

解析:将常量池内符号引用替换为直接引用的过程。

初始化:交由应用程序初始化对象的信息,比如:static代码块和构造函数内对属性的赋值。