目录:Class文件被载入虚拟机后,会做哪些额外的处理?类加载的具体步骤是怎么样的?

  • Class文件的装载流程
  • Class类型以文件形式存在,只有被Java虚拟机装载的Class类型才能在程序中使用;
  • 系统装载Class类型可分为加载,连接和初始化3个步骤;
  • 连接又分为验证,准备和解析3步;
  • 类的装载条件
  • Class只有在必须要使用的时候才会被装载,Java虚拟机不会无条件地装载Class类型。
  • 一个类或接口在初次使用前,必须要进行初始化
  • 所谓的"使用",是指主动使用,有以下几种情况:
  • 当创建一个类的实例时,比如使用new关键字,或者通过反射,克隆,反序列化;
  • 当调用类的静态方法时,即当使用了字节码invokestatic指令;
  • 使用类或接口的静态字段时(final常量除外),比如,使用getstatic或者putstatic指令;
  • 使用java.lang.reflect包中的方法反射类的方法时;
  • 当初始化子类时,要求先初始化父类;
  • 作为启动虚拟机,含有main()方法的那个类;
  • 主动引用的示例

java操作虚拟dom java 虚拟类_java

java操作虚拟dom java 虚拟类_java操作虚拟dom_02

分析:
Child为Parent的子类。若Parent被初始化,根据代码中的static语句块可知,会打印"Parent init",若Child被初始化,则会打印"Child init",结果为:

java操作虚拟dom java 虚拟类_加载_03

系统首先装载Parent类,之后装载Child类。符合主动装载中的两个条件,使用new关键字创建类的实例会装载相关类,以及在初始化子类时,必须先初始化分类


  • 被动引用的示例

java操作虚拟dom java 虚拟类_java_04

分析:
Parent中有静态变量v,并且在UseParent中,使用其子类Child去调用父类中的变量。结果如下:

java操作虚拟dom java 虚拟类_虚拟机_05

虽然在UseParent中,直接访问了子类对象,但是Child子类并未被初始化,只有Parent父类被初始化。在引用一个字段时,只有直接定义该字段的类,才会被初始化;


  • 注意,Child类没有被初始化,但此时Child类已经被系统加载,只是没有进入到初始化阶段;
  • 使用-XX:+TraceClassLoading参数运行这段代码,输出结果:Child已经被加载入系统,但Child的初始化并未进行;
  • final常量并不会引起类的初始化,示例:

 



在编译后的UseFinalField.class中,并没有引用FinalFieldClass类,而是将final常量直接存放到常量池中,因此,FinalFieldClass类自然不会被加载。

java操作虚拟dom java 虚拟类_java操作虚拟dom_06

结果:

java操作虚拟dom java 虚拟类_java操作虚拟dom_07

FinalFieldClass类并没有因为其常量字段constString被引用而初始化。这是因为在Class文件生成时,final常量由于其不变性,做了适当的优化。分析UseFinalField类生成的Class文件,可以看到main()函数的字节码为:

java操作虚拟dom java 虚拟类_加载_08

在字节码偏移3的位置,通过ldc将常量池第22项入栈,在此Class文件中常量池第22项为:

java操作虚拟dom java 虚拟类_虚拟机_09


  • 总结:并不是在代码中出现的类,就一定会被加载或者初始化。如果不符合主动使用的条件,类就不会初始化;
  • 加载类
  • 加载类处于类装载的第一阶段,虚拟机要完成一下工作:
  • 通过类的全名,获取类的二进制数据流;
  • 解析类的二进制数据流为方法区的数据结构;
  • 通过二进制信息,创建java.lang.Class类的实例,该类是反射得以实现的关键数据;
  • 虚拟机获取二进制数据流的途径:
  • 通过文件系统读入一个class后缀的文件;
  • 或读入jar,zip等归档数据包,提取类文件;
  • 实例:通过Class类,获取java.lang.String类的所有方法信息

java操作虚拟dom java 虚拟类_java_10


  • Java虚拟机完成类的加载是通过ClassLoader类加载器;
  • 验证类
  • 类被加载到系统后,开始连接操作,验证是该操作的第一步;
  • 目的:
  • 保证加载的字节码是合法,合理并符合规范的,验证流程大致如下:

java操作虚拟dom java 虚拟类_java操作虚拟dom_11

1.判断类的二进制数据是否符合格式要求和规范;
2.语义检查,会查看所有的类是否都有父类,一些被定义为final的类是否被集成了,非抽象类是否实现了所有抽象方法或者接口方法,是否存在不兼容的方法。语义检查不和规范,虚拟机不予验证通过;
3.虚拟机通过字节码流的分析,判断字节码是否被正确的执行;该过程只是尽可能的检查可预知的问题。即使通过了此阶段的检查,也不能说明该类没有问题;

以上3个检查排除了文件格式错误,语义错误,字节码的错误。但依然不能确保类是没有问题的;
4.进行符合引用的检验。Class文件在其常量池会通过字符串记录自己将要使用的其他类或者方法。在验证阶段,虚拟机会检查这些类或者方法确实存在,并且当前类有权限访问这些数据,如果一个需要使用类无法在系统中找到,会抛出NoClassDefFoundError,如果一个方法无法被找到,则会抛出NoSuchMethodError;


  • 准备
  • 类通过验证后,虚拟机会进入准备阶段。这个阶段,虚拟机会为这个类分配相应的内存空间,并设置初始值;各类型初始值如下:
  • 常量字段也会在准备阶段被附上正确的值,这个行为由虚拟机来做,属于变量的初始化;
  • 这个阶段不会有任何代码被执行;
  • 示例:
  • 在类中定义如下常量:
  • 在生成的class文件中,可以看到该字段含有ConsantValue属性,直接存放于常量池中。该常量constString在准备阶段被附上字符串"CONST"(并非由字节码引起的)
  • 如果没有final的修饰,仅仅作为普通的静态变量:
  • constString的赋值在函数clinit中发生,属于Java字节码的行为。字段constString上未携带任何数据信息,在clinit方法中,将字符串常量CONST通过ldc指令压栈,并通过putstatic语句进行赋值;
  • ldc字节码会加载一个常量到操作数栈中,putstatic字节码设置给定的静态字段的值;
  • 解析类
  • 准备阶段之后,进入解析阶段,需要完成的工作有:
  • 将类,接口,字段和方法的符合引用转为直接引用;
  • 符号引用
  • 就是字面量的引用,和虚拟机的内部数据结构,内存布局无法;
  • 在Class类文件中,常量池就进行了大量的符号引用;
  • 示例:查看System.out.println()的字节码

java操作虚拟dom java 虚拟类_虚拟机_12

它使用了常量池第24项,常量池的结构如下图:



java操作虚拟dom java 虚拟类_加载_13

常量池第24项被invokevirtual使用,顺着CONSTANT_Methodref #24的引用关系查找,
所有对于Class以及NameAndType类型的引用都是基于字符串的。可以认为invokervirtual的函数调用通过字面量的引用描述已经表达清楚。这就是符号引用


  • 符号引用只是一部分,当一个方法被调用,系统需要明确知道该方法的位置。Java虚拟机为每个类都准备了一张方法表,所有的方法都在这张表上。当需要调用一个类的方法时,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作,符号引用就可以转变为目标方法在类中方法表中的位置,使方法被调用成功;
  • 综上,"解析"就是将符号引用转变为直接引用,也就是得到类,字段,方法在内存中的指针或者偏移量;
  • 如果直接引用存在,可以肯定系统中存在该类,方法,字段等。但如果只存在符号引用,则不能确定;
  • 关于String的说明,CONSTANT_String的解析
  • 在Java代码中直接使用字符串常量,会在类中出现CONSTANT_String,它表示字符串常量,且引用一个CONSTANT_UTF8的常量项;
  • Java虚拟机内部运行时,会维护一张字符串拘留表,它保存所有出现过的字符串常量,且没有重复项,只要以CONSTANT_String形式出现的字符串都会在这张表中;
  • 使用String.intern()可以得到一个字符串的拘留表中的引用,因为拘留表不含重复项,所有字面相同的字符串的String.intern()方法,会返回相等的数据;
  • 示例:

java操作虚拟dom java 虚拟类_java_14

分析:
a和b的字面值都是"123",equals方法会返回true;
a和b并不指向同一个对象,因此a==b返回false;
由于a在拘留表中的引用就是b,所以最后的值为true


  • 初始化
  • 该阶段是类装载的最后一个阶段。如果前面没有出现问题,那么类可以顺利装载到系统;
  • 该阶段,类才会开始执行Java字节码;
  • 该阶段,执行类的初始化方法,clinit;
  • clinit是由编译器自动生成,由类静态成员的赋值语句以及static语句块合并产生的;
  • 示例:

java操作虚拟dom java 虚拟类_虚拟机_15

Java编译器会为这段代码生成如下的clinit:

java操作虚拟dom java 虚拟类_java操作虚拟dom_16

clinit函数中,整合了SimpleStatic类中的static赋值语句以及static语句块,先后对id和Number两个成员变量进行赋值


  • 示例:在加载一个类之前,虚拟机总是会试图加载该类的父类,因此父类clinit总是在子类clinit之前被调用。也就是说,子类的static块优先级高于父类

java操作虚拟dom java 虚拟类_加载_17

分析:
这段代码继承了SimpleStatic类,并且在其static语句块中重新为Number变量赋值为2.在main()方法中,输出number变量,结果为2。ChildStaitc类的static语句块覆盖了SimpleStatic中的staitc语句块;


  • 示例:Java编译器并不会为所有的类都产生clinit初始化函数。如果一个类既没有赋值语句,也没有static语句块,那么生成的clinit函数就应该为空,因此,编辑器就不会为该类插入clinit函数;

java操作虚拟dom java 虚拟类_加载_18

StaticFinalClass只有final常量,而final常量在准备阶段初始化,而并不在初始化阶段处理,因此对于StaticFinalClass来说,clinit就无事可做,因此,在产生的class文件中,没有该函数存在


  • 类初始化的多线程问题
  • 对于clinit函数的调用,虚拟机会在内部确保其多线程的安全性。当多个线程试图初始化同一个类时,只有一个线程可以进入clinit函数,其它线程等待,如果之前的线程成功加载了类,则等在队列中的线程就没有机会再执行clinit函数了(使用这个类时,虚拟机会直接返回给它已经准备好的信息)
  • 示例:clinit是带锁线程安全的,在多线程环境下进行类初始化的时候,可能引起死锁,这种死锁很难发现,它看起来并没有可用的信息。
  • 下段代码在展示类初始化的时候,产生了线程死锁的问题:

java操作虚拟dom java 虚拟类_虚拟机_19

java操作虚拟dom java 虚拟类_java操作虚拟dom_20

java操作虚拟dom java 虚拟类_加载_21

上段代码由3个类组成,StaticA,StaticB和StaticDeadLockMain。在StaticDeadLockMain中创建了两个线程,线程A试图去初始化StaticA,线程B尝试去初始化StaticB,在StaticA的初始化过程中,会去尝试初始化StaticB,同样在StaticB的初始化过程中,也去初始化StaticA,如下图:

java操作虚拟dom java 虚拟类_初始化_22