类加载

>>>>、类加载时机
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括了加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)七个阶段,其中,验证、准备和解析三个部分统称为连接(Linking),
这七个阶段的发生顺序,加载、验证、准备、初始化和卸载五个阶段的顺序是确定的,类加载过程必须按这个顺序开始,但解析阶段则不一定,某些情况下它可能在初始化之后进行,另外这些阶段通常都是互相交叉在混合进行的,在一个阶段执行过程中会调用或激活另一个阶段。

虚拟机没有规定什么时候开始加载,但是虚拟机严格规定了类的初始化时机有且只有下面四种,而加载、验证、准备过程显然在初始化之前进行。
1. 遇到new、getstatic、putstatic、invokestatic四条字节码指令时, 如果类没有进行过初始化,则需要触发其初始化。这四个指令分别对应使用new关键字实例化对象,调用静态但非final字段值,设置静态但非final字段值,调用静态方法。
2. 使用java.lang.reflect包的方法反射调用类的时候。
3. 当初始化一个类时,如果它的父类还没有初始化,要先初始化父类。
4. 虚拟机启动时,虚拟机会初始化用户指定的主类(含main方法的类),

此外所有引用都不会触发类的初始化,称被动引用,如:
1. 调用子类继承自父类的静态字段,子类不初始化。
2. 定义某类型的数组,它其实是实例化了一个数组对象,而数组类型对应的类不初始化。
3. 引用常量(public static final),常量在编译期会被替换成具体的值,编译之后就和常量所在的类没有任务关系了。

被动引用不会触发类初始化,但不代表类不被加载和验证,再次说明,虚拟机规范没有规定类加载和验证时机,不同虚拟机自由实现。

import org.junit.Test;

public class InitTest {

    /** 通过子类引用父类的静态字段, 不初始化子类. */
    @Test
    public void testStatic() {
        System.out.println(SubClass.value);
    }

    /**
     * 定义数组不初始化数组元素的类型.
     * 其实这里初始化了另外一个类,"[Lpackage.of.superclass.SuperClass",
     * 这是虚拟机自动生成的直接继承于java.lang.Object的类,
     * 用于描述SuperClass类型的一维数组.
     */
    @Test
    public void testArray(){
        SuperClass[] sca = new SuperClass[10];
        System.out.println(sca.length);
    }

    /** 引用常量, 编译之后常量引用已替换为常量值了 */
    @Test
    public void testFinal(){
        System.out.println(SuperClass.KEY);
    }
}

class SuperClass{
    static{
        System.out.println("SuperClass init! ");
    }
    public static final int KEY = 123;  //常量
    public static int value = 456;      //静态字段
}

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

 

接口的加载与类加载过程稍有不同,
接口无法通过静态代码块验证加载过程。
接口与类初始化区别在于类初始化时机中的第3种,
类初始化时要求其父类全部已初始化了,但接口初始化时不初始化父接口。

 

>>>>、类加载的加载阶段
加载阶段,jvm完成三件事:
1. 通过类的全限定名获取此类的二进制字节流;
2. 将字节流中的静态存储结构转化为方法区的运行时数据结构;
3. 在java堆中生成一个代表这个类的java.lang.Class类型对象,作为方法区数据的访问入口。

上面第1点是开发期可控性最强的,加载阶段可以使用系统提供的类加载器完成,也可以自定义类加载器,在这里虚拟机规范留下了很大的灵活度,它没有说一定要从class文件中获取字节流,于是我们可以:
①从zip包中获取,从而有了jar、ear、war格式;
②从网络中获取,典型应用是Applet;
③运行时计算生成,使用最多的无疑是动态代理技术了;
④由其他文件生成,如jsp;
⑤从数据库中读取,有些中间件服务器可以把程序安装到数据库中来完成代码在集群中的分发。

 

>>>>、类加载的验证阶段
为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全
虚拟机规范没有明确验证哪些内容,如何验证,什么时候验证,只是说如果字节流不符合class文件存储格式,就抛出java.lang.VerifyError类型异常。
不同虚拟机实现验证方式不同,但大体会有下面四个过程:

1. 文件格式验证
魔数、主次版本号、常量池中是否有不被支持的常量类型、指向常量的各种索引值中是否有指向不存在的常量,或者不符合类型的常量、CONSTANT_Utf8_info型常量中是否有不符合UTF8编码的数据......
经过这一验证之后,字节流会进入内存的方法区中进行存储,之后的验证全部基于方法区中的数据进行。

2. 元数据验证
对字节码描述的信息进行语义分析,保证其描述信息符合java语言规范
是否有父类、它的父类是否继承了不允许被继承的类(final类)、如果不是抽象类,是否实现了所有方法、类中的字段方法是否与父类矛盾(覆盖final,重载不条例规则...)

3. 字节码验证
字节码验证
这是最复杂的一个验证过程,主要是进行数据流和控制流分析,在元数据验证完成后,这阶段对类的方法体进行校验,保证方法在运行时不会做出危害虚拟机安全的行为:
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会在操作栈中放了一个int数据,去按long类型去取。
保证跳转指令不会跳转到方法体以外的字节码指令上。
保证方法体中的类型转换是有效的。
验证通不过肯定是有问题的,但是验证通过了,不能说明一定就没有问题,任务全面的验证都无法保证安全。
在离散数学中有一个著名的问题Halting Problem,放在此处意思大概就是,通过程序去校验程序逻辑是无法做到绝对准确的,不能通过程序准确地检查出程序是否能在有限的时间之内运行结束。

4. 符号引用验证
最后的校验发生在虚拟机将符号引用转为直接引用的时候,这个转化动作将在连接的第三个阶段——解析阶段中发生,符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常要校验以下内容:
符号引用中通过字符串描述的全限定名是否能找到对应的类、在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段、符号引用中的类字段和方法的访问性(private,protected,pubilc,default)是否可被当前类访问.

>>>>、类加载的准备阶段
准备阶段是正式为变量分配内存并设置类变量初始值的阶段。
这些内存都在方法区中分配。
注意这里分配内存的仅类变量,没有实例变量,实例变量在实例化时随对象一起分配到堆内存中。
注意这里说的初始值通常是数据类型的零值,如一个类变量定义:
public static int value = 123;
准备阶段完成之后,它的值为0,而不是123.
初始化阶段,执行类构造器<clinit>()方法中的putstatic指令执行后,它的值才是123.
但是,如果这个类变量是一个常量(final),类字段的字段属性表中存在ConstantValue属性,那在准备阶段会给它赋值为123.


>>>>、类加载的解析阶段
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程
符号引用:以一组长符号来描述所引用的目标,可以是任务形式的字面量,只要使用时能无歧义地定位到目标即可,与虚拟机实现无关,引用的目标不一定已经加载到内存中。
直接引用:可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,与虚拟机实现的内存布局相关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不相同,如果有了直接引用,引用目标必定已经在内存中存在。


>>>>、类初始化
初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户程序可以通过自定义类加载器参与之外,其余动作都是虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的java代码(字节码)
在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其它资源,或者可以从另外一个角度为表达,初始化阶段在是执行类构造器<clinit>()方法的过程。

<clinit>()方法的特征:
1. <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{})中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。

public class ClinitTest {
    public static int i;
    public static int j;
    static{
        i = 5;
        System.out.println(j);
        m = 5; // 可赋值

       // Cannot reference a field before it is defined
       // System.out.println(n);  // 编译通不过, 不可访问
    }
    public static int m;
    public static int n;
}

 

<clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕,因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object
由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作
<clinit>()方法对于类或者接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法,但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化,另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕,如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的.

posted @ 2019-09-23 17:23  LinuSiyu  阅读(107)  评论(0编辑  收藏  举报