深入理解JVM虚拟机——7.虚拟机类加载机制

7.1 概述

本章讲解虚拟机如何加载class文件以及class文件进入虚拟机后会发生什么变化。

虚拟机把描述类的数据从class文件加载到内存,并对数据校验、转换解析和初始化,最终成为虚拟机可以使用的Java类型。而这些工作不需要进行连接,直接在运行时完成,这种方式灵活性强但会增加性能开销。


7.2 类加载的时机

类从被加载到虚拟机内存开始到卸载出内存的生命周期有七个阶段。

  1. 加载(Loading)
  2. 验证(Verification)
  3. 准备(Preparation)
  4. 解析(Resolution)
  5. 初始化(Initialization)
  6. 使用(Using)
  7. 卸载(Unloading)
    验证,准备,解析三个阶段统称为连接(Linking)

1.加载
2.验证
3.准备
5.初始化
7.卸载五个阶段的顺序是确定的,同样的,解析阶段是不一定的,某些情况下可以在初始化后开始,这是为了支持动态绑定。

什么时候开始加载虚拟机并没有强制约束,但是在以下5种情况必须立即对类进行初始化:

  1. 遇到new、getstatic、putstaticinvokestatic这4条字节码指令时,如果类没有进行过初 始化,则需要先触发其初始化。
  2. 使用java.lang.reflect包的方法对类进行反射调用时,如果类没有进行初始化则需要先触发其初始化。
  3. 当初始化一个类时,如果其父类还没有初始化,则先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包括main()的类),虚拟机先初始化这个类。
  5. 使用JDK1.7后的动态语言支持时,如果一个java.lang.invoke.MethodHandl实例最后的解析结果REF_getStatic,REF_putStatic,REF invokeStatic的方法句柄,并且方法句柄对应类没有初始化时,则先触发初始化。

这五种场景称为对一个类的主动引用,除此之外,所有引用类的方式都不会触发初始化,称为被动引用

被动引用例子一

/**
*通过子类引用父类的静态字段不会触发初始化
*/
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 NoInitialization{
    public static void main(String[] args){
        System.out.println(SubClass.value);
    }
}

上述代码运行后会直接输出"SuperClass init"而不会输出"SubClass init"。
对于静态字段,只有直接定义这个字段的类才会被初始化,所以当子类引用父类的静态字段时,只会触发父类的初始化而不是子类的。

被动引用例子二

/**
*通过数组定义引用类,不会触发此类的初始化
*/
public class NoInitialization{
    public static void main(String[] args){
        System.out.println(SubClass.value);
    }
}

这段代码复用了上面的类定义,运行之后并没有输出"SuperClass inti",但是这段代码会触发一个由虚拟机自动生成的另一个类名相同但包名不同直接继承自Object的子类,这个类代表了一个元素类型为原类的一维数组。

被动引用例子三

/**
*常量会在编译阶段存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发类的初始化
*/
public class ConstClass{
    static{
        System.out.println("ConstClass init");
    }
    public static final String HELLOWORLD = "hello world";
}
public class NoInitialization{
    public static void main(String[] args){
        System.out.println(ConstClass.HELLOWORLD);
    }
}

上述代码执行时不会输出"ConstClass init",这是因为编译时通过常量传播优化,将此常量的值存储到了ConstCLass的类常量池中。

另外接口的初始化过程与类基本一致,真正的区别只在五种情况的第三种:接口初始化时不要求其父接口全部初始化,只有在用到父接口时才会初始化


7.3 类加载的过程

这一章详解了类加载的全过程,也就

sequenceDiagram
A->>B: How are you?
B->>A: Great!
graph LR
A-->B

是加载、验证、准备、解析和初始化5个阶段的具体动作。

7.3.1 加载

这里不要混淆,加载是类加载的一个过程。

加载阶段虚拟机要完成三件事情

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

一个非数组类的加载阶段是程序员可控性最强的,因为加载阶段可以通过系统的引导类加载器完成或者由用户自定义的类加载器完成。
对于数组类,它是由Java虚拟机直接创建的(不通过类加载器)。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区当中,格式由虚拟机自行定义。

7.3.2 验证

验证是连接阶段的第一步,这一阶段确保Class文件的字节流种包含的信息符合当前虚拟机的要求,并且不会危害虚拟机本身。

虽然Java语言本身是相对安全的,但是例如数组越界跳转不存在的代码行之类的操作会让编译器拒绝编译。Class文件可以来自于任何地方不仅限于Java代码,甚至可以自己编写,也就是说上述操作可以直接通过编辑字节码实现。在字节码语言层面上这些操作如果虚拟机不加以检查而直接通过的话会导致系统崩溃,所以验证阶段是自我保护的一项必要工作。
验证阶段大致包括四个动作

  1. 文件格式检验: 检查字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。列举一小部分验证点
    • 是否以魔数0xCAFEBABE开头。
    • 主、次版本号是否在当前虚拟机处理范围之内。
    • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
    • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
      通过这个阶段的验证后,字节流进入内存的方法区存储,所以后面三个动作是基于方法区的存储结构,不直接操作字节流。
  2. 元数据验证: 对字节码描述的信息进行语义分析,以保证符合Java语言规范,列举一小部分验证点
    • 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
    • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
  3. 字节码验证: 这个阶段通过数据流和控制流的分析确定程序语义是合法的、符合逻辑的,保证类的方法在运行时不会做出危害虚拟机安全的事件,例如:
    • 保证任意时刻操作数栈的数据类型与指令代码序列都可以配合工作。
    • 保证跳转指令不会跳转到方法体以外的字节马上。
    • 保证方法体种类型转换是有效的。
      由于数据流的复杂性,虚拟机团队给方法体的code属性的属性表增加了一项 “StackMapTable” 的属性,这项属性描述了基本块开始时本地变量表和操作站应有的状态,在验证期间就不用推导,只需要检查此属性中的记录是否合法。
  4. 符号引用验证: 这个阶段发生在虚拟机将符号引用转化为直接引用的时候,对类自身以外的信息进行校验匹配。
    • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
    • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
    • 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被 当前类访问。

7.3.3 准备

准备阶段是正式为类变量分配内存并设置变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

这里分配的时候包括类变量而不包括实例变量,而初始值通常情况下是数据类型的零值,真正赋值的动作是在初始化阶段才执行。

7.3.4 解析

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

  • 符号引用(Symboil Reference): 符号引用以一组符号来描述所引用的目标,符号引用与内存无关,引用的目标不一定要加载到内存中。
  • 直接引用(Direct Reference): 直接引用是直接指向目标的指针、相对偏移量或者一个间接定位到目标的句柄,直接引用和内存布局是相关的,如果有了直接引用,引用目标在内存中必定存在。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,书中介绍了前面4种,后面三种与动态语言相关。

  1. 类或接口的解析: 假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用则需要以下三个步骤:
    1. C不是数组类型,虚拟机把N的全限定名称传递给D的类加载器去加载C,加载过程中由于验证的需要,有可能触发其他相关类的加载动作,例如父类和实现的接口,一旦出现异常,解析过程失败。
    2. C是数组类型,并且数组的元素类型为对象,将会按照第一点的规则加载元素类型,接着虚拟机生成一个代表此数组维度和元素的数组对象。
    3. 如果没有出现异常,C在虚拟机中实际已经成为一个有效的类或接口了,但在解析完成后还要进行符号引用验证以确保D是否具备对C的访问权,如果不具备访问权抛出java.lang.IllegalAccessError异常。
  2. 字段解析:
    要解析一个未被解析过的字段符号引用,首先会对字段所属的类或接口的符号引用进行解析,如果解析成功将这个字段所属的类或接口用C表示,虚拟机按照以下步骤对C进行后续字段的搜索:
    1. 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    2. 否则,如果在C中实现了接口,则按照继承关系从上往下递归搜索各个接口和它的父接口,然后进行步骤1。
    3. 否则,如果C不是java.lang.Object的话,将会按照继承关系从上往下搜索其父类,然后进行步骤1。
    4. 否则,查找失败抛出java.lang.NoSuchFieldError异常。
      如果成功返回了引用,将会对字段进行权限验证,如果不具备访问权限则抛出java.lang.IllegalAccessError异常。
  3. 类方法解析: 类方法解析的第一个步骤与字段解析一样,然后按照以下步骤进行后续的类方法搜索。
    1. 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index所引的C是个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
    2. 如果通过了第1步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法, 如果有则返回这个方法的直接引用,查找结束
    3. 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如 果有则返回这个方法的直接引用,查找结束。
    4. 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符 都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时查找结束,抛 出java.lang.AbstractMethodError异常。
    5. 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。
      最后,如果成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备此方法的访问权限则抛出java.lang.IllegalAccessError异常。
  4. 接口方法解析: 接口方法也需要先解析出接口方法表的class_index[4]项中索引的方法所属的类或接口的符 号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续 的接口方法搜索。
    1. 与类方法解析不同,如果在接口方法表中发现class_index中的索引C是个类而不是接 口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
    2. 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返 回这个方法的直接引用,查找结束。
    3. 否则,在接口C的父接口中递归查找,直到java.lang.Object类(查找范围会包括 Object类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方 法的直接引用,查找结束。
    4. 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
      由于接口中的所有方法默认都是public的,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出java.lang.IllegalAccessError异常。是

7.3.5 初始化

初始化阶段才真正开始执行类中定义的Java程序代码。

在准备阶段,变量赋过一次初始值,在初始化阶段,根据程序员的通过程序制定的计划去初始化类变量和其他资源也就是执行类构造器()方法的过程。静态语句块只能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值但是不能访问。

public class Test{
    static{
        i = 0;//给变量赋值可以正常编译通过
        System.out.println(i);//这句编译器提示"非法向前引用“
    }
    static int i = 1;
}

虚拟机会保证子类的()方法执行之前,父类的()方法已经执行完毕。
也就意味着父类的静态代码块要优于子类的变量赋值操作。

7.4 类加载器

类加载阶段中的"通过一个类的全限定名来获取描述此类的二进制字节流"这个操作放大虚拟机外部实现,以便让程序自己决定如何获取所需的类。实现这个动作的代码块叫做 “类加载器”

7.4.1 类与类加载器

比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来自同一个Class文件,只要它们类加载器不同,这两个类必定不相等。

7.4.2 双亲委派模型

从Java虚拟机角度讲,只存在两种不同的类加载器。

  • 启动类加载器(BootStarp ClassLoader): C++语言实现,虚拟机自身的一部分
  • 其他类加载器: Java语言实现,独立于虚拟机外部。

从开发人员的角度看,类加载可以划分的更细致,以下三种是常用的:

  • 启动类加载器(BootStarp ClassLoader): 启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,直接使用null代替。
/**
*java.lang.ClassLoader.getClassLoader代码片段
*/
public ClassLoader getClassLoader(){
    ClassLoader cl = getClassloader();
    if(cl == null){
        ClassLoader ccl = ClassLoader.getCallerClassLoader();
        if(ccl!=null&&ccl!=cl&&!cl.isAncestor(ccl)){
            sm.checkPermission(SecurityConstants.GET_CLASSLOADER_PERMISSION);
        }
    }
    return cl;
}

  • 扩展类加载器(Extension ClassLoader): 负责加载\lib\ext目录中的,开发者可以直接使用。
  • 应用程序类加载器(Application CLassLoader): 负责加载用户类路径上所制定的类库,开发者可以直接使用,如果应用程序中没有自定义过自己的类加载器,这个就是程序的默认的类加载器。

这些类加载器关系如图

深入理解JVM虚拟机——7.虚拟机类加载机制_第1张图片

这种层次关系称为类加载器的双亲委派模型(Parents Delegation Model)。这个模型要求除了启动类加载器,其他的类加载器都应当有自己的父类加载器。
它的工作过程是:如果一个类收到了类加载的请求,首先不会自己尝试加载这个类,而是委派给父类加载器完成,因此所有的请求都会传送到顶层的启动类加载器当中,当父类加载器反馈自己无法完成时,子加载器才会自己尝试完成。

实现模型的代码很简单:先检查是否已经被加载过,若没有调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,则抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。

protected synchronized Class<?>loadClass(String name,boolean resolve)throws ClassNotFoundException { 
    //首先,检查请求的类是否已经被加载过了 Class 
    c = findLoadedClass(name); 
    if(c==null){ 
        try{ 
            if(parent!=null){  
               c=parent.loadClass(name,false);
            }else{
                c=findBootstrapClassOrNull(name);
            }
        }catch(ClassNotFoundException e){
            //如果父类加载器抛出ClassNotFoundException 
            //说明父类加载器无法完成加载请求 
        } 
        if(c==null){
        //在父类加载器无法加载的时候
        //再调用本身的findClass方法来进行类加载 
            c=findClass(name); 
        }
    }
    if(resolve){
        resolveClass(c);
    } 
    return c;
}

7.4.3 破坏双亲委派模型

双亲委派模型有过三次较大规模的被破坏的情况。

  1. 在JDK1.2之前,用户去继承ClassLoader的目的就是重写loaderClass()方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass()。
  2. 双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API,如果基础类又要调用回用户的代码,那就没有办法了。
  3. 这次被破坏是用户追求程序动态性导致的(热更新之类的)
    1. 将以java.*开头的类委派给父类加载器加载
    2. 否则,将委派列表名单内的类委派给父类加载器加载。
    3. 否则,将Import列表中的类委派给Export这个类的Bundle的类加载器加载。
    4. 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
    5. 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的 类加载器加载。
    6. 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
    7. 否则,类查找失败。
      上面的查找顺序中只有开头两点仍然符合双亲委派规则,其余的类查找都是在平级的类 加载器中进行的

你可能感兴趣的:(深入理解JVM)