16-JVM类加载机制

一、概述

类的加载是指将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并向Java程序员提供了访问方法区内的数据结构的接口。

wps5F9E.tmp

类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误

二、类的生命周期

类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载这七个阶段,其中验证、准备和解析三个阶段统称为连接,这7个阶段的发生顺序如图1所示。


图 1:类的生命周期

图1中,加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。注意这里写的是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用、激活另一个阶段。

三、类加载的时机

四、类加载的过程

Java虚拟机中类加载的全过程包括:加载、验证、准备、解析和初始化五个阶段。

1. 加载:查找并加载类的二进制数据

加载是”类加载过程”的第一个阶段。在加载阶段,虚拟机需要完成以下三件事情:

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

相对于类加载的其他阶段而言,一个非数组类的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段既可以使用系统提供的类加载器来完成,也可以由用户自定义的类加载器来完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式 (即重写一个类加载器的loadClass()方法)。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区中,然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。

加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

2. 连接

(1) 验证:确保被加载的类的正确性

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。从整体上看,验证阶段大致会完成4个阶段的检验动作:

  • 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。例如:是否以魔数0xCAFEBABE开头、主次版本号是否在当前虚拟机处理范围之内、常量池中的常量是否有不被支持的类型等。
  • 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。例如:这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)、这个类的父类是否继承了不允许被继承的类(被final修饰的类)等。
  • 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  • 符号引用验证:确保解析动作能正确执行。

对于虚拟机的类加载机制来说,验证阶段是非常重要的,但不是必须的阶段(因为对程序运行期没有影响)。如果所运行的全部代码(包括自己编写的及第三方包中的代码)都已经经过反复使用与验证,那么在实施阶段可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

(2) 准备:为类的静态变量分配内存,并将其初始化为默认值

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

对于该阶段有以下几点需要注意:

  • 这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一块分配在Java堆中。

  • 这里所说的初始值通常情况下是数据类型的零值(如0、0L、null、false等),而不是Java代码中被显式地赋予的值。
    假设一个类变量的定义为:public static int value = 123;那么变量value在准备阶段过后的初始值为0,而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是在程序编译后,存放于类构造器()方法之中的,所以把value赋值为123的动作将在初始化阶段才会执行。

  • 上面提到的在通常情况下初始值是零值,但也会有一些特殊情况:如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那在准备阶段变量value就会被初始化为ConstValue属性所指定的值。假设上面的类变量value被定义为:public static final int value = 123;编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3。

(3) 解析:把类中的符号引用转换为直接引用

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

  • 符号引用是以一组符号来描述所引用的目标,可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
  • 直接引用可以是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

3. 初始化

类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说字节码)。

初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:

  • 声明类变量是指定初始值
  • 使用静态代码块为类变量指定初始值

    JVM初始化步骤

  • 假如这个类还没有被加载和连接,则程序先加载并连接该类

  • 假如该类的直接父类还没有被初始化,则先初始化其直接父类
  • 假如类中有初始化语句,则系统依次执行这些初始化语句

类初始化时机

虚拟机规范严格规定了有且只有5种情况必须立即对类进行初始化(而加载、验证、准备自然需要在此之前开始):

  • 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候、以及调用一个类的静态方法的时候。
  • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  • 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

五、类加载器

六、相关问题

问:Java类加载时的初始化顺序

(1) 初始化父类中的静态成员变量和静态代码块(按照在程序中出现的顺序初始化)

(2) 初始化子类中的静态成员变量和静态代码块(按照在程序中出现的顺序初始化)

(3) 初始化父类中的普通成员变量和构造代码块(按照在程序中出现的顺序初始化),然后再执行父类中的构造方法

(4) 初始化子类中的普通成员变量和构造代码块(按照在程序中出现的顺序初始化),然后再执行子类中的构造方法

例1:

class Member {
    Member(String str) {
        System.out.println(str);
    }
}
class A {
    static {
        System.out.println("父类静态代码块");
    }
    public A() {
        System.out.println("父类构造函数");
    }
    {
        System.out.println("父类构造代码块");
    }
    Member member=new Member("父类成员变量");
}
class B extends A {
    Member member=new Member("子类成员变量");
    static {
        System.out.println("子类静态代码块");
    }
    public B() {
        System.out.println("子类构造函数");
    }
    {
        System.out.println("子类构造代码块");
    }
}
public class Test{
    public static void main(String[] args) {
        new B();
    }
}

//输出:
父类静态代码块
子类静态代码块
父类构造代码块
父类成员变量
父类构造函数
子类成员变量
子类构造代码块
子类构造函数

例2:下面代码的输出是什么?(易错)

public class B {
    public static B t1 = new B();
    public static B t2 = new B();
    {
        System.out.println("构造块");
    }
    static {
        System.out.println("静态块");
    }
    public static void main(String[] args) {
        B t = new B();
    }
}

// 输出

构造块
构造块
静态块
构造块

例3:下面代码的输出是什么?(易错)

public class Base {
    private String baseName = "base";

    public Base() {
        callName();
    }

    public void callName() {
        System.out.println(baseName);
    }

    static class Sub extends Base {
        private String baseName = "sub";

        public void callName() {
            System.out.println(baseName);
        }
    }

    public static void main(String[] args) {
        Base b = new Sub();
    }
}

// 输出:null

实例化子类对象时会先调用父类构造方法,由于父类构造方法调用了callName()方法并且子类重写了此方法,因此父类构造方法将调用子类的callName()方法将输出子类成员变量baseName的值。
但是由于子类的成员变量在父类构造方法调用完才会赋初值,因此调用callName()方法时,baseName值为null,所以输出结果为null。
分享到 评论