1

在<深入理解Java虚拟机-周志明>这本书里面,在讲到类初始化的五种情况时,提及了一个比较有趣的事情。先来看看下面的代码

public class SubClass {
    static{
        System.err.println("I m your son");
    }
    public static final int name = 111;
}

这个时候如果调用SubClass.name,是根本不会触发SubClass初始化的(这里是因为name是一个常量,和下面的例子不一样,如果这里把final去掉,是会触发Subclass的初始化的,因为对于静态字段而言,如果静态字段被引用,就会调用getstatic指令和putstatic指令,那么自然就会引发类的初始化,详情看下面关于触发类初始化的五种情况)。再来看看另一种情况;

public class SuperClass {

    static{
        System.err.println("I am your father");
    }
    public static int value = 123;
}
public class SubClass extends SuperClass{
    static{
        System.err.println("I m your son");
    }
}

这个时候如果调用SubClass.value(静态字段和静态方法是可以继承但是无法被覆盖,所以这里调用value,只会导致直接定义这个静态变量的类被初始化),同样也是不会使得SubClass这个类进行初始化。那么问题来了,到底类在什么时候会进行初始化,类的初始化顺序到底是怎样的?让我们接着往下看。

一. 类加载的过程
虚拟机加载类主要有五个过程:加载、验证、准备、解析和初始化。

  1. 加载:加载是“类加载”的一个过程,希望读者没有混淆这两个概念。

在这个过程虚拟机主要完成三件事,
 通过一个类的全限定名___[解释全限定名]___来获取此类的二进制字节流,这点上,虚拟机并没有指明要从哪里获取类的二进制字节流,因此发展出了很多不一样的加载方式。比如jar,zip等压缩包中加载,从网络获取[如Applet],或者由其他文件生成[如从JSP生成]。
 将字节流所代表的静态存储结构转化为方法区的运行时数据结构。
 在Java堆[这个没有强制规定,比如HotSpot则选择在方法区中生成这个对象]中生成一个代表这个类的java.lang.Class对象,作为程序访问方法区中的各种数据的外部入口[也就是说当常量池表中的数据被转换成运行时数据结构的时候,实际上[堆/方法区]有一个Class对象的实例可以访问到方法区的各类数据,包括常量池表,代码等]。
如果加载对象是普通的类或者接口(统称为C),则是通过类加载器(L)去加载C的二进制表示来创建。但是如果加载的是数组类,那情况就有所不同了,数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。但是数组类内部的元素类型最终还是要靠类加载器去加载。[后续可以添加类加载器的详细解释]

  1. 验证

验证是链接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机本身的安全。验证大致上有以下4个过程:
1) 文件格式验证:
a) 检查魔数,主、次版本号是否在当前虚拟机处理范围。
b) 常量池的常量是否不被支持[通过检查tag],指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
c) CONSTANT_Utf8_info类型的常量中是否有不符合UTF8编码的数据。
d) Class文件中各个部分及文件本身是否有被删除或者附加其他信息等等。
这个节点的主要目的是保证输入的字节流能被正确的解析并存储于方法区内,格式上符合描述一个java类型信息的要求。这个阶段是基于二进制流,只要通过了这个阶段的验证,字节流才会进入内存的方法区中存储。所以后续的三个阶段基于方法区的存储结构进行的,不会再直接操作字节流。
2) 元数据验证:这个阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。主要验证包括以下几点:
a) 这个类是否有父类(除了java.lang.Object外,所有的类都应该有父类)。
b) 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
c) 如果这个类不是抽象类,那么应该实现其父类或接口中要求实现的方法。
d) 类中的字段,方法是否与父类相矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载)。
这个阶段主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java规范的元数据信息。
3) 字节码验证:
4) 符号引用验证:

  1. 准备

准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,这些变量所使用的内存都将在方法区中分配。这里有几个值得注意的点:
1) 这里初始化的仅仅是类变量(被static修饰的变量)的初始化,并不包括实例变量。实例变量将会在对象实例化的时候随着对象一起分配在java堆中。
2) 这里所说的初始值,通常是数据类型的零值,举个例子:
public static int value = 123;
这句代码中,value在准备阶段的初始值为0,而不是123,因为这个时候还没开始执行任何的java方法。而把value的值置为123的putstatic指令是程序被编译后,存放在类构造器<clinit>()方法中的。所以value置为123是在初始化[第五阶段]阶段才会执行。[还有一些其他类型的零值,可以参考虚拟机规范]
当然,上述情况也有例外的地方,如果类字段的字段属性表(参考class文件中的属性数据结构)中存在ConstatntValue[即同时被final和static修饰]属性,那么在准备阶段,变量value就会被初始化为ConstantValue属性所指定的值,例如上述变量中,编译时javac将会为value生成的ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue属性而将value赋值为123。

  1. 解析

解析阶段就是虚拟机将常量池内的符号引用[使用一组描述符来描述所引用的目标,符可以是任意形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中]替换为直接引用[直接引用可以是直接指向目标的指针,相对偏移量或者一个能间接定位到目标的句柄。直接引用与内存的布局有关,如果有了直接引用,则目标一定存在]的过程,符号引用在Class文件内的常量池中以CONSTANT_Fieldref_info,CONSTANT_Class_info,CONSTANT_Methodref_info等类型出现。那么,解析阶段中的直接引用于符号引用又有什么关联呢?
对同一个符号引用进行多次解析请求是很常见的,比如你在代码里面多次new同一个类。这里要分成两种情况:
1) invokeddynamic指令:这个指令的特殊之处在于,它是为了支持动态语言而存在的,也就是说,必须等到程序实际运行这条指令的时候,解析动作才能进行[目前仅使用java语言并不会生成这条指令]。相对的,其余触发的解析指定都是“静态”的,可以在刚刚完成加载阶段,还没开始执行代码时就进行解析。
2) 除了上述的指令外,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)。从而避免了多次解析。
解析动作主要针对“类或接口”,“字段”,“类方法”,“接口方法”,“方法类型”,“方法句柄”和“调用点限定符”7类符号引用进行[分别对应7种常量池表的CONSTATN_Class_info,CONSTATN_Fieldref_info,CONSTATN_Methodref_info,CONSTATN_InterfaceMethodref_info,CONSTATN_MethodType_info,CONSTATN_MethodHandle_info,CONSTATN_InvokeDynamic_info,后续三种和动态类型有关,目前java还是静态类型语言]。

  1. 初始化

在虚拟机中严格规定需要对类进行初始化的,有下面五种情况:
1) 遇到new,getstatic,putstatic或者invokestatic这4条字节码指令时。
2) 使用java.lang.reflect包的方法对类进行反射调用的时候。
3) 当初始化一个类,发现其父类并没有初始化时,需要先初始化父类。
4) 虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的类),虚拟机会先初始化这个类。
5) 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有初始化,则需要先触发其初始化。
对于以上五种初始化场景,虚拟机规范中使用了“只有”,除此之外,所有的引用类的方式都不会触发初始化。


chasel
280 声望10 粉丝

想造轮子。