虚拟机把描述类的数据(Class文件)加载到内存,对其校验、转换、解析和初始化,最终形成可以被直接引用的Java类型。这就是虚拟机的类加载机制。关于Class文件的格式,可以参考这篇文章:Java的Class(类)加载机制详解。
加载是根据特定名称查找类或接口类型的二进制表示(binary representation), 并由此二进制表示来创建类或接口的过程。 链接是为了让类或接口可以被Java虚拟机正确的执行,而将类或接口并入虚拟机运行时状态的过程。 类或接口的初始化是指执行类或接口的初始化方法< clinit >。
其中,加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的。而解析则不一定,在某些情况下是在初始化之后再开始,这是为了支持Java语言的运行时绑定(也称动态绑定或晚期绑定)。另外,上面的阶段通常都是互相交叉混合式进行的,通常会在一个阶段执行的过程中调用、激活另外一个阶段。
下面来看看每一步虚拟机都是怎么做的!
如果一个类不是数组类,那么它可以通过类加载器加载对应的的类Class(二进制文件)文件(经过上面几步)来创建相应的类。因为数组类型没有外部二进制文件,它们都是在虚拟机内部加载的,而不是通过加载器加载的。
虚拟机规范严格规定了有且只有5种情况必须对类进行“初始化”,当然初始化前的三个阶段(加载、验证、准备)就必须在此之前开始执行了。关于这5种必须初始化的场景如下:
这5种场景中的行为称为对一个类的主动引用,字面意思,程序员主动引用一个类,如果这个类没有初始化,则会先触发初始化。
除此之外,引用类却不会发生初始化称为被动引用,例如通过子类引用父类的静态字段、通过数组定义来引用类、直接调用类的静态常量字段。
1、下面是通过子类引用父类的静态字段例子:
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
//System.out.println(SubClass.class); //会进行初始化
//SubClass subClass = new SubClass(); //会进行初始化
}
}
运行结果:对于静态字段,只有直接定义这个字段的类才会被初始化。注意:此时子类已经被系统加载了,但是未到初始化阶段。
添加vm参数:-XX:+TraceClassLoading再次启动,就可以看见被load的类中包括了子类:
2、通过数组定义来引用类,不会触发此类的初始化
/**
* 被动使用类字段演示二:
* 通过数组定义来引用类,不会触发此类的初始化
* 虚拟机会初始化一个[SuperClass的数组类,由虚拟机自动产生,通过执行newarray字节码,不会使用类加载器
**/
public class TestArrayNotInitialization {
public static void main(String[] args) {
SuperClass[] sca = new SuperClass[10];
}
}
3、直接调用类的静态常量字段(static final)
public class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final String HELLOWORLD = "hello world";
}
class TestStaticFinalNotInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);
}
}
添加vm参数:-XX:+TraceClassLoading再次启动,就可以看见被load的类中并没有包括ConstClass,实际上这个常量在编译时被优化、放在了TestStaticFinalNotInitialization类的常量池中。
使用javap -v TestStaticFinalNotInitialization.class反编译TestStaticFinalNotInitialization类的class文件,从中可以看到,main方法引用的字符串指向了自身类的字符串常量池的第四个常量:
根据第四个和第二十五个常量就能看出来,ConstClass 的常量字段被放在了TestStaticFinalNotInitialization类的常量池中。
注意:加载是“类加载”过程的一个阶段。在加载阶段,虚拟机需要完成3件事情:
数组类的加载阶段有所不同,从以上“被动引用例子2”我们就知道,数组类的应用是不会对该类进行初始化,而是虚拟机通过字节码指令“newarray”去创建一个“[Object”对象。“初始化阶段”是在“加载阶段”之后,但不代表该类不会被加载。接下来,看看数组类加载过程要遵循的规则:
相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
类加载阶段的第一件事“通过一个类的全限定名来获取定义此类的二进制字节流”,是启动类加载器完成的。类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类的加载阶段。对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在就Java虚拟机中的唯一性,也就是说,即使两个类来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。这里的“相等”包括了代表类的Class对象的equals()、isAssignableFrom()、isInstance()等方法的返回结果,也包括了使用instanceof关键字对对象所属关系的判定结果。
加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序,也就是必须先加载才能验证。
验证是连接的第一步,验证阶段目的是为了确保Class文件的字节流包含的信息符合当前虚拟机的要求,确保Java虚拟机不受恶意代码的攻击。从整体上看,验证阶段大致上会完成下面4个阶段的检查动作:第一、文件格式验证;第二、元数据验证;第三、字节码验证;第四、符号引用验证。
准备阶段是正式为类变量分配内存设置类变量初始化值的阶段,这些变量所使用的内存都将在方法区中进行分配。
这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:public static int value = 123;那变量value在准备阶段过后的初始化值为0而不是123,因为这是尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后存放在类构造器()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。
上面提到通常情况下准备阶段是赋予零值,也有特殊情况,比如被final修饰的常量量,在准备阶段就要赋予value指定的值,如下代码:
public static final int value = 123;
在编译生成的Class文件中,常量字段具有ConstantValue属性,存放于常量池中,ConstantValue属性持有常量的具体值,在准备阶段该常量就会被赋与具体值(并非Java字节码引起的)。如果不是常量,那么将会在初始化阶段进行赋值(在方法中,是由字节码引起的,比如ldc、putstatic字节码指令)。关于Class文件结构和ConstantValue属性,可以看:Java的 Class(类)文件结构详解。
解析阶段是虚拟机将常量池的符号引用直接替换为直接引用的过程。
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用的目标不一定加载到内存中。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接点位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的。
对同一个符号引用进行多次解析请求是很常见的事情,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)从而避免解析动作重复进行。但对于invokedynamic指令,上面规则则不成立。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号进行引用,下面只对前4种引用的解析过程进行介绍:
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为 一个类或接口C的直接引用,那虚拟机完成整个解析过程需要一个3个步骤:
如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现接口。一旦这个加载过程出现了任何异常,解析过程宣布失败。
如果C是一个数组类型,并且数组的元素类型是对象,也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述如前面所假设的形式,需要加载的元素类型就是“Java.lang.Integer”,接着有虚拟机生成一个代表此数组维度和元素的数组对象:“[Ljava/lang/Integer”(数组引用可回顾上文“类加载时机-被动引用演示二”)。
如果上述步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。
要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束。
对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。
与类方法解析步骤类似,知识接口不会有父类,因此,只递归向上搜索父接口就行了。
初始化阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器< clinit>()方法的过程。
这里简单说明下< clinit>()方法的执行规则:
class Father {
public static int a = 1;
static {
a = 2;
}
}
class Child extends Father {
public static int b = a;
}
public class ClinitTest {
public static void main(String[] args) {
System.out.println(Child.b);
}
}
执行上面的代码,会打印出2,也就是说b的值被赋为了2。我们来看得到该结果的步骤。首先在准备阶段为类变量分配内存并设置类变量初始值,这样A和B均被赋值为默认值0,而后再在调用< clinit>()方法时给他们赋予程序中指定的值。当我们调用Child.b时,触发Child的< clinit>()方法,根据规则2,在此之前,要先执行完其父类Father的< clinit>()方法,又根据规则1,在执行< clinit>()方法时,需要按static语句或static变量赋值操作等在代码中出现的顺序来执行相关的static语句,因此当触发执行Father的< clinit>()方法时,会先将a赋值为1,再执行static语句块中语句,将a赋值为2,而后再执行Child类的< clinit>()方法,这样便会将b的赋值为2。
如果我们颠倒一下Father类中“public static int a = 1;”语句和“static语句块”的顺序,程序执行后,则会打印出1。很明显是根据规则1,执行Father的< clinit>()方法时,根据顺序先执行了static语句块中的内容,后执行了“public static int a = 1;”语句。
另外,在颠倒二者的顺序之后,如果在static语句块中对a进行访问(比如将a赋给某个变量),在编译时将会报错,因为根据规则1,它只能对a进行赋值,而不能访问。
/**
* 演示初始化死锁
*/
public class InitLock extends Thread {
private String name;
private InitLock(String name) {
this.name = name;
}
@Override
public void run() {
try {
setName(name);
Class.forName("com.ikang.JVM.staticfiled." + name);
System.out.println("init " + name + " is ok!");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
//线程一:先初始化ClassA,在ClassA内部请求初始化ClassB
InitLock a = new InitLock("ClassA");
a.start();
//加上睡眠时间,等待线程一把ClassB和ClassA初始化完毕,线程二再尝试初始化就不会死锁。
Thread.sleep(2000);
//线程二:先初始化ClassB,在ClassB内部请求初始化ClassA
InitLock b = new InitLock("ClassB");
b.start();
}
}
class ClassA {
static {
try {
//ClassA中初始化ClassB,必须持有ClassA和ClassB
Class.forName("com.ikang.JVM.staticfiled.ClassB");
System.out.println("ClassB is ok!");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
class ClassB {
static {
try {
//ClassB中初始化ClassA,必须持有ClassB和ClassA
Class.forName("com.ikang.JVM.staticfiled.ClassA");
System.out.println("ClassA is ok!");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
使用jps 和jstack可以看到两个线程都在等待,实际上已经发生了死锁,但是线程状态并没有被改变,还都是RUNNABLE,因此容易误导开发者。
相关文章:
如有需要交流,或者文章有误,请直接留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!