代码编译的结果从本地机器码转变为字节码, 是存储格式发展的一小步, 却是编程语言发展的一大步
----《深入理解Java虚拟机:JVM高级特性与最佳实践》
Java虚拟机把描述类的数据从Class文件加载到内存, 并对数据进行校验、 转换解析和初始化, 最终形成可以被虚拟机直接使用的Java类型, 这个过程被称作虚拟机的类加载机制。
Class文件被加载到内存中是如何存储的?就要涉及到JVM的Klass模型了,Klass类是C++中的一个类,包含元数据和方法信息,用来描述Java类,像常量池、字段、属性、方法、父类、类的访问权限等等,这些都是Java类的元信息。所以想要搞清楚Java类在JVM中如何存储,那么就有必要了解一下Klass模型。
继承结构
Metadata表示元空间的内存结构,由此可见类的元信息是存储在原空间的
Java类在JVM中分成两种:非数组类和数组类,非数组类在JVM中对应的是InstanceKlass类的实例,InstanceKlass用来存储Java类的元信息,存储在方法区,它有三个子类:
Java数组的元信息用ArrayKlass的子类来表示:
一个类型从被加载到虚拟机内存中开始, 到卸载出内存为止, 它的整个生命周期将会经历加载(Loading) 、 验证(Verification) 、 准备(Preparation) 、 解析(Resolution) 、 初始化(Initialization) 、 使用(Using) 和卸载(Unloading) 七个阶段, 其中验证、 准备、 解析三个部分统称为连接(Linking)
1、遇到new、 getstatic、 putstatic或invokestatic这四条字节码指令时, 如果类型没有进行过初始化,则需要先触发其初始化阶段。 能够生成这四条指令的典型Java代码场景有:
2、使用反射的时候,如果类没有进行过初始化,则需要先进行初始化。
3、初始化类的时候,如果其父类还没有进行过初始化,则需要先初始化其父类,但是一个接口在初始化 时, 并不要求其父接口全部都完成了初始化, 只有在真正使用到父接口的时候(如引用接口中定义的常量) 才会初始化。
4、虚拟机启动时,会先初始化主类(main()所在类)。
5、当使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。
6、在jdk1.8中,如果一个接口中定义了默认方法(default修饰的方法),如果有这个接口的实现类进行了初始化,则需要在该实现类初始化之前先初始化该接口。
在《Java虚拟机规范》一书中,将上述6种场景称为对类的主动使用,除了这6种场景之外的使用方法称为被动引用,并且所有被动引用都不会初始化,即有且仅有上述6中方式中的任意一种场景时,才会进行类的初始化。
关于被动引用,通过几个代码示例来说明,加深一下理解。
public class Test {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
class SuperClass {
public static int value = 123;
static {
System.out.println("SuperClass init!");
}
}
class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
上述代码运行指挥,输出结果如下:
SuperClass init!
123
没有输出“SubClass init!”,对于静态字段,只有直接定义这个字段的类才会被初始化, 因此通过其子类来引用父类中定义的静态字段, 只会触发父类的初始化而不会触发子类的初始化,由此可见,JVM加载类是懒加载模式,只有在使用的时候,才会去初始化类。
以上代码只是说明了SubClass类没有初始化,那么类加载在类的生命周期的第一阶段加载(Loading)有没有触发呢?其实可以通过JVM参数来观察:-XX:+TraceClassLoading,如下图所示,可以看到,其实虚拟机已经加载(Loading)了子类,其实在《Java虚拟机规范》中并没有明确规定这种场景下子类要不要加载,可能不同的虚拟机实现会有不同的结果,我所运行的代码使用的都是HotSpot虚拟机,HotSpot虚拟机在此种情况下会去加载子类。
public class Test {
public static void main(String[] args) {
SuperClass[] superClasss = new SuperClass[1];
}
}
class SuperClass {
public static int value = 123;
static {
System.out.println("SuperClass init!");
}
}
运行上述代码,发现没有任何输出!说明并没有初始化SuperClass类,其实这里只是定义了一个SuperClass类型的数组,并没有使用SuperClass类;从另一个角度看,这种情况并不在类的初始化的6个场景中,因此并不会初始化。
public class Test {
public static void main(String[] args) {
System.out.println(SuperClass.value);
}
}
class SuperClass {
public static final int value = 123;
static {
System.out.println("SuperClass init!");
}
}
运行上述代码,只是输出了123,"SuperClass init!"还是没有输出!这又是为什么呢?与代码演示2不同的是这里的value是个final修饰的的常量,在JVM中,常量在编译阶段就已经经过常量传播优化,将此常量值123放入了Test类的常量池中,以后对SuperClass.value的使用,都是直接从Test类的常量池中获取了,也就是说Test类中并不存在对SuperClass类的引用,在编译阶段它俩就没有任何联系了。
可以通过查看Test类的字节码来证实这一说法(使用IDEA的Jclasslib插件),如下图,bipush 123字节码指令的意思就是将常量123压入操作数栈的栈顶。
为了增加说服力,再来看一下SuperClass类的字节码,如下图,看到没有,字节码指令直接就是获取静态变量(getstatic),并没有关于value字段赋值的字节码指令(getstatic),这是因为编译的时候已经把它放进Test类的常量池中了,SuperClass类就不再有对value常量的引用了。
我把代码改一下,来进行更加直观的比较:
public class Test {
public static void main(String[] args) {
System.out.println(SuperClass.value);
}
}
class SuperClass {
public static int value = 123;
static {
System.out.println("SuperClass init!");
}
}
上述代码中,我把value字段的final关键字去掉,变成了一个普通的静态字段,再来分别看一下Test类和SuperClass类的字节码,看看有什么变化:
SuperClass类字节码:
可以看到,跟加了final关键字的代码的字节码完全相反了,此时SuperClass类便拥有对value变量的引用,并且存在于自己的常量池中,这样Test.value就要从SuperClass类的常量池中获取。
接下来我们会详细了解Java虚拟机中类加载的全过程, 即加载、 验证、 准备、 解析和初始化这五个阶段所执行的具体动作。1
“加载”(Loading)阶段主要干了以下几件事:
验证阶段以及之后的准备、解析阶段都属于连接阶段,“加载”(Loading)阶段与连接阶段的部分动作( 如一部分字节码文件格式验证动作) 是交叉进行的, 加载阶段尚未完成, 连接阶段可能已经开始, 但这些夹在加载阶段之中进行的动作, 仍然属于连接阶段的一部分, 这两个阶段的开始时间仍然保持着固定的先后顺序。比如“加载”(Loading)阶段中包括了字节码解析操作,而字节码解析的时候需要验证字节码格式,比如通过验证字节码中首个4字节数据是否是“0xcafebabe”来验证是否是一个合法的java字节码文件,而这里的验证正是连接阶段里的验证。
验证阶段是非常重要的,它是Java虚拟机保护自身安全的一项必要措施,验证阶段主要验证如下4部分内容(按顺序进行验证):
这个阶段主要验证了class文件格式是否合法,比如魔数(“0xcafebabe”)验证、主、次版本号是否在当前Java虚拟机接收的范围之内、常量池中是否有非法的常量类型(tag)等等,想要详细了解的,可以参考周志明的《深入理解Java虚拟机:JVM高级特性与最佳实战》
这个阶段是对类的元数据信息进行语义校验, 以保证其描述的信息符合《Java虚拟机规范》 的要求,这个阶段可能包括的验证点如下:
这个阶段主要是保证程序语义是合法的、符合逻辑的,主要对方法体(Class文件中Method_info中的Code属性)进行校验分析,例如:
这个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候, 这个转化动作将在连接的第三阶段——解析阶段中发生,主要目的是为了保证解析行为能正常执行,主要校验一下内容:
如果无法通过符号引用的验证,JVM将会抛出如java.lang.IllegalAccessError、 java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等异常。
验证阶段对于虚拟机的类加载机制来说, 是一个非常重要的、 但却不是必须要执行的阶段, 因为验证阶段只有通过或者不通过的差别(比如,Java用户自己手写了一个JVM,他完全可以忽略验证部分,只要他能保证运行在他缩写的JVM上的Java程序完全符合Java虚拟机规范,那就没问题,而实际上正是因为无法保证这一点,所以各种商用的JVM才会有验证这一阶段), 只要通过了验证, 其后就对程序运行期没有任何影响了。 如果程序运行的全部代码(包括自己编写的、 第三方包中的、 从外部加载的、 动态生成的等所有代码) 都已经被反复使用和验证过, 在生产环境的施阶段就可以考虑使用-Xverify: none参数来关闭大部分的类验证措施, 以缩短虚拟机类加载的时间。
准备阶段就是为类变量(静态变量,被static关键字修饰)分配内存并赋初值。这种变量存在于方法区,在JDK1.8之前的版本中,HotSpot虚拟机中的方法区由永久代来实现,而永久代存在于堆中,因此类的静态变量存在于堆区;在JDK1.8以及之后的版本,类的静态变量存放在InstanceMirrorKlass中(即Class对象),也是存放在堆区,所以我们常说的“类静态变量在方法区”是一种逻辑上的概念,而静态变量真正在JVM中是存放在Java堆这一物理内存中。
对于在JDK1.8以及之后的版本,类的静态变量存放在InstanceMirrorKlass中这一说法,我们可以使用HSDB工具来进行验证,例如下面的代码:
public class Test {
public static String str = "test";
public static void main(String[] args) {
while (true);
}
}
运行上述代码,通过jps命令查看Test的进程id,然后通过HSDB(关于HSDB的使用,这里不详细介绍,需要了解的自行在网上查资料)查看该类的内存结构如下图,可以看到str变量正是存在于Class对象中,而Class对象在JVM中使用InstanceMirrorKlass来描述的。
关于准备阶段,需要注意的是,只有类变量才会在准备阶段分配内存和赋初值,而实例变量将会在对象实例化时随着对象一起分配在Java堆中,所以没有赋初值一说。这里的赋初值其实就是各种数据类型的零值,下表列出了所有类型的数据的零值。
有一个例外,就是类变量如果被final修饰,在编译的时候会给类字段的字段属性表中添加ConstantValue属性,准备阶段直接完成赋值,即没有赋初值这一步。
其实前面的代码演示中已经证实了这一点,这里为了更加直观的说明这个问题,我再通过查看字节码的方法来验证一下吧,对于下面的代码段:
public class Test {
public static final String str = "test";
}
查看Test的字节码如下图,可以看到ConstantValue属性的值就是“test”,这里是一个符号引用cp_info #7,指向了常量池中CONSTANT_Utf8_info类型的数据结构,它的值正是“test”。
注意:
这里所说的赋初值是指类变量,即静态变量,也就是说类变量有一个默认的初始值,即便程序员不手动赋值,也不影响程序运行,但是如果是方法内的局部变量,则不会赋初值(即没有默认初始值),必须由程序员手动初始化。如下面的代码是无法运行的,在字节码的校验阶段就无法通过:
public static void main(String[] args) {
int a;
System.out.println(a); //提示:Variable 'a' might not have been initialized
}
在前面验证阶段的符号引用验证的介绍中,已经说明解析阶段的目的:将常量池中的符号引用转为直接引用。
首先需要明白一个问题:为什么会有符号引用?
Java代码在进行Javac编译的时候, 并不像C和C++那样有“连接”这一步骤, 而是在虚拟机加载Class文件的时候进行动态连接(具体见第7章) 。 也就是说, 在Class文件中不会保存各个方法、 字段最终在内存中的布局信息, 这些字段、 方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址, 也就无法直接被虚拟机使用的。 当虚拟机做类加载时, 将会从常量池获得对应的符号引用, 再在类创建时或运行时解析、 翻译到具体的内存地址之中。
这段解释是摘抄自《深入理解Java虚拟机:JVM高级特性与最佳实践》一书中,我个人的理解其实就是JVM在编译的时候,并不知道类中所引用的引用类型到底是什么,也不知道这个字段的访问权限是什么,比如下面的代码,编译时JVM不知道superClass的全限定名是什么,因为这时候SuperClass可能还没有被加载到虚拟机内存中,必须在运行时才会知道,因此需要用指向常量池的一个符号引用来代替,等到运行时,才会解析成SuperClass类的真正的内存地址。
public class Test {
private SuperClass superClass;
}
在解析时,经常会对同一个符号引用进行多次解析,因此JVM会将解析后的信息存储在ConstantPoolCache类实例中,下次再遇到对这个符号引用进行解析情况时,就不需要再次解析了,直接从ConstantPoolCache取出解析结果即可。
关于何时解析,《Java虚拟机规范》中并没有明确的规定,规范中只是强制规定了在ane-warray、checkcast、 getfield、 getstatic、 instanceof、 invokedynamic、 invokeinterface、 invoke-special、invokestatic、 invokevirtual、 ldc、 ldc_w、 ldc2_w、 multianewarray、 new、 putfield和putstatic这17个用于操作符号引用的字节码指令之前,先对它们所使用的的符号引用进行解析即可。这就给了Java开发者发挥的空间,我们先不管HotSpot虚拟机是如何实现的,先以我们自己的思路去想一下,解析其实就是符号引用->直接引用,那么我是不是有两种思路:
HotSpot虚拟机采用的是第二种思路,即在符号引用被使用的时候才取解析。
在初始化阶段,Java虚拟机才真正开始执行Java程序代码,在准备阶段,会对类变量(静态变量)赋过初值(零值)了,那么在初始化阶段,就要根据Java程序对静态变量进行赋值,这时候的静态变量才是程序员想要看到的实际的值。
从字节码层面,初始化阶段就是执行类构造器()方法,它是如何产生的?()由Javac编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块) 中的语句合并产生的, 编译器收集的顺序是由代码在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量, 定义在它之后的变量, 在前面的静态语句块可以赋值, 但是不能访问。例如如下代码,在编译时就无法编译通过,但是可以赋值。
类的()方法在执行之前,会先执行该类的父类的()方法,因此,如果一个类有父类,则会根据继承链逐级向上找到最顶层的父类(java.lang.Object)的()方法,然后由java.lang.Object类沿着这个继承链向下开始逐级执行()方法,在Java虚拟机中第一个执行的()方法的类型用于是java.lang.Object。
为了更形象的表达这个意思,用一张图片来表示:
接口中虽然不能有static{}语句块,但是可以有静态变量的赋值操作,但是与类不同,接口中的()方法执行的时候,不一定非要先执行其父接口的()方法,例外情况就是:只有当父接口中的静态变量被使用的时候才会先初始化父接口。此外,接口的实现类在初始化时也不会执行接口的()方法,但是也有例外情况:在jdk1.8中,如果一个接口中定义了默认方法(default修饰的方法),如果有这个接口的实现类进行了初始化,则需要在该实现类初始化之前先初始化该接口(执行接口的()方法)。这两个例外情况其实在本文开始时,类初始化时机的6个场景中的3和6中已经有介绍了。
如果多线程同时初始化一个类,即有多个线程同时想去执行该类的()方法,这就相当于多线程同时对静态变量进行赋值操作,这时候该怎么办呢?很显然,需要有同步控制手段,Java虚拟机这时候会对()方法加锁,以保证同一时刻只有一个线程执行()方法,其它线程阻塞等待。那么,你肯定会以为当前线程执行完()方法的时候,其它阻塞等待的线程被唤醒后,会继续执行()方法,其实不然,这是因为在同一个类加载器下,同一个类只会被加载一次。
这个场景中有几个重点需要特别说明一下,我分别用代码来演示一下吧。
public class Test {
public static void main(String[] args) {
Runnable script = new Runnable() {
public void run() {
System.out.println(Thread.currentThread() + "start");
DeadLoopClass dlc = new DeadLoopClass();
System.out.println(Thread.currentThread() + " run over");
}
};
Thread thread1 = new Thread(script, "thread1");
Thread thread2 = new Thread(script, "thread2");
thread1.start();
thread2.start();
}
}
class DeadLoopClass {
static {
//如果不加上这个if语句, 编译器将提示“Initializer does not complete normally”,并拒绝编译
if (true) {
System.out.println(Thread.currentThread() + "init DeadLoopClass");
while (true) {
}
}
}
}
运行上述代码,结果如下图,通过该运行结果可以看到,只有thread2初始化了DeadLoopClass类,即只有thread2执行了()方法,thread1一直在阻塞。这其中会隐藏一个实际生产中的问题:如果一个类的()方法(对于程序员来说,基本上就是static{}代码块,静态变量赋值操作很快,不会耗时很久)中包含有耗时很长的操作,那么会造成多个线程长时间阻塞,从而导致系统性能变慢。
public class Test {
public static void main(String[] args) {
Runnable script = new Runnable() {
public void run() {
System.out.println(Thread.currentThread() + "start");
DeadLoopClass dlc = new DeadLoopClass();
System.out.println(Thread.currentThread() + " run over");
}
};
Thread thread1 = new Thread(script, "thread1");
Thread thread2 = new Thread(script, "thread2");
thread1.start();
thread2.start();
}
}
class DeadLoopClass {
static {
//如果不加上这个if语句, 编译器将提示“Initializer does not complete normally”,并拒绝编译
if (true) {
System.out.println(Thread.currentThread() + "init DeadLoopClass");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
运行上述代码,结果如下,发现当thread2执行()方法的时候,thread1线程阻塞,当5s之后,thread2线程执行完()方法,thread1会被唤醒,但是并没有接着执行()方法,而是直接跳过了()方法往下执行了,有没有感觉到很无法理解?但是如果了解JVM类加载器的话,可以想明白:同一个类加载器下,同一个类只会被加载一次。
解决办法以及原因:
class DeadLoopClass {
static boolean flag = true;
static {
System.out.println(Thread.currentThread() + "init DeadLoopClass");
while (flag) {
}
}
}
概念说明
先说一下容易混淆的两个概念:类的加载和类加载过程中的“加载”阶段,以上7个阶段中的前5个阶段统称为类的加载过程,我们常说的类加载指得就是这个;而这5个阶段的第一步就是“加载”(Loading)阶段,所以本文在此之后所说的类加载指的是加载、 验证、 准备、 解析和初始化这五个阶段的统称,凡是需要表达类加载过程中的“加载”阶段,我都会用“加载”(Loading)表示。 ↩︎