一、类加载的时机
《Java虚拟机规范》严格规定了有且只有六种情况必须对类进行初始化(加载、验证、准备需要在这之前开始):
-
遇到四条字节码指令,分别是new(new关键字实例化对象)、getstatic(读取静态字段,除final修饰外)、putstatic(设置静态字段,除final修饰外)、invokestatic(调用静态方法)。
-
对类型进行反射调用
-
子类初始化触发,如父类无初始化,则先触发父类初始化
-
执行某个主类(main()方法存在的类)
-
被default关键字修饰的接口方法,如该接口实现类初始化,则该接口先初始化
以上六种场景的行为称为对一个类型进行主动引用,其他所有引用类型的方式都不会触发初始化,称为被动引用。
二、类的生命周期
一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中验证、准备、解析统称为连接。
其中加载、验证、准备、初始化和卸载这五个阶段顺序确定。解析阶段也有可能在初始化后开始。 这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
1.加载
加载为类加载的第一个阶段,这个阶段虚拟机需要完成下面三件事:
1.通过一个类的全限定名来获取其定义的二进制字节流。
2.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3.在java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
2.验证
验证的目的在于确保class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身的安全,主要包括四种验证:文件格式的验证,元数据的验证,字节码验证,符号引用验证。
3.准备
正式为类变量(静态变量)分配内存并设置类变量的初始值(不包括被final修饰的静态变量,因为final在编译的时候就已经分配了)。这里不会为实例变量分配初始值,类变量会分配在方法区中,实例变量会随着对象分配到Java堆中
4.解析
解析阶段主要是把常量池中的符号引用替换成直接引用。
那什么是符号引用,什么是直接引用呢?书上是这么说的
-
符号引用: 以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。
-
直接引用可以是:
1.直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)
2.相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
3.一个能间接定位到目标的句柄
-
直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。
看到这里你也许还是不清楚符号引用和直接引用到底是什么?下面我举个简单的例子:
符号引用即用(用字符串符号的形式)来表示引用,其实被引用的类、方法或者变量还没有被加载到内存中。而直接引用则是有具体引用地址的指针,被引用的类、方法或者变量已经被加载到内存中。
String str = "abc";
//符号引用
System.out.println("str="+str);
//直接引用
System.out.println("str="+"abc");
符号引用要转换成直接引用才有效,这也说明直接引用的效率要比符号引用高。那为什么要用符号引用呢?
原因有两个:一是为了代码逻辑清晰整洁,二是因为类加载之前,javac会将源代码编译成.class文件,这个时候javac是不知道被编译的类中所引用的类、方法或者变量他们的引用地址在哪里,所以只能用符号引用来表示 。
回到解析阶段,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这七类符号引用进行,分别对应于常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dynamic_info和CONSTANT_InvokeDynamic_info8种常量类型。
5.初始化
初始化阶段是类加载过程的最后一个步骤, 到了此阶段,才真正开始执行类中定义的Java程序代码。 在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个形式来表达:初始化阶段是执行类构造器<clinit>()方法的过程。
<clinit>()方法是javac的自动生成物,下面是它的执行规则:
1、<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。 2、<clinit>()方法与实例构造器<init>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。 3、<clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。 4、接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。但是接口鱼类不同的是:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。 5、虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。