虚拟机(5)虚拟机类加载机制

1. 类加载时机

    类从被加载到虚拟机内存中开始,到被卸载出内存为止,生命周期包括:加载,验证,准备,解析,初始化,使用,卸载。7个阶段。

    加载,验证,准备,初始化和卸载着5个阶段是顺序确定的,必须按照这个顺序开始,而解析阶段不一定,在某些情况下可以在初始化阶段之后再开始。

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

  1. 遇到了new,getstatic,putstatic,invokestatic这4条字节码指令时,如果没有被初始化,就需要先出发其初始化。这4条指令最常见的java代码场景是:使用new实例化对象读取设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候、调用一个类的静态方法的时候。
  2. 使用反射调用的时候,如果没有初始化,需要先触发初始化。
  3. 初始化一个类时,发现父类没有初始化,先触发其父类的初始化。
  4. 虚拟机启动时,用户执行的主类(main方法那个类),虚拟机要先初始化这个类
  5. 使用了java.lang.invoke.MethodHandle实例最后解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个句柄对应的类没有进行初始化,则需要先触发其初始化。

    以上5种情况的行为成为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。

下面列举三个被动引用的例子

示例1

/**
 * 被动使用类字段演示,通过子类引用父类的静态字段,不会触发子类的初始化
 */
public class Demo1 {

    public static void main(String[] args) {
        System.out.println(SupperClass.value);
    }
}

class SuperClass {
    static {
        System.out.println("SuperClass init");
    }

    public static int value = 123;
}

class SupperClass extends SuperClass{
    static {
        System.out.println("SupperClass init");
    }
}

    上面的例子中,不会加载子类,对于静态字段,只有直接定义这个字段的类才会被初始化。

示例2

public class Demo2 {

    public static void main(String[] args) {
        SuperClass[] superClasses = new SuperClass[10];
    }
}

class SuperClass {
    static {
        System.out.println("SuperClass init");
    }

    public static int value = 123;
}

class SupperClass extends SuperClass{
    static {
        System.out.println("SupperClass init");
    }

}

    示例2中同样也不会去触发类的初始化,不过会触发【数组类】的初始化,它是一个有虚拟机自动生成的,直接继承Object的子类,创建动作有字节码指令newarray触发。

示例3

/**
 * 被动使用类演示3,常量在编译阶段就存入调用类的常量池中,本质上并没有引用到定义常量的类,不会触发定义常量类的初始化
 */
public class Demo2 {

    public static void main(String[] args) {
        System.out.println(SuperClass1.value);
    }
}

class SuperClass1 {
    static {
        System.out.println("SuperClass init");
    }

    public final static String value = "ABC";
}

    示例3也没有初始化SuperClass1这个类,原因就是在编译阶段通过常量的传播优化,已经将此常量的值存储到了Demo2类的常量池中。以后对常量的引用实际都被转化为对Demo2类自身常量池的引用了,就是说Demo2的class文件中并没有SuperClass1类的符号引用入口。编译后,这两个类就没有任何联系了。

接口加载过程

    接口的加载过程有一些特殊说明:接口也有初始化过程,不过接口没有static静态语句块,但是编译器影人会为接口生成<clinit>()类构造器,用于初始化接口中所定义的成员变量。

    接口和类的初始化场景中,只有在对父类初始化上面有所不同,接口初始化的时候不要求对其父类加载,只有在使用到父类的时候(如引用接口中定义的常量)才会初始化。

2. 类加载过程

   2.1 加载

    “加载”是“类加载”过程的一个阶段,加载阶段完成以下三件事情:

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

    2.2验证

    验证是连接阶段的第一步,目的是为了保证Class文件的字节流中包含的信息符合当前虚拟机的要求,并缺不会危害虚拟机自身的安全。

    验证阶段大致上会完成4个阶段的检验动作:文件格式验证元数据验证字节码验证符号引用验证

  • 文件格式验证
    • 是否已魔数0xCAFFBABE开头
    • 主次版本号是否在当前虚拟机处理范围之内
    • 常量池的常量中是否有不被支持的常量类型
    • 。。。。。
  • 元数据验证
    • 类是否有父类,除了Object之外,所有类都应该有父类
    • 类的父类是否继承了不允许被继承的类
    • 类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
    • 类中的字段、方法是否与父类产生矛盾,例如覆盖了父类的final字段,或者出现不服了规则的方法重载(方法参数都一致,返回值类型却不同)
    • 。。。
  • 字节码验证
    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如在操作栈放了一个int类型,使用时却按long类型来加载如本地变量表
    • 保证跳转至零不会跳转到方法体以外的字节码指令上
    • 保证方法体中的类型转换时有效的
    • 。。。。
  • 符号引用验证  这个阶段发生在虚拟机将符号引用转化为直接引用的时候,这个转化将在链接的第三阶段,解析阶段发生
    • 符号引用中通过字符串描述的全限定名是否能找到对应的类
    • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
    • 符号引用中的类,字段,方法的访问性是否可被当前类访问

2.3 准备

    准备阶段时正式为类变量分配内存设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这里进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。

例如一个类变量定义为:

    public static int value = 123;

    那这个变量value在准备阶段过后的初始值为0而不是123,因为这个时候尚未开始执行任何java方法,而把value赋值为123 的putstatic指令是程序被编译后,存放在类构造器<clinit>()方法之中,所以把value赋值为123的做动作将在初始化阶段才会执行。

例如将上面的类变量改为:

    public static final int value = 123;

    上面示例中在编译时将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

2.4解析

    解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,在CLass文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
  • 直接引用(Direct References):直接引用可以是直接指向目标的指针,相对偏移量或事一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用,那引用的目标必定已经在内存中存在。

    解析动作主要针对类或接口,字段,类方法,接口方法,方法类型,方法句柄,和调用点限定符,7类符号引用进行。

1. 类或接口解析

    假设当前代码所处的类为D ,把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,分为以下3个步骤:

  • 如果C不是一个数组类型,那虚拟机会把N的全限定名传递给D的类加载器去加载这个类C。
  • 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,那就按照第一点的规则加载数组元素类型。还需要加载的元素类型java.lang.integer,接着由虚拟机生成一个代表此数组维度和元素数组对象。
  • 如果上面的步骤中没有任何异常,再确认D是否有C的访问权限,如果没有,抛出异常

2. 字段解析

    解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或者接口的符号引用。如果没有问题的话,后续对字段所属的类或者接口进行搜索。如果用C表示的话,过程如下

  • 如果字段C本身就包含了简单名称和字段描述符都与目标相匹配,就会返回字段的直接引用,查找结束。
  • 否则,在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标匹配的字段,就会返回直接引用,查找结束。
  • 否则,如果C不是object的话,将会按照继承关系从下往上递归搜索父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回直接引用,查找结束。
  • 否则,查找失败,报告异常
  • 查找成功后,还会验证是否有该字段的权限,如果没有也抛出异常

3. 类方法解析

    类字段解析的第一个步骤和字段解析一样,也是要去类方法表的class_index项中索引的方法所属的类或接口的符号引用。 如果没问题,也用C来表示这个类,然后虚拟机将会按照如下步骤进行后续类方法搜索。

  • 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中的索引的C是个接口,直接报错。
  • 如果上面的步骤通过了,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有直接返回这个方法的直接引用,查找结束。
  • 否则,在类C的父类中递归查找是否有,如果有,查找结束。
  • 否则,在类C的实现的接口列表及他们的父接口中递归查找是否有,如果有,说明类C是否抽象类,查找结束,抛出异常。
  • 否则,查找失败,抛错
  • 查找成功后,还会验证是否有权限,如果没有也抛出异常。

4. 接口方法解析

    接口方法也需要先解析出接口方法表的class_index项中哦索引的方法所属的类或接口的符号引用,如果解析成功,用C表示这个接口。虚拟机按照如下方法进行搜索

  • 与类方法解析不同,如果在接口方法表中发现class_index中的索引C是个类而不是接口,抛出错误。
  • 否侧,在接口C中查找是否有简单名称和描述符与目标相匹配的方法,如果有,返回直接引用,查找结束。
  • 否侧,在接口C的父接口中递归查找,直到object类(查找范围会包括object类)为止,如果有返回直接引用,查找结束。
  • 否侧,抛出错误。
  • 由于接口中的方法默认都是public的,所以不存在权限的问题。

2.5 初始化

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

    在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段是执行类构造器<clinit>()方法的过程,可以理解成为static{}快。

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句快(static{}快)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句快中只能访问到定义在静态语句快之前的变量,在前面的静态语句快可以赋值,但是不能访问。
    public class Test {
    
        static{
            //赋值可以
            i = 1;
            //访问就报非法向前引用
            System.out.println(i);
        }
        static int i = 0;
    }
  • <clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法类肯定是java.lang.Object。
  • 由于父类的<clinit>()方法先执行,所以父类中定义的静态语句快要优先于子类的变量赋值操作。
  • <clinit>()方法对于类或者接口来说不是必须的,如果类没有静态语句快,也没有变量的赋值操作,编译器可以不为这个类生成<clinit>()方法。
  • 接口中不能使用静态语句快,但是会有变量初始化赋值操作,因此接口和类一样都会生成<clinit>()方法,但是接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁,同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果类的<clinit>()方法中有耗时时间很长的操作,就可能造成多个进程阻塞。需要注意的是,其他线程虽然会被阻塞,但如果执行<clinit>()放发的那条线程退出<clinit>()方法后,其他线程唤醒之后不会再次进入<clinit>()方法,同一个类加载器下,一个类型只会初始化一次

 

转载于:https://my.oschina.net/u/3885275/blog/2874671

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值