类加载过程(深入理解Java虚拟机笔记)

概述

在Java语言里,类型的加载,连接和初始化过程都是在程序运行期间完成的。Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程称为类的加载机制。

这种策略为Java提供了极高的扩展性和灵活性,Java天生可以动态动态扩展的语言特性 就是依赖运行期动态加载和动态连接这个特点实现的。例如,在编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类。用户可以通过Java预置的 或自定义类加载器,让某个本地的应用程序在运行时 从网络或其他地方加载一个二进制流作为代码的一部分。这种动态组装应用的方式广泛应用于Java程序中,如JSP,OSGi。

 

类加载时机

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载 (Loading)、验证(Verification)、准备、解析、初始化 、使用)和卸载七个阶段,其中验证、准备、解析三个部分统称 为连接。

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始, 这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。

注:按部就班地“开始” 并不意味 按部就班地“进行” 或 按部就班地“完成”。这些阶段通常是互相交叉的混合进行的,会在一个阶段执行的过程中调用,激活另一个阶段

《Java虚拟机规范》 规定了有且只有六种情况必须立即对类进行“初始化”(加载、验证、准备需要在此之 前开始):

(1)遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始 化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:

  • ·使用new关键字实例化对象的时候。
  • ·读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外) 的时候。
  • ·调用一个类型的静态方法的时候。

(2)使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需 要先触发其初始化。

(3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

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

(5)当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有 这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

(6)当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解 析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

上述六种场景的行为称为对一个类型的主动引用,除此之外的所有引用类型方式都不会触发初始化,称为被动引用。下面来说明什么是被动引用:

(1)通过子类引用父类的静态字段,不会导致子类初始化

package org.fenixsoft.classloading;

public class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }
    public static int value = 123;
}
public class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}
/**
 * 非主动使用类字段演示
 **/
public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}

上述代码运行之后,只会输出“SuperClass init!”,而不会输出“SubClass init!”。

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

(2)通过数组定义来引用类,不会触发此类的初始化

public class NotInitialization {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];       //上面代码的SuperClass
    }
}

运行后没有输出“SuperClass init!”,说明并没有触发类org.fenixsoft.classloading.SuperClass的初始化阶段。不过这段代码触发了一个类的初始化:Lorg.fenixsoft.classloading.SuperClass。它是一个由虚拟机自动生成的,创建动作由字节码指令newarray触发。这个类代表了一个元素类型为org.fenixsoft.classloading.SuperClass的一维数组,数组中应有的属性 和方法(用户可直接使用的只有被修饰为public的length属性和clone()方法)都实现在这个类里。

Java语 言中对数组的访问要比C/C++相对安全,很大程度上就是因为这个类包装了数组元素的访问,而C/C++中则是直接对数组指针的移动。在Java语言里,当检查到发生数组越界时会抛出 java.lang.ArrayIndexOutOfBoundsException异常,避免了直接造成非法内存访问。

(3)常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类初始化。

public class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }
    public static final String HELLOWORLD = "hello world";
}
/**
 * 非主动使用类字段演示
 **/
public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLOWORLD);
    }
}

上面的代码没有输出。虽然代码中确实引用了ConstClass类的常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello world”直接存储在NotInitialization类的常量池中,以后NotInitialization对常量 ConstClass.HELLOWORLD的引用,实际都被转化为NotInitialization类对自身常量池的引用了。

 

接口的加载过程与类加载过程稍有不同:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父 接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

接口也有初始化过程,上面的代码都是用静态语句块“static{}”来输出初始化信息的,而接口中不能使 用“static{}”语句块,但编译器仍然会为接口生成““<clinit>” ”类构造器,用于初始化接口中所定义的 成员变量。

 


类加载的过程

加载

在该阶段,Java虚拟机需要做:

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

第一步中的二进制字节流并不是必须从某个Class文件中获取,它可以从以下方式获取:

  • 从 ZIP 包读取,成为 JAR、EAR、WAR 格式的基础。
  • 从网络中获取,最典型的应用是 Applet。
  • 运行时计算生成,例如动态代理技术,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 的代理类的二进制字节流。
  • 由其他文件生成,例如由 JSP 文件生成对应的 Class 类。

对于数组类而言,它本身并不由类加载器创建,而是由Java虚拟机直接在内存中动态构造出来的。不过数组类的元素类型(是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载。

 

验证

Class文件并不一定只能由Java源码编译而来,它可以使用包括靠键盘0和1直接在二进制编辑器中敲出 Class文件在内的任何途径产生。Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为 载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是Java虚拟 机保护自身的一项必要措施。

 

准备

此阶段正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值,这些变量所有的内存都应在方法区中分配。

此阶段不会对实例变量进行内存分配,实例变量是在对象实例化时随着对象一起分配在Java堆中。

此处说的初始值“通常情况下”是数据类型的零值,假设一个类的变量的定义如下,则value在准备阶段后的初始值是0而不是123,因为此时尚未执行任何Java方法。把 value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值 为123的动作要到类的初始化阶段才会被执行。

public static int value = 123;

 

解析

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

符号引用:一组描述引用目标的符号

直接引用:直接指向目标的指针、相对偏移量或者是一个能 间接定位到目标的句柄。若有了直接引用,那引用的目标必定已经在虚拟机 的内存中存在。在程序运行时,只有符号引用是不够的

类或接口的解析

假设当前代码所处的类为D,若要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,则需要:

(1)若C不是一个数组类型,则虚拟机把代表N的全限定名传递给D的类加载器去加载类C。在加载过程中,由于类数据验证,字节码验证的需要,可能触发其他相关了类的加载动作,例如加载这个类的父类或其实现的接口。如果加载过程出现任何异常,解析过程失败。

(2)若C是一个数组类型,且数组的元素为对象,则按上一点的规则加载数组元素类型。如果N的描述符如前面假设的形式,需要加载的元素类型类似为java.lang.Intege,则会由虚拟机生成一个代表该数组维度和元素的数组对象。

(3)若上面两步没有出现任何异常,则C在虚拟机实际已成为一个有效的类或接口。但在解析完成前 还要进行符号引用验证,确认D是否具备对C的访问权限。若发现不具备权限,则抛出java.lang.IllegalAccessError异常。

字段解析

要解析一个未被解析过的字段符号引用,首先会对字段表内的class_index项中索引的 CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果解析成功,此处我们把该字段所属的类或接口用C表示。接下来的步骤:

(1)若C本身包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

(2)否则,若在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口。如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

(3)否则,如果C不是java.lang.Object,则按照继承关系从下往上递归搜索其父类,若在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个租佃的直接引用,查找结束。

(4)否则,查找失败,抛出java.lang.NoSuchFieldError异常。

方法解析

先解析出方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,此处用C表示这个类,虚拟机进行如下步骤:

(1)如果在类的 方法表中发现class_index中索引的C是个接口的话,那就直接抛出java.lang.IncompatibleClassChangeError 异常。

(2)如通过第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则 返回这个方法的直接引用,查找结束。

(3)否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返 回这个方法的直接引用,查找结束。

(4)否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标 相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,此时查找结束,抛出 java.lang.AbstractMethodError异常。

(5)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。

接口方法解析

首先解析出接口方法表的class_index 项中索引的方法所属的类或接口的符号引 用,如果解析成功,此处用C表示这个接口,接下来虚拟机执行如下步骤:

(1)如果在接口方法表中发现class_index中的索引C是个类而不是接口,则直接抛出java.lang.IncompatibleClassChangeError异常。

(2)否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方 法的直接引用,查找结束。

(3)否则,在接口C的父接口中递归查找,直到java.lang.Object类为止,查看是否有简单名称和描述符都与目标相匹配的方法,若有则返回这个方法的直接引用,查找结束。对于此步骤的查找,如果C的不同父接口中存有多个简单名称和描述符 都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找。

(4)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

 

初始化

在该阶段,Java虚拟机开始真正执行类中编写的Java代码。进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据类中的代码去初始化类变量和其他资源。初始化阶段就是执行类构造器<clint>()方法的过程。

<clint>()方法是编译器自动收集类中的所有变量的赋值动作和静态语句块(static{}块)中的 语句合并产生的,收集的顺序由语句在源文件中出现的顺序决定。

静态语句块中只能访问 到定义在静态语句块之前的变量。对于定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访 问

<clint>()方法与类的构造方法(虚拟机视角中的实例构造器()方法 )不同,他需要显示的调用父类构造器,Java虚拟机会保证在子类的()方法执行前,父类的()方法已经执行完毕。

父类的<clint>()方法先执行,意味着父类中定义的静态语句块要优先于子类的变量赋值。下面的代码中,字段B的值是2而非1

static class Parent {
    public static int A = 1;
    static {
        A = 2;
    }
}
static class Sub extends Parent {
    public static int B = A;
}
public static void main(String[] args) {
     System.out.println(Sub.B);
}

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

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

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

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章