类加载

一个类的生命周期如下

  • 加载
  • 验证
  • 准备
  • 解析
  • 初始化
  • 使用
  • 卸载

其中,验证、准备、解析三个阶段可以统称为连接阶段

加载、验证、准备、初始化、卸载,这5个阶段的顺序是可以保证的,类的加载过程必须按照这个顺序来开始

JVM中没有规定什么时候会触发类加载,但是规定了什么时候开始进行类加载中的初始化操作

什么时候开始初始化

JVM规范中规定了有且只有6种情况下会对类进行初始化

  • 遇到new、getstatic、putstatic、invokestatic指令时
    • new指令,在代码中显示创建对象时
    • getstatic、putstatic指令,读取/修改类的静态字段时,不包括final修饰的,和在编译器已经被放入常量池的字段
    • invokestatic,调用类的静态方法时
  • 对类进行反射调用时,如果此时类没有被初始化,则会执行初始化
  • 初始化一个类的时候,如果其父类还未初始化,则会先触发其父类的初始化
  • 启动虚拟机执行main方法时,需要先初始化main方法所在类
  • 当接口定义了default方法时,如果有其实现类发生了初始化,那该接口会在其实现类之前初始化
  • JDK7动态语言中,如果一个java.lang.invoke.MethodHandle实例最后的解 析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化

以上6种情况触发初始化被称为对一个类的主动引用,除此之外,其他的操作都被称为被动引用,被动引用是不会触发初始化的,比如

  • 通过子类来引用父类中的静态字段,不会触发子类的初始化,只会触发父类的初始化,至于是否触发子类的加载、验证,不同的虚拟机会有不同的实现,在Hotspot中,此操作是会导致子类进行加载和验证的,可以通过JVM参数-XX:+TraceClassLoading来观察类是否进行了加载
  • 创建某类型的数组时,因此不会触发该类的初始化,但是会触发另一个类[L**的初始化,该创建动作是由指令newarray来触发的,该类包装了数组元素的访问,会检查数组越界
  • 编译阶段已经存放到类的常量池的final变量被调用时

接口的初始化过程与类的初始化过程不一样,接口初始化并不要求其父接口也被初始化,只有在用到父接口时,比如引用了父接口中的常量时,才会对父接口进行初始化

在接口中不能使用static{}代码块,JVM为接口生成的类构造器方法为\<clinit\>,而实例的构造器方法为<init>

类加载过程

加载

在这个阶段,JVM需要完成三件事

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

如何获取这个二进制字节流

  • 从JAR等压缩包中读取
  • 运行时生成,例如动态代理技术,为每个被代理类生成*$Proxy代理类
  • 从加密文件中获取
  • 由其他文件生成,如JSP
  • 从数据库中读取
  • 从网络中读取

数组类型的加载

非数组类型的加载只需要调用系统提供的几个类加载器即可,或者调用自定义的类加载器

但是数组类型的加载则不一样,数组本身不会通过类加载器来创建,而是由JVM直接在内存中构造出来,但是数组中的元素类型(去掉数组所有维度后的类型)还是要依靠类加载器来加载的

  • 如果数组的组件类型(Component Type,指去掉一个维度后的类型)是引用类型,那么就采用递归的方式重新执行本流程,该数组将被标识在加载该组件类型的类加载器的类名称空间上
  • 如果数组的组件类型不是引用类型,比如,int[],它的组件类型为int,那么JVM会把数组标记为与BootstrapClassLoader关联
  • 数组的访问性和它的组件类型的访问性一致,如果组件类型不是引用类型,则它的访问性默认为public

当加载结束后,二进制字节流就会被存放到方法区中,作为运行时数据,接着JVM就会在内存中为该二进制字节流创建其对应的java.lang.Class对象

连接

连接是由验证、准备、解析三个阶段组成的

验证

该阶段是连接阶段的第一个步骤,目的是确保Class文件的字节流包含的信息符合JVM规范,保证运行的代码不会对JVM造成破坏,该阶段主要有4个步骤

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证
文件格式验证

验证二进制字节流是否符合Class文件格式规范,主要检查的项有

  • 魔数是否为0xCAFEBABE
  • 主、次版本号是否符合当前JVM版本
  • 常量池中是否有不被支持的类型
  • 指向常量的索引是否有指向不存在的或者不符合类型的常量
  • CONSTANT_utf8_info中的常量是否有不符合UTF-8编码的数据
  • Class文件中各个部分及文件本身是否有被删除的或附加的信息

等等...

该步骤的目的是保证输入的二进制字节流能正确解析并存储在方法区内,只有通过了这个阶段的验证后,字节流才允许存储在方法区中,接下来的验证都是基于该步骤的

元数据验证

该步骤主要是对类的元信息进行语义分析,确保其符合Java语言规范的要求,这里主要验证的项有,

  • 该类是否有父类,除java.lang.Object之外
  • 该类是否继承了被final修饰的类
  • 如果该类不是抽象类,是否实现了其父类或者父接口中的所有的要求实现的方法
  • 该类中的方法、字段是否与父类矛盾,比如重写了父类中被final修饰的字段,或者出现了不符合规则的方法重载
字节码验证

该步骤主要是对方法体进行校验分析,保证被校验的方法在运行时不会破坏JVM,如果方法没有通过校验,那么该方法肯定是有问题的

符号引用验证

该步骤发生在JVM将符号引用转化为直接引用的时候,转化将在解析阶段完成

该步骤的主要目标就是验证该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源,保证解析行为能够正常执行,主要验证的项有,

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类
  • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段
  • 符号引用中的类、字段、方法的可访问性(private,public等访问修饰符)是否可被当前类访问

如果类无法通过该步骤的验证,那么会抛出java.lang.IncompatibleClassChangeError的子类异常,典型的如: java.lang.IllegalAccessErrorjava.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError

验证阶段不是必须的,如果代码已经通过了验证,那么可以考虑使用-Xverify:none来关闭大部分的验证措施,缩短JVM类加载的时间

准备

该阶段,是JVM为类变量(静态变量)分配内存并赋初值的阶段,类变量存放在方法区中,JDK8后存放在堆中,这里不包括实例变量的赋值,实例变量需要到初始化阶段随着对象的实例化而分配内存并赋值

这里的赋初值是给变量赋零值,比如int类型对应的零值为0,boolean类型对应的零值为false等,而真正的赋值(代码中给定的值,如public static int a = 123,123就是变量a的真正的值)操作也是在初始化阶段,因为在准备阶段,没有执行任何的Java方法,而真正赋值操作是在putstatic指令被编译后,存放于类构造器\<clinit\>()方法中

通常情况下,该阶段是对变量赋零值,除非该变量同时又被final关键字修饰,那么在编译时,就会为该变量生成对应的ConstantValue,然后在该阶段对其赋值

解析

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

  • 符号引用,用一组符号来描述所引用的目标,符号可以是任意形式的字面量,与虚拟机实现的内存布局无关
  • 直接引用,可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄,与虚拟机实现的内存布局直接相关
类/接口的解析

对于一个类Clazz,如果要把一个从未被解析的符号引用F解析为一个类或者一个接口C的直接引用,那么在JVM中至少会经过如下3个步骤

  • 如果C不是一个数组,那么JVM会把F的全限定名传递给Clazz的类加载器去加载这个C,如果出现异常,则解析失败
  • 如果C是一个数组,并且数组的元素为对象,那么会按照第一步的规则加载数组元素类型,然后由JVM生成一个代表该数组维度和元素的数组对象
  • 如果以上两步都通过了,那么C在JVM中已经是一个有效的类或者接口了,接下来会对其访问权限进行验证,如果发现不具备访问权限,就会抛出java.lang.IllegalAccessError
字段解析

对于一个类Clazz的字段解析,首先需要对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,如果解析成功,再对类Clazz的字段进行如下步骤的搜索

  • 如果类Clazz本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,搜索结束
  • 如果类Clazz实现了接口,则会从继承关系从下往上寻找其父接口中包含了简单名称和字段描述符与目标相匹配的字段,如果有则返回这个字段的直接引用,搜索结束
  • 如果类Clazz不是java.lang.Object的话,将会按照继承关系从下往上寻找其父类,如果其父类包含了简单名称和字段描述符与目标相匹配的字段,则返回这个字段的直接引用,搜索结束
  • 否则搜索失败,抛出java.lang.NoSuchFieldError

查找到之后会对字段进行访问权限的验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError

如果一个同名字段同时出现在一个类的父接口和父类中,并且该类没有覆盖这个字段,那么使用javac编译将不会通过,并且会提示field x.x is ambigous

方法解析

对于一个类Clazz的方法解析,同样首先需要对方法表内class_index项中索引的方法所属的类或者接口的符号引用进行解析,如果解析成功,那么会继续对类Clazz的方法进行如下步骤的搜索

  • 如果class_index中的索引是个接口的话,会抛出java.lang.IncompatibleClassChangeError异常,因为Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的
  • 如果第一步检查通过,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,搜索结束
  • 否则在类Clazz的父类中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,搜索结束
  • 否则在类Clazz实现的接口列表及其父接口中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在的话,则表示该类Clazz是一个抽象类,搜索结束,抛出java.lang.AbstractMethodError
  • 否则,查找结束,抛出java.lang.NoSuchMethodError

同样的,找到方法的直接引用后,会对方法进行访问权限检查,如果没有权限访问,则抛出java.lang.IllegalAccessError

接口方法解析

对于一个接口interface的接口方法解析,同样首先需要对方法表内class_index项中索引的方法所属的类或者接口的符号引用进行解析,如果解析成功,那么会继续对接口interface的接口方法进行如下步骤的搜索

  • 接口方法解析时如果class_index中的额索引是个类,抛出java.lang.IncompatibleClassChangeError异常
  • 否则,在接口interface中查找是否有简单名称和描述符都与目标接口方法匹配的方法,如果有,则返回这个方法的直接引用,搜索结束
  • 否则,在接口interface的父接口中继续查找,直到查找到java.lang.Object为止,如果找到了,则返回其直接引用,搜索结束
  • 因为接口是可以多继承的,因此如果在多个父接口中找到了相同的接口方法,则由JVM来决定返回哪个方法
  • 否则,查找失败,抛出java.lang.NoSuchMethodError异常

接口方法,就不需要判断访问权限了,默认都是public的

初始化

初始化阶段是执行类构造器<clinit>()的阶段,在该阶段会初始化类变量及其他资源

这个类构造器<clinit>是由javac编译器生成的,javac会按照出现的顺序自动收集所有类变量的赋值动作和static语句块中的语句,静态语句块中只能访问到定义在语句块前面的变量,定义在其之后的变量只能对它赋值而不能访问它

static {
    // 正确
    i = 0;
    // 编译出错,提示“非法向前引用”
    System.out.println(i)
}
static int i = 1;

JVM会保证父类的<clinit>()方法在子类的<clinit>()方法执行之前执行,执行<clinit>()方法时不需要和执行实例构造器<init>()一样显示调用父类的构造器

如果一个类中没有静态语句块,那么编译器将不会为其生成类构造器<clinit>()

接口中不能使用静态语句块,但是接口也会对变量进行初始化操作,因此编译器也会为接口生成<clinit>(),并且执行子接口的<clinit>不要求父接口的<clinit>先执行,只有在子接口中调用了父接口中定义的变量时,才会执行父接口的<clinit>

接口的实现类在初始化时也不会执行接口的<clinit>方法

JVM保证了一个类的<clinit>方法在多线程环境中必须能够正确的加锁同步,如果多个线程同时初始化一个类,那么只有一个线程能够执行该类的<clinit>方法,其他线程都会阻塞直到该线程执行<clinit>完毕,如果一个类的<clinit>方法中有很多耗时操作,则会造成线程的长时间阻塞,当有一个线程执行完<clinit>方法后,其他阻塞的线程不会再次执行<clinit>方法,同一个类加载器下,一个类型只会被初始化一次