文章目录
上一节虚拟机加载机制——类加载时机中我们提到类从被虚拟机加载到内存中到从内存中卸载的生命周期。这一节我们来具体谈一下生命周期里面的几个阶段加载、连接(验证、准备、解析)、初始化、使用、卸载。
一、加载
在加载阶段需要进行三个步骤:
- 通过一个类的全限定名获取类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在方法区生成这个类的java.lang.Class对象,作为这个类的各种数据的访问入口。
获取二进制流可以从磁盘中获取,也可以从网络上,zip包等获取。
对于非数组类的加载阶段,可能是程序员在类加载中可控性最强的阶段,程序员可以自己编写类加载器加载类,也可以通过系统提供的引导类加载器完成。然而对于数组类的加载阶段,加载是有java虚拟机直接创建的,而不是通过类加载器完成加载的。
二、连接之验证
验证阶段是为了保证Class文件的字节流是满足当前虚拟机要求的,不危害虚拟机自身安全的。
《java虚拟机规范》定义了验证的规则,大体可分为文件格式验证、元数据验证、字节码验证、符号引用验证四个阶段。
文件格式验证:
验证Class文件字节流是否符合Class文件格式,并且是否能被当前版本虚拟机处理。比如验证魔术是否为0xCAFFBABE开头、版本号是否在当前虚拟机处理范围之内等。
元数据验证:
对字节码描述的信息进行语义分析,是否符合java语言规范。如验证这个类是否有父类,这个类是否继承了不被允许继承的类(final修饰的类)
字节码验证:
对数据流和控制流(方法体)分析,确保程序语义是合法的、符合逻辑的。
符号引用验证:
这个验证发生在连接的第三阶段解析,将符号引用转换为直接引用。该验证可以看成是对类自身以为(常量池中的各种符号引用)的信息进行匹配性验证。
三、连接之准备
准备阶段:
准备阶段是正式为类变量(用static修饰)分配内存空间和初始化(初始化为相应的零值)的阶段。分配空间是分配在方法区的。
需要注意的是,一般情况下类变量在这个阶段初始化为零值。如下:
class A{
//在准备阶段,a初始化为0而不是3
public static int a=3;
}
这个阶段初始化a的值为0,而不是3。因为这个阶段尚未执行java代码。把a赋值为3是在后面的初始化阶段执行的。
然而,特殊情况,是常量。也就是类字段的字段属性表中存在ConstantValue时,那么a就被初始化为ConstantValue指定那个值。如下:
class A{
//在准备阶段,a初始化为ConstantValue指定的3.
public static final int a=3;
}
四、连接之解析
解析阶段虚拟机将常量池中符号引用转换直接引用的阶段。
4.1 准备工作
符号引用:
符号引用用一组符号描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位目标就行。符号引用于内存布局无关,引用目标不一定在内存中。
直接引用:
直接引用可以是直接指向引用目标的指针、相对偏移量或是一个能够间接指向目标的句柄。直接引用于虚拟机的内存布局有关。如果有了直接引用,那么引用目标就一定存在于内存中。
就好比学生和教室。
符号引用就是学生(引用目标)的姓名,而此时学生在不在教室(内存)里面是不确定的、无关的。而直接引用就是学生坐在教室的那个位置,既然已经知道了学生坐在教室那个位置,那么学生就一定在教室(内存)里面了。
解析阶段时间
《java虚拟机规范》并没有指定解析的具体时间。只要求了在anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeiterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multanewarray、new、putfield、putstatic这16个操作符号引用的字节码指令之前,先对符号引用进行解析就行。
解析结果缓存及特例
对一个符号引用请求多次解析是常有的事情。除了invokedynamic指令外,java虚拟机提供了缓存机制对解析的结果进行缓存,避免多次重复解析符号引用。invokedynamic是用来支持动态语言的,“动态”是指等到程序运行到这条指令时,解析才开始。
4.2 类或接口解析
首先我们查看常量池中的类和接口的符号引用的表结构:
其中u1用来表示该常量是属于哪一类常量(类和接口的全限定名、自动名称和描述符、方法名称和描述符)。u2指向常量池中类或接口的全限定名。
现在我们来开始类或接口的解析,首先假设当前所处的类叫做D,如果要把一个未经解析的符号引用N解析一个类或接口C的直接引用,要经过一下三步:
- 如果C不是一个数组类型,那么虚拟机将会把符号引用N的全限定名(u2指向的常量池中的全限定民)传递给D的类加载器区加载。由于验证的需要,有可能会触发其他相关类的加载动作(如C的父类)。一旦这个步骤有问题,宣告解析失败。
- 如果C是一个数组类型,并且数组的元素类型是对象,那么就会第一步的规则加载元素类型。加载元素类型成功后,由虚拟机生成一个代表次数组维度和元素的数组对象。
- 如果上面步骤没有出现问题,那么C已经在虚拟机内成为一个有效的类或接口了。在解析完成之前,还有验证D对C的访问权限,如果D不具备访问权限,就会抛出java.lang.IllegalAccessError。
4.3 字段解析
首先也是字段在常量池的的结构表:
tag,用于表明该常量类型,第一个index表示该字段所属的类或接口,第二个index表示当前字段的描述符。
现在开始字段解析,首先进行字段所属的类或接口解析。如果失败,导致字段符号引用解析失败;如若成功,那么将字段所属的类或接口用C表示,然后进行如下步骤:
- 如果C本身就存在简单名称和字段描述符和目标字段匹配(第二个index)的字段,那么就返回这个字段的直接引用。字段查找结束。
- 否则,如果C实现了接口,将会按照继承关系,从下往上递归查找接口及其父接口是否存在简单名称和字段描述符与目标字段匹配的字段,如果存在就返回这个字段的直接引用。字段查找结束。
- 否则,如果C不是java.lang.Object的话,虚拟机将会从下往上递归查找父类中是否存在简单名称和字段描述符与目标字段匹配的字段,如果存在就返回这个字段的直接引用。字段查找结束。
- 否则,查找失败,抛出java.lang.noSuchFiledError。
在查找成功之后,还要对字段的访问权限进行验证,当发现C并不具有访问权限,将抛出java.lang.IllegalAccessError。
4.4 类方法解析
首先,我们看类方法和接口方法在常量池里面的结构:
tag 表示常量的类型,CONSTANT_Methodref_ref的第一个index表示方法的所属的类索引,第二个index表示当前方法描述符。
和字段解析一样,首先进行方法所属的类或接口解析。如果失败,导致方法符号引用解析失败;如若成功,那么将方法所属的类或接口用C表示,然后进行如下步骤:
- 类方法和接口方法符号引用的常量类型定义是分开的,如果类方法表中发现class_index(第一个index)中索引的C是一个接口,将抛出java.lang.incompatibleClassChangeError。
- 如果通过第一步,就在类C中查找是否有简单名称和描述符和目标相匹配。如果有,则返回这个方法的直接引用。查找结束。
- 否则,在类C的父类中递归查找是否有简单名称和描述符和目标方法相匹配的。如果有,则返回该方法的直接引用。查找结束。
- 否则,在类C的接口实现列表及其符接口中递归查找简单名称和描述符与目标方法相匹配的方法。如果存在,说明C是一个抽象类。查找结束,抛出java.lang.abstractMethodError。
- 否则,宣告查找失败,抛出java.lang.NoSuchMethodError。
最后,如果返回方法的直接引用,还要验证C对方法的访问权限,如果C对方法不具备访问权限,将会抛出java.lang.IllegalAccessError。
4.5 接口方法解析
首先我们看接口方法在在常量池里面的类型结构:
tag表示常量类型,第一个index表示方法所属的接口描述符的索引,第二个index表示方法的名称及类型描述符的索引。
首先还是要对接口方法所属的类或接口进行解析。如果解析失败,说明接口方法解析失败。如若成功,我们任然用C表示接口方法所属的类或接口,进行以下步骤:
- 如果发现C表示的是一个类,将抛出java.lang.incompatibleClassChangeError。
- 否则,在接口C及其父接口中递归查找(直到java.lang.Object,包括java.lang.Object)是否有简单名称和描述符与目标项匹配的方法,如果有,则直接返回方法的直接引用,查找结束。
- 否则,宣告查找失败。抛出java.lang.NoSuchMethodError。
由于接口中方法默认是public,不存在权限访问问题。因此不会抛出java.lang.IllegalAccessError。
五、初始化
5.1 准备阶段的初始化与初始化阶段的初始化
准备阶段的初始化只是按系统要求给类变量(不包括用final修饰的类变量)初始化为零值。而初始化阶段的初始化是根据程序员的意愿进行初始化的。
5.2 clinit()的产生
<clinit>()是由编译器自动收集类中的类变量的赋值语句和static语句块合并产生的。编译器收集的顺序是按代码出现的顺序进行的。
5.3 clinit()与类的构造函数(实例构造函数init())的区别
与<init>()方法不同,<clinit>()不用显示调用父类的<clinit>(),然而<init>需要。虚拟机保证了在调用<clinit>()之前,其父类的<clinit>()已经被调用。因此虚拟机第一个被执行的<clinit>()是java.lang.Object的<clinit>()。
5.4 非法向前引用和clinit()执行顺序例子。
非法向前引用(Illegal forward reference):静态语句块只能访问定义在它前面的类别量(包括用fianl修饰的),而不能访问在它后面定义的类变量(虽然不能访问,但可以赋值哟!!!!)。
class subClass extends superClass { static { s=6;//阔以,不报错!!! System.out.println(s);//报错,Illegal forward reference!!! } public static int s = 3; }
由于父类的<clinit>()方法优先于子类执行,意味着父类的静态代码块的执行要早于子类的类变量赋值语句。例如
public class Main {
public static void main(String[] args) {
System.out.println(subClass.b);//输出结果为6而不是3
}
}
class superClass {
//父类的静态代码块的执行要早于子类的类变量赋值语句。
public static int a=3;
static {
a=6;
}
}
class subClass extends superClass {
public static int b = a;
}
5.5 clinit()并不是必须的。
<clinit>()并不是必须的,只要类中没有静态代码块,也没有类变量的赋值语句。那么编译器收集时并不产生<clinit>()。
5.6 接口和类的clinit()区别。
刚才说了,父类的<clinit>()早于子类调用。然而,调用接口的<clinit>()并不需要先调用父接口的<clinit>()。只有父接口的定义的变量被使用时,才调用父接口的<clinit>()。
5.7 多线程情况下的clinit()。
当有多个线程调用<clinit>()时,虚拟机会给<clinit>()进行加锁同步,保证只有一个线程执行<clinit>(),其他线程阻塞。需要注意的是,当使用<clinit>()的线程执行完之后,其他阻塞的线程并不会再次执行<clinit>()了。因为同一个类加载器,一个类只会被加载一次。
下面是个例子:
public class Main {
public static void main(String[] args) {
Runnable target=new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread()+"start");
DeadLoopClass d=new DeadLoopClass();
System.out.println(Thread.currentThread()+"end");
}
};
Thread a=new Thread(target);
Thread b=new Thread(target);
a.start();
b.start();
}
}
class DeadLoopClass {
static {
System.out.println(Thread.currentThread()+"clinit!!!");
if (true) {
while (true) {
}
}
}
}
运行结果: