《深入理解Java虚拟机》读书笔记(六)--虚拟机类加载机制(上)

一、概述

所谓类加载机制,就是虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。Java的类加载、连接和初始化过程都是在程序运行期间完成的,这虽然会让类加载时增加性能开销,但是提供了高度的灵活性。

二、类加载的时机

类的整个生命周期包括加载、验证、准备、解析、初始化、使用和卸载7个阶段,其中验证、准备、解析3个部分统称为连接。

其中,加载、验证、准备、初始化和卸载5个阶段的顺序是固定的,类加载需要按照这种顺序开始(只是按照顺序开始,但不是按部就班的按照这个顺序进行和完成的,这些阶段通常都是互相交叉混合进行的),而解析阶段在某些情况下可以在初始化阶段之后再开始。

对于类什么时候开始加载,Java虚拟机规范没有进行强制约束,这点可交给虚拟机自由把握。但是对于初始化阶段,虚拟机规范则严格规定了有且只有以下5种情况必须立即对类进行“初始化”(当然加载、验证、准备自然需要在此之前开始):

  1. 遇到new、getstatic、putstatic或invokestatic者4条字节码指令时:生成这4条指令最常见的Java代码场景是:使用new关键实例化对象、读取或设置一个类的静态字段(被final修饰,在编译器就把结果放入常量池的静态字段除外)、调用一个类的静态方法。

  2. 使用java.lang.reflect包的方法对类进行反射调用的时候。

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

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

  5. 使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,且这个方法句柄所对应的类没有进行过初始化。

上述这5中场景中的行为称为对一个类进行主动引用。除此之外的所有引用类的方式都不会触发初始化,称为被动引用

对于被动引用,例如:

  • 通过子类访问父类的静态字段,不会触发子类的初始化,只有直接定义这个字段的类才会被初始化

  • 通过数组定义来引用类,不会触发此类的初始化:但是会触发另一个类的初始化,比如com.test.User[] array = new com.test.User[10]; com.test.User类不会被初始化,会触发一个[Lcom.test.User类的初始化,这个类是由字节码指令newarray触发,直接继承于java.lang.Object。它代表了一个元素类型为com.test.User的一位数组,数组应有的属性和方法都实现在这个类里。

  • 常量在编译阶段会存入调用类的常量池,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化:如果一个类A引用了另一个类B的常量,那么经过编译阶段的常量传播优化,已经将此常量存储到了A类的常量池中,以后A类对B类的该常量的引用实际上都被转换为了A类对自身常量池的引用。

注:对于接口的初始化,即使接口不能使用"static{}"语句块,但编译器也可能会为其生成"<clinit>"类构造方法,因为接口可能存在静态变量的赋值,后文会提到。而当接口初始化时,并不会要求其父接口全部初始化,只有在真正使用到父接口的时候才会初始化。

三、类加载的过程

3.1 加载

加载阶段主要完成以下3件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流:高自由度,来源可以是ZIP包、网络、运行时计算产生等等
  2. 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构:也就是按照虚拟机所需的格式存储在方法区中
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口:并没有明确规定是在Java堆中,对于HotSpot而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里

注:数组类的加载和普通类的加载不同。数组类本身不通过类加载器加载,它是由Java虚拟机直接创建的(前文提到的L[xx.xx.xx),但是数组类的元素类型最终还是要靠类加载器。数组类的可见性与它的元素类型的可见性一致,如果元素类型不是引用类型,那数组类的可见性将默认为public。

3.2 验证

连接操作的第一步,目的是确保class文件的字节流中包含的信息符合当前虚拟机的要求,从整体上看,验证阶段大致分为下面4个阶段的检验动作:

文件格式验证:检查魔数是否正确、主次版本号是否在当前虚拟机处理范围之内、常量池中的常量是否有不被支持的常量类型(检查tag标志)等等。该阶段的主要目的是保证输入的字节流能够正确解析并存储于方法区中,格式上符合描述一个Java类型信息的要求。只有通过了这个验证,字节流才会即进入方法区中存储,所以后面的3个验证阶段都是基于方法区的存储结构进行的。

元数据验证:对字节码描述的信息进行语义分析,以保证其符合Java语言规范要求。比如,这个类是否有父类(除了Object,所有类都应当有父类)、这个类否是继承了不允许继承的类(final)、如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法等等。

字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。例如,保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作、跳转指令不会跳转到方法体以外的字节码指令上、方法体中的类型转换的有效性等;为了避免此阶段消耗大量的时间,JDK1.6之后,方法体的Code属性表中增加了一项名为”StackMapTable“的属性,通过检查目标方法的局部变量和操作数栈所需要的类型来确定一段字节码指令是否符合逻辑约束,这样将字节码验证的类推导转变为类型检查从而节约时间。

符号引用验证:可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验符号引用中通过字符串描述的全限定名是否能找到对应的类、符号引用中的类、字段、方法的访问性(public、private等)是否可以被当前类访问

注:验证阶段不是必须的,如果所运行的代码被反复使用和验证过,那么在实施阶段可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

3.3 准备

正式为类变量分配内存,并设置类变量初始值的阶段,这些变量所使用的的内存都将在方法区中进行分配。要注意的是,这时候进行内存分配的仅包括类变量(static修饰),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中(通常情况)。对于代码:

public static int value = 123;

在准备阶段设置初始值之后,value的值为0,不是123,因为这个时候还没有开始执行Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>方法之中,所以要在初始化阶段才会执行。而对于代码:

public static final int value = 123;

由于value有final修饰,在编译时,值123会被放入字段所属的属性表的ConstantValue属性中,那在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

3.4 解析

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

  • 符号引用:以一组符号来描述所引用的目标,其与虚拟机的内存布局无关,引用的目标并不一定已经加载到内存。
  • 直接引用:可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,如果有了直接引用,那引用的目标必定在内存中存在。

Java虚拟机规范没有规定解析阶段发生的具体时间,只要求了在执行anewarray、checkcast、getfield、getstatic、instanceof等等16个用于操作符号引用的字节码指令之前,先对他们所使用的的符号引用进行解析。所以虚拟机可以选在在类被加载时就对常量池中的符号引用进行解析,也可以在一个符号引用将要被使用前才去解析。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

3.4.1 类或接口的解析

假设当前代码所处的类为A,现要把一个从未解析过的符号引用B解析为一个类或接口C的直接应用,则需要完成以下3个步骤:

  1. 如果C不是一个数组类型,那虚拟机会把代表B的全限定名传递给A的类加载器去加载这个类C。加载过程又可能触发其它类的加载,比如加载C类的父类或实现的接口。

  2. 如果C是一个数组类型,且数组元素类型为对象,那会按照第一点的规则加载数组元素类型。

  3. 如果上面的步骤没有出现任何异常,那么在解析完成之后还要进行符号引用验证,确认A是否具备对C的访问权限,若不具备权限,则抛出java.lang.IllegealAccessError异常。

3.4.2 字段解析

要解析一个未被解析过的字段符号引用,首先会对字段所属的类或接口的符号引用进行解析。若在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功,那将这个字段所属的类或接口用C表示,虚拟机规范要求按照以下步骤对C进行后续字段的搜索:

  1. 如果C本身包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束;
  2. 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中找到了相匹配的字段,则返回这个字段的直接引用,查找结束;
  3. 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果父类中找到了相匹配的字段,则返回这个字段的直接引用,查找结束;
  4. 否则,查找失败,抛出java.lang.NoSuchFieldError异常。

和类或接口的解析一样,如果查找过程成功返回了引用,将会对这个字段进行权限验证,权限验证失败抛出IllegalAccessError异常。实际上虚拟机的编译器可能会更加严格,如果一个同名字段同时出现在C的接口和父类中,那么编译器可能拒绝编译。

3.4.3 类方法解析

类方法解析与字段解析的第一个步骤一样,也要先解析出所属类的符号引用,如果解析成功,依然用C表示这个类,接下来按照以下步骤进行类方法搜索:

  1. 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是一个接口,则抛出IncompatibleClassChangeError异常;
  2. 如果通过了第一步,在类C中匹配到了方法,则返回这个方法的直接引用,查找结束;
  3. 否则,在类C的父类中递归查找,如果有则返回这个方法的直接引用,查找结束;
  4. 否则,在类C实现的接口列表及他们的父接口中递归查找,如果存在匹配的方法,由于前面没有在C类中匹配到方法,所以可以证明C类是一个抽象类,这时查找结束,抛出AbstractMethodError异常。
  5. 否则,查找失败,抛出NoSuchMethodError。

最后如果成功返回了引用,将会对这个方法进行权限验证,如果权限验证失败,抛出IllegalAccessError异常。

3.4.4 接口方法解析

也需要先解析出接口方法所属类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来按照以下步骤进行接口方法搜索:

  1. 与类方法解析相反,如果在接口方法表中发现class_index中的索引C是一个类而不是接口,则抛出IncompatibleClassChangeError异常;
  2. 否则,在接口C中查找相匹配的方法,如果有则返回这个方法的直接引用,查找结束;
  3. 否则,在接口C的父接口中递归查找,直到java.lang.Object类为止,如果查找到了相匹配的方法,则返回这个方法的直接引用,查找结束;
  4. 否则,查找失败,抛出NoSuchmethodError异常。

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

3.5 初始化

前面的类加载过程中,除了加载阶段用户可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java代码。

在准备阶段,类变量已经赋过一次初始值了,而在初始化阶段,则会根据程序员的意志去初始化类变量和其他资源:执行类构造器方法<clinit>()。关于<clinit>方法:

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

  • public class Test{
        static{
            i = 0;//给变量赋值可以正常编译通过
            System.out.println(i);//这句编译器会提示”非法向前引用“
        }
        static int i = 1;
    }
  • <clinit>()方法与实例构造器方法<init>()不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。所以在虚拟机中第一个被执行的<clinit>()方法肯定是java.lang.Object。

  • 由于父类的<clinit>()方法先执行,所以父类中定义的静态语句块要优先于子类的变量操作。

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

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

  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁、同步,如果多个线程同时初始化一个类,那么只有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程<clinit>()方法完毕。

四、总结

这篇主要介绍类的加载、验证、准备、解析和初始化过程,下篇继续总结类加载器和双亲委派模型。

猜你喜欢

转载自blog.csdn.net/huangzhilin2015/article/details/114228248
今日推荐