• JVM学习六:类加载机制


    一、概述

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

    与那些在编译时需要进行连接的语言不同,Java语言中,类的加载、连接和初始化过程都是在程序运行期间完成,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为Java应用提供了极高的扩展性和灵活性,Java语言天生可以动态扩展的语言特性就是依赖运行期间动态加载和动态链接这个特效实现。

    在开始正式介绍类加载机制前,先做以下约定:

    • 每个Class文件代表的可能是一个类或者一个接口,本文中直接对“类型”的描述都同时蕴含着类和接口的可能性,需要对类和接口分开描述的时候,会进行特别指明
    • 本文提到的“Class文件”并非特指某个存在于具体磁盘中的文件,应当是一串二进制字节流,它的存在形式包括但不限于磁盘文件、网络、数据库、内存或者动态产生等。

    二、类加载的时机

    一个类的生命周期将会经历**加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unlaoding)**七个阶段。具体的顺序如图:

    在这里插入图片描述

    其中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,而解析阶段在特定情况下可以在初始化阶段之后在开始,这是为了支持Java的运行时绑定特点。另外,这里说的确定的顺序,是指按确定的顺序开始,但不是按确定的顺序完成,这些阶段通常都会互相交叉地混合进行。

    对于什么时候开始类加载的第一阶段“加载”,《Java虚拟机规范》并没有进行强制约束。但是对于初始化阶段,《Java虚拟机规范》严格规定有且仅有六种情况必须立刻对类进行初始化(在此之前,自然需要完成加载、验证、准备):

    • 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有初始化,需要先触发其初始化,具体的场景包括:
      • 使用new关键字实例化对象
      • 读取或者设置一个类型的静态字段(被final修饰、已在编译期将结果放入常量池的静态字段除外)
      • 调用一个类型的静态方法
    • 使用Java.lang.reflect包的方法对类型进行反射调用的时候
    • 初始化类的时候,如果其父类没有进行过初始化,需要先初始化父类
    • 虚拟机启动时,用户指定的一个要执行的主类(包含main()方法的类),虚拟机会先初始化该类
    • 使用JDK 7新加入的动态语言支持时,如果一个java.lang.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类型没有进行初始化,需要先初始化
    • 当一个接口中定义了JDK 8新加入的默认方法时,如果有这个接口的实现类发生了初始化,该接口要在这之前初始化。

    这六种场景中的行为称为对一个类型的主动引用,除此之外,所以引用类型的方式都不会出发初始化,称为被动引用

    接口的初始化时机与类有些微区别,主要是对应前述的6种场景中的第三种,对于接口,在初始化时并不要求其父接口全部完成初始化,而是只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化父接口。

    三、类加载的过程

    类的加载过程分为:加载、验证、准备、解析和初始化这五个阶段。

    1. 加载

    JVM虚拟机在加载阶段主要完成以下三件事:

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

    这里有几个需要注意的点

    a. 二进制字节流的获取

    《Java虚拟机规范》中并没有指明二进制字节流的获取必须从某个Class文件中获取,JVM允许各种方式,包括:从ZIP压缩包中读取(JAR、WAR)、从网络中获取(Web Applet)、运行时计算生成(动态代理)、从其他文件生成等等…

    b. 非数组类型的加载

    非数组类型的加载(严格来说是加载阶段获取二进制字节流的动作)是开发人员可控性最强的阶段,可以通过JVM内置的启动类加载器完成,也可以由用户自定义的类加载器去完成。

    c. 数组类型的加载

    数组类本身不通过类加载器创建,它是由JVM直接在内存中动态构造出来的。但是,数组类的元素类型(Element Type,数组去除所有维度的类型)最终还是要靠类加载器来完成加载。一个数组类的创建需要遵循以下规则:

    • 如果数组的组件类型(Component Type,数组去除一个维度的维度)是引用类型,就递归采用本节定义的类加载过程去加载组件类型,数组类会被标识在加载该组件类型的类加载器的类名称空间上
    • 如果数组的组件类型不是引用类型,JVM将会将数组类标记为与启动类加载器相关
    • 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类和接口访问

    2. 验证

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

    验证阶段大致上分为以下四个阶段:

    a. 文件格式验证

    主要目的是保证输入的字节流能正确地解析并存储于方法区。

    这阶段的验证是基于二进制字节流进行的,验证通过后字节流才会被允许进入JVM中的方法区中,后续的三个验证阶段都是基于方法区的存储结构进行的,不再直接读取、操作字节流。

    b. 元数据验证

    主要目的是对类的元数据信息进行语义校验,保证不存在与《Java虚拟机规范》定义相悖的元数据信息

    c. 字节码校验

    主要目的是通过数据流分析和控制流分析,确定程序语义是合法的。这阶段要对类的方法体信息校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。

    但是,即使通过字节码校验,也不能保证就一定是安全的,还是无法避免"停机问题"。通过程序去校验程序逻辑是无法做到绝对准确的,不可能用程序来判断一段程序是否存在Bug。

    为了简化字节码校验阶段的耗时,JVM虚拟机设计团队将尽可能多的校验辅助措施放到了Javac编译器中进行,具体做法是给方法体Code的属性表中新增一项名为"StackMapTable"的新属性。借助该属性,JVM只需要检查该属性的记录是否合法即可,这样就将字节码验证的类型推导转变为类型检查,节省了大量校验时间。

    d. 符号引用校验

    该阶段发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段发生。

    该阶段的目的是保证解析行为能正常执行,如果不通过JVM将抛出java.lang.ImcompatibleClassChangeException的子异常。

    最后,验证阶段虽然很重要,但是却不是必须要执行的。如果程序运行的全部代码都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,缩短类加载时间。

    3. 准备

    该阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

    关于该阶段需要注意以下两点:

    • 这时候的内存分配仅仅包括类变量,不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中
    • 通常情况下,这里说的初始值是数据类型的"零值"。但如果类字段的字段属性表中存在ConstantValue属性,在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值(如:public static fianl int value = 123;)

    4. 解析

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

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

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

    解析主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定负这7类符号引用进行。

    5. 初始化

    除了在加载阶段应用程序可以通过自定义类加载器的方法局部参与之外,其余的阶段都完全由JVM来主导控制。到了初始阶段,JVM才开始真正执行类中编写的Java程序代码,将主导权移交给应用程序。

    初始化阶段就是执行类构造器()方法的过程。

    有关()方法有以下细节:

    • ()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中(static{}块)的语句合并产生的
    • ()方法和类构造器不同,不需要显示地调用父类构造器,JVM会保证在子类的()方法执行前,父类的()方法已经执行完毕。JVM中第一个被执行的()方法的类型肯定是java.lang.Object
    • 由于父类的()方法先执行,因此父类中定义的静态语句块要优先于子类的变量赋值操作
    • ()方法对于类或者接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,编译器可以不为这个类生成()方法
    • 对于接口,执行接口的()方法不需要先执行父接口的()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。同时,接口的实现类在初始化是也不会执行接口的()方法
    • JVM必须保证一个类的()方法在多线程环境中被正确地加锁同步

    四、类加载器

    JVM设计团队将类加载阶段中的"通过一个类的全限定类名来获取描述该类的二进制字节流"这个动作放到了JVM外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被成为"类加载"。

    1. 类与类加载器

    对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确定其在JVM中的唯一性。

    比较两个类是否"相等",只有在这两个类是由同一个类加载加载的前提下才有意义。否则,即使两个类来源于同一个Class文件,被同一个JVM加载,只要加载它们的类加载器不同,这两个类就一定不相等。

    2. 双亲委派模型

    站在JVM的角度看,只有两种不同的类加载器:一种是启动类加载器,由C++语言实现;一种是其他所有的类加载器,由Java语言实现。

    站在开发者的角度看,有三种不同的类加载器:启动类加载器、扩展类加载器和应用程序类加载器

    启动类加载器(Bootstrap Class Loader)

    负责加载存放在\lib目录的,或者被-Xbootclasspath参数所指定的路径中存放的,而且是JVM能识别的类库到JVM内存中。

    扩展类加载器(Extension Class Loader)

    负责加载\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库到JVM内存中。开发者可以直接在程序中使用扩展类加载器来加载Class文件

    启动类加载器(Application Class Loader)

    负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器

    自定义类加载器(User Class Loader)

    用户可以自定义类加载器,只需要继承ClassLoader类并重写findClass(String className)方法即可。

    双亲委派模型

    双亲委派模型要求除了顶层的启动类加载器,其余的类加载器都应该有自己的父类加载器。注意,这里的父子级关系并不是以继承实现,而是通过组合关系来复用父加载器的代码。

    工作过程:
    如果一个类加载起收到了类加载的请求,首先会将这个请求委托给父类加载器去完成,每一层的类加载器都是如此,最终请求会被委托到启动类加载器。只有父类加载器反馈自己无法完成这个请求时,子加载器才会尝试自己去完成加载。

    双亲委派模型

    好处:

    • Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。
    • Java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。
    • 在classpath路径下自定义一个名为java.lang.SingleInterge类(该类是胡编的)呢?该类并不存在java.lang中,经过双亲委托模式,传递到启动类加载器中,由于父类加载器路径下并没有该类,所以不会加载,将反向委托给子类加载器加载,最终会通过系统类加载器加载该类。但是这样做是不允许,因为java.lang是核心API包,需要访问权限,强制加载将会报出SecurityException异常

    3. 破坏双亲委派模型

    双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们使用的类加载器实现方式。知道Java模块化出现为止,该模型一共经历了三次大规模的"被破坏"的情况

    第一次破坏

    双亲委派模型出现在JDK 1.2之后,而在这之前已经有类加载器的概念和抽象类java.lang.ClassLoader。

    在JDK 1.2之后,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型是不得不做一些妥协,在java.lang.ClassLoader中添加一个新的protected方法findClass(),并引导用户编写的类加载起尽可能去重写这个方法,而不是在loadClass中编写代码。

    第二次破坏

    双亲委派模型保证了越基础的类由越上层的加载器进行加载,但是如果有基础类型又要调用回用户的代码,怎么处理呢?

    Java设计团队为此引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载起可以通过java.lang.Thread类的setContextClassLoader()方法进行设置。如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,这个类加载器默认就是应用程序类加载器。

    第三次破坏

    这次破坏是由于用户对程序动态性的追求所导致的,所谓的动态性包括:代码热替换、模块热部署等,用户希望Java应用程序能像电脑外设一样,街上鼠标、U盘,不需要重启就能立刻使用。

    在Java的模块化规范战役中,IBM提出了OSGi项目。OSGi中,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle的时候,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器不再是双亲委派模型推荐的树状结构,而是更为复杂的网状结构。

  • 相关阅读:
    Redis-持久化讲解
    HAL (software)
    idea必装插件推荐和联调自测方法
    ES7+向量检索实现方法
    【Redis】缓存击穿的产生情况&解决方案
    MacBook Pro 耗电严重的终极解决办法2022年
    问题记录1 json解析问题
    南大通用数据库-Gbase-8a-学习-19-Gbase8a从Kafka订阅Topic消费数据
    学习笔记——数据结构与算法之美_极客时间
    小物体的目标检测的研究综述
  • 原文地址:https://blog.csdn.net/weixin_41402069/article/details/125983650