美文网首页JAVA面试
JVM笔记:Java虚拟机的类加载机制

JVM笔记:Java虚拟机的类加载机制

作者: BigX | 来源:发表于2019-11-04 19:02 被阅读0次

    前言

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

    • 类加载的流程

      类从被加载到虚拟机内存中开始,到卸载出内存位置,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载,其中验证、准备、解析三个部分统称为连接。这七个阶段的发生顺序如图1-1所示。


      图1-1:类加载流程图

    上图中,加载、验证、准备、初始化和卸载这5个阶段的顺序是固定的,类的加载过程必须按照这种顺序按部就班地开始,但是解析阶段则不一定:他在否种情况下可以再初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。同事,上面这是阶段通常都是互相交叉地混合进行的,通常会在一个阶段执行的过程中调用、激活另一个阶段(例如在一个类的内部初始化另一个类)。

    • 类加载的时机

      什么情况下需要开始类加载过程的第一个阶段:加载?Java虚拟机规范中并没有进行强制约束,这点交给虚拟机的具体实现来自由把握。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5中情况必须立即对类进行初始化(加载、验证、准备自然需要在此之前开始)。

      • 遇到new 、getstatic、putstatic、invokestatic这四条字节码指令时,如果类没有进行国储石化,则需要先触发其初始化。生成这四条指令的场景是:使用new关键字实例化对象,读取或这只一个类的静态变量(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
      • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有经过初始化,则需要先触发其初始化
      • 当初始化一个类的时候,如果其父类还没有经过初始化,则需要先触发其父类的初始化。
      • 虚拟机启动时,用户需要制定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
      • 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有经过初始化,则需要先触发其初始化。

      对于以上5种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的现定于:有且只有,这5种场景中的行为被称为对一个类进行主动引用,但是除此之外,所有引用类的方式都不会触发初始化,称为被动引用,如下面例子:

    public class Parent {
        public static int a = 1;
        static {
            System.out.println("Parent init");
        }
    }
    public class Son extends Parent{
        static {
            System.out.println("Son init");
        }
    }
       public static void main(String[] args) {
            System.out.println("args = [" + Son.a + "]");
        }
    输出结果:
    Parent init
    args = [1]
    

    对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化,至于是否要触发子类的加载和验证,在虚拟机规范中并未明确规定,这点取决于虚拟机的具体实现,对于Sun HotSpot虚拟机来说,可通过-XX:_TraceClassLoading参数观察到次操作会导致子类的加载。

    除此之外,通过数组定义来引用类,不会触发此类的初始化。

       public static void main(String[] args) {
            Parent[] parentArry = new Parent[10];
        }
    

    运行上述代码后什么输出也没有,说明并没有触发Parent类的初始化阶段。但是这段代码里面触发了另一个名为[Lxxx.xxx.Parent(前面的xxx指代类的包名)的类的初始化,这里是不是看起来有点眼熟,在前面字节码的文章里可以知道[L这里表示的是一个对象数组。它是由虚拟机自动生成的、直接继承与Object的类,创建动作由字节码指令newarray触发。
    这个类表示了一个元素类型为Parent的一维数组,数组中应有的属性和方法(可被用户直接调用的方法只有length和clone)都实现在这个类里。在Java语言中,当检查到数组越界时会抛出ArrayIndexOutOfBoundsException异常,但是这个异常检测不是封装在数组元素访问的类中,而是封装在数组访问的xaload、xastore字节码指令中。

    当引用一个类的静态且被final修饰的常量时,不会触发此类的初始化

    public class Parent {
        public static final int a = 1;
        static {
            System.out.println("Parent init");
        }
    }
      public static void main(String[] args) {
            System.out.println("args = [" + Son.a + "]");
        }
    输出结果:
    args = [1]
    

    因为作为final修饰的常量时一个不可变的值,所以在编译阶段会通过常量传播优化,将此常量的值1存储到了主类(main方法所在的类)的常量池中,所以以后主类中对常量1的引用实际都被转化了主类对自身常量池的引用,也就是说,实际上主类的Class文件中并没有Parent类得符号引用,这两个类在编异常Class之后就不存在任何联系了。

    接口的架子啊过程与类加载过程稍有不同,针对接口需要做一些特殊说明:接口也有初始化过程,这点和类是一致的,但是接口中不能使用static{}语句块,但是编译器仍然会为接口生成<client>类构造器,用于初始化接口中所定义的成员变量。接口与类正则有所区别的是前面讲述的需要初始化场景的第三种:当一个类在初始化时,要求其父类全部都已经初始化过了。但是一个接口在初始化时,并不要求其负借口全部都完成了初始化,只有在真正使用到负借口的时候(如引用接口中定义的常量)才会被初始化。

    • 类加载的步骤

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

    • 加载

    加载是类加载过程的一个阶段,在加载阶段,虚拟机主要完成一下三件事

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

    加载阶段没有规定加载的内容从哪来,因为它加载的是一个类的全限定名来获取定义此类的二进制字节流。所以,虚拟机根本没有制定要从那里获取,怎样获取,但是常见的获取方式有下面几种:

    • 从zip包中获取,也就是常见的JAR,EAR,WAR
    • 从网络中获取,最典型的场景应用就是Applet
    • 运行时计算生成,主要用于动态代理技术,在java.lang.reflect.Proxy中就是用了ProxyGenerator.gengrateProxyClass来为特定接口生成形式为*$Proxy的代理类的二进制字节流
    • 由其他文件生成,例如由JSP文件生成对应的Class类
    • 从数据库中读取,例如有些中间件服务器可以选择把程序安装到数据库中来完成程序代码在集群间的分发。
      ......

    对于类加载过程的其他阶段,一个非数组的加载阶段(准确的说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成(例如对字节码加密,然后通过自定义类加载器来解密后加载类),开发人员可以通过定义自己的类加载器去控制字节流的获取方式。

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

    • 1 . 如果数组的类型时一个引用类型,那就需要去加载这个组件类型,然后在加载该组件类型的类加载器的类名称空间上被标识,这一点在后续的类加载器中会讲述到。
    • 2 . 如果数组的类型时基础数据类型,Java虚拟机会把数组标记为与引导类加载器关联。
    • 3 . 数组类的可见性与它的组件类型可见性一致,如果组件类型不是引用类型,那数组的可见性将默认为public。

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

    加载阶段和后续的连接阶段的部分内容是交叉进行的,加载阶段尚未完成时,连接阶段可能已经开始了,但是这些夹在加载阶段的动作仍然属于连接阶段。

    • 验证

      验证是连接阶段的第一部,这一步的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。
      从2011年发布的《Java虚拟机规范(JSE 7版)》中从整体上上看,研制阶段大致上会完成下面4个阶段的校验动作:文件格式校验、元数据校验、字节码校验、符号引用验证。

    • 1 . 文件格式验证
      验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,可能包含下面这些验证点:

      • 是否以魔数0xCAFEBABY开头。
      • 主次版本号是否在当前虚拟机处理范围之内。
      • 常量池的常量中是否有不被支持的常量类型(检查常量的tag标志)。
      • 指向常量的各种索引值是否有指向不存在的常量或不符合类型的常量。
      • CONSTANT_Utf8_info类型的常量中是否有不符合UTF8编码的数据。
      • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
        ......

    上面只是验证的一小部分点,目的是包在输入的字节流能正确地解析并且格式上符合一个Java类型的数据要求。只有通过这个阶段的兖州,字节流才会进入内存的方法区进行存储,后面的三个验证阶段全部是基于方法取得存储结构进行的,不会再直接操作字节流。

    • 2 . 元数据验证
      第二步是对字节码描述的信息进行语义分析,保证其描述的信息符合Java语言规范的要求,这个阶段的包含的验证点如下:

      • 这个类是否有父类(除了Object,所有的类都应该有父类)。
      • 这个类的父类是否继承了不被允许继承的类(被final修饰的类)。
      • 如果这个类不是抽象类,是否实现了其父类或接口中的要求实现的所有方法。
      • 类中的字段、方法是否和父类产生了矛盾(例如覆盖了父类的final字段)。
        ......
    • 3 .字节码验证
      这是验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。交验完元数据后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:

      • 操作数栈的数据类型和指令代码序列能配合工作,例如不会出现操作数栈存入了int类型数据,加载时却用long类型。
      • 保证跳转指令(goto)不会跳转到方法体以外的字节码指令上。
      • 保证方法体中的类型转换时有效的。
        ......

      如果一个类方法体没通过校验,那肯定是有问题的,但是通过了校验也不一定是完全安全的,即通过程序去校验程序逻辑是无法做到绝对准确的

      虚拟机设计团队为了避免过多的时间消耗在字节码校验阶段,在JDK1.6之后Javac虚拟机中进行了一项优化,给方法体的Code属性的属性表中增加了一项名为StackMapTable的属性,这项属性描述了方法体中所有的基本亏啊开始时本地变量表和操作数栈应有的状态,字节码校验期间,就不需要根据程序推导这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可,这样将字节码验证的类型推导转换为类型检查,从而节省一些时间。

    • 4 .符号引用验证

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

      • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
      • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
      • 符号引用中的类、字段、方法的访问类型是否可被当前类访问。
        ......
        符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用,那么会抛出一个IncompatibleClassChangeError异常的子类,例如NoSuchField(Method)Error

      对于虚拟机来说,验证阶段是一个重要,但不是必要的阶段,如果你的代码已经被反复使用和验证过了,那么在实施阶段就可以考虑用-Xverify:none参数来关闭大部分的类验证措施,以缩短类加载的时间。

    • 准备

      准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段有两个容易混淆的概念需要强调一下:首先,这个时候进行内存分配的仅包含类变量(static变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中;其次,这里所说的初始值他那个场情况下是数据类型的零值。

      public static  int number= 1;
      public static final int numberFinal= 123;
    

    上面例子中number在准备阶段后的初始值为0而不是1,因为这个时候尚未开始执行仍和Java方法,而把number赋值为1的putstatic指令时程序被编译后,存放于类构造器<clinit>()方法之中,所以把number赋值为1的动作将在初始化阶段才会执行。

    但是在特殊情况下,如果类字段的字段属性表中存在ConstantValue属性(被final修饰),那在准备阶段变量numberFinal就会被初始化为指定的值。编译时Javac将会为numberFinal生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将值设为123。

    • 解析

      解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在JVM笔记:Java虚拟机的常量池提到过很多次了,在Class文件中他以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,那解析阶段中所说的直接引用和符号引用又有什么关联呢?

      符号引用(SymbolicReferences):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义地定位到目标即可。但是引用的目标并不一定已经加载到内存中,它在很多情况下类似一个占位符,表示将来需要指向这么一个内容,然后在后续阶段将其替换为直接引用。各种虚拟机所能接受的符号引用必须是一致的,没因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

      直接引用(SymbolicReferences):直接引用可以是直接指向目标的指针、相对偏移量或一个能简介定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。

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

      除了invokedynamic指令以外,虚拟机实现了对第一次解析的结果进行缓存,在运行时常量池中记录直接引用,并把常量标识为已解析状态,从而避免解析动作重复,如果一个符号引用解析成功或失败,那么后续对其的引用解析也应该收到成功或者异常告知。

      对于invokedynamic指令,当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令也同样生效。因为invokedynamic指令的目的本来就是用于动态语言支持,它所对应的引用称为动态调用点限定符,这里动态的含义就是必须等到程序运行到这条指令的时候,解析动作才能进行。相对的,其余可触发解析的指令都是静态的,即可以在刚刚完成加载阶段,还没有开始执行代码时就开始进行解析。

      解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,这里主要介绍前面4种,后面三种与JDK新增的动态语言支持息息相关,暂时这里不多做赘述,前面三种分别对于常量池的CONSTANT_(Class、Fieldref、Methodref、InterfaceMethodref)_info

      1 . 类或接口的解析

      假设在类W要把一个从未解析过的符号引用N解析为一个类或接口O的直接引用,那虚拟机完成整个过程主要分为以下三个步骤。

      • 如果O不是一个数组类型,那虚拟机将会把代表N的全限定名传递给W的类加载器中去加载这个类O。在加载过程中,由于元数据验证,字节码验证的需要,有可能触发其他相关类的加载动作,一旦这个加载过程出现了异常,解析过程就宣告失败。

      • 如果O是一个数组类型,并且数组类型为对象(描述符为[Lxxx/xxx),那将会按照上面的规则加载数组元素类型,如果N的描述符如前面锁假设的形式,那么就会加载该元素类型的对象,接着由虚拟机生成一个代表此数组维度和元素的数组对象。

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

      2 . 字段解析

      解析一个未被解析过得字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类和接口的符号引用,也就是说,欲解字段,必先解其所在类。

      • 解析完类后,如果类本身包含了简单名称和字段描述符都与目标匹配的字段,则直接返回该字段的直接引用

      • 如果该类实现了接口,将会按照继承关系递归搜索各个接口和他的父接口,如果接口中包含了简单名称和字段描述符都与目标匹配的字段,则直接返回该字段的直接引用。

      • 如果该类不是Object的话,将会按照继承关系递归搜索其父类,如果父类中包含了简单名称和字段描述符都与目标匹配的字段,则直接返回该字段的直接引用。

      • 如果以上步骤都失败,那么抛出NoSuchFieldError异常。

      • 同样的如果不具备对返回的字段引用的访问权限,抛出IlleagalAccessError异常。

      • 如果一个同名字段同时出现在类的接口和父类中,或者在自己父类的多个接口中出现,那么编译器将可能拒绝编译。

      3 . 类方法解析

      类方法解析第一个步骤和字段解析一样,也需要先解析出该方法所在的类。然后按照下面步骤进行后续的类方法搜索。

      • 1)类方法和接口方法符号引用的常量类型定义是分开的(一个是Methodref,一个是InterfaceMethodref),如果类方法表中发现索引的是一个接口,那么会抛出IncompatibleClassChangeError异常。

      • 2)如果通过第一步,接着在类中查找是否包含了简单名称和字段描述符都与目标匹配的方法,则直接返回该方法的直接引用。

      • 3)否则,在类的父类中递归查找是否包含了简单名称和字段描述符都与目标匹配的方法,则直接返回该方法的直接引用。

      • 4)否则,在类实现的接口列表和他们的父接口中递归查找是否包含了简单名称和字段描述符都与目标匹配的方法,如果存在,说明该类是一个抽象类(如果不是抽象类,该类中会查找到这个方法),这时候抛出AbstractMethodError异常。

      • 5)以上步骤都不行,抛出NoSuchMethodError异常。

      • 6)同样的如果不具备对返回的方法引用的访问权限,抛出IlleagalAccessError异常。

      4 . 类方法解析

      老样子,接口方法也需要先解析出接口方法表class_info想中索引的方法所属的类或接口的符号引用。然后按照下面步骤进行后续的接口方法搜索。

      • 1)与类方法解析相反,如果在接口方法表中发现该接口所对应的是一个类而不是接口,抛出IncompatibleClassChangeError异常。

      • 2)如果通过第一步,接着在接口中查找是否包含了简单名称和字段描述符都与目标匹配的方法,则直接返回该方法的直接引用。

      • 3)否则,在接口的父接口中递归查找,直到Object类为止,看是否包含了简单名称和字段描述符都与目标匹配的方法,则直接返回该方法的直接引用。

      • 4)以上步骤都不行,抛出NoSuchMethodError异常。

      • 5)因为接口方法默认都是public的没所以不存在访问权限,所以接口方法不会抛出IlleagalAccessError异常。

    • 初始化

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

      在准备阶段,变量已经赋值过一次系统要求的初始值,而在初始化阶段,则根据程序制定的计划去初始化类变量和其他资源,从另一个角度来表达:初始化阶段是指向类构造器<clinit>()方法的过程。

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

    public class Parent {
        static {
            a=2;
            System.out.println("Parent init"+a);
        }
        public static  int a = 1;
    }
    

    上面代码中可以在代码块中对a进行赋值,但是没啥作用,因为会被后面的a重新赋值为1,而且代码块内不能调用下面的类变量,会显示illeagal forward reference错误

    <clinit>()方法与类的构造方法,也就是实例构造器 <init>()不同,它不需要显示地调用它父类构造器,虚拟机会保证在子类的 <clinit>()方法执行之前,父类的 <clinit>()方法已经执行完毕,也就是说,父类中定义的静态语句块要由于子类的变量赋值操作,因此在虚拟机中第一个被执行的 <clinit>()方法的类肯定是Object。

    下面例子中输出的结果就是2,因为父类的静态赋值操作比子类先执行

    public class Parent {
        public static  int a = 1;
        static {
            a=2;
        }
    }
    public class Son extends Parent{
          public static int b=a;
    }
     public static void main(String[] args) {
            System.out.println("args = [" + Son.b + "]");
        }
    

    <clinit>()方法不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成 <clinit>()方法。

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

    虚拟机会保证一个类的 <clinit>()方法在多线程环境中被正确的加锁,用不,如果多个线程同时去初始化一个类,那么只有一个线程回去执行这个类 <clinit>()方法,其他线程都需要阻塞等待,这也是静态单例实现的原理。

    • 总结

      本文内容来自于《深入Java虚拟机》,感兴趣的朋友可以入这本书看看。

    相关文章

      网友评论

        本文标题:JVM笔记:Java虚拟机的类加载机制

        本文链接:https://www.haomeiwen.com/subject/ztkdbctx.html