前言
上一篇文章简单学习了Class文件的结构,这次看看虚拟机如何加载Class文件,并且在加载过后做什么额外的处理
Class文件的装载流程
Class文件的装载流程可以分为加载,连接和初始化 3 步,其中连接又分为验证,准备和解析 3 步,整体流程如下图
1. 类装载的条件
Class 文件只有在必须使用的时候才装载,Java虚拟机规定,一个类或接口在初次使用前,必须进行初始化。这里的使用是指主动使用,只有下列的情况才会主动使用
- 创建一个类的实例时,比如使用new关键字或反射、克隆、反序列化
- 调用类的静态方法时,即使用了字节码的invokestatic指令
- 使用类或接口的静态字段时(final常量除外),比如使用getstatic或putstatic指令
- 使用java.lang.reflect包中的方法反射类的方法时
- 初始化子类时,要求先初始化父类
- 作为启动虚拟机,含有main方法的那个类
除了以上的情况属于主动使用,其余都是被动使用,被动使用不会引起类的初始化
主动使用容易理解,下面举一个被动使用的例子方便记忆
public class Parent {
static{
System.out.println("Parent init");
}
public static int v = 100;
}
public class Child extends Parent{
static {
System.out.println("Child init");
}
}
public class Test1 {
public static void main(String[] args) {
System.out.println(Child.v);
}
}
运行以上代码的输出如下
Parent init
100
虽然在Test1中直接访问了子类对象,但是Child并没有被初始化,只有Parent被初始化,由此可见,在使用一个字段时,只有直接定义了该字段的类才会被初始化(虽然此处Child没有被初始化,但是它已经被加载,只是没有进入初始化阶段)
使用-XX:+TraceClassLoading运行这段代码,可以得到下列日志(仅截取小部分)
[Loaded com.xxxx.Parent from file:/D:/ideaWorkSpace/xxxx/target/classes/]
[Loaded com.xxxx.Child from file:/D:/ideaWorkSpace/xxxx/target/classes/]
Parent init
100
可以看到两个类都已经被加载到系统
2. 加载类
加载类处于类装载的第一个阶段。加载类时,虚拟机会完成以下工作:
- 通过类的全名获取类的二进制数据流
- 解析类的二进制数据流为方法区内的数据结构
- 创建java.lang.Class类的实例,表示该类型
类的二进制数据流可以从文件系统中的Class文件,也可以是zip等文件中提取类文件,也可以从网络加载,甚至在运行时生成一段Class的二进制信息。获取二进制信息后,Java虚拟机会处理这些数据并最终转为一个java.lang.Class的实例,Class实例是访问类型元数据的接口,也是实现反射的关键数据。
3. 验证类
类加载到系统后就开始连接操作,验证是连接的第一步,目的是保证加载的字节码是合法、合理且符合规范的,验证的步骤比较复杂,大体上需要做的检查如下图:
4. 准备
类验证通过时,虚拟机就会进入准备阶段。这个阶段,虚拟机会为这个类分配相应的内存空间,并设置初始值。各类型变量的初始值如下表
各类型变量默认初始值
类型 | 默认初始值 |
int | 0 |
long | 0L |
short | (short)0 |
char | \u0000 |
boolean | false |
reference | null |
float | 0f |
double | 0f |
Java并不支持boolean类型,对于boolean实际上是int,由于int默认为0,故boolean默认为false
如果类存在常量字段,那么常量字段也会在准备阶段附上正确的值。
5. 解析类
连接的第三步是解析阶段,解析阶段的工作就是将类、接口、字段和方法的符号引用转为直接引用。符号引用就是一些字面量的引用,与虚拟机内部数据结构与内存布局无关。在Class类文件中,通过常量池进行了大量的符号引用。
invokevirtual #24 <java/io/PrintStream.priintln>
这个是System.out.println()的字节码,可以看到它使用了常量池的第24项,查看并分析常量池,可以看到如下的结构
常量池第24项被invokevirtual引用,顺着CONSTANT_Methodref #24的引用关系查找,最终发现所有对于Class及NameAndType类型的引用都是基于字符串的。因此,可以认为invokevirtual函数调用通过字面量的引用描述已经表达清楚。这就是符号引用。
程序实际运行时只有符号引用是不够的,当方法被调用时,系统需要明确知道方法的位置。以方法为例,Java虚拟机为每个方法都准备了一张方法表,所有的方法都列在其中,当需要调用一个类的方法时,只要知道这个方法在方法表中的偏移量就可以直接调用。通过解析操作,符号引用可以转变为目标方法在类的方法表中的位置。
综上所述,解析就是将符号引用转换为直接引用,得到类、字段或方法在内存中的指针或偏移量。
6. 初始化
初始化是类装载的最后一个阶段。如果前面的步骤都没有问题,表示类可以顺利装载到系统中。此时,类才会开始执行Java字节码。初始化阶段的重要工作就是执行类的初始化方法<clinit>。方法<clinit>是由编译器自动生成的,它是由类静态成员的赋值语句及static语句块共同产生的。
public class SimpleDemo {
public static int id = 1;
public static int number;
static {
number = 4;
}
}
比如上面这个类,编译后的字节码如下
public class SimpleDemo {
<ClassVersion=52>
<SourceFile=SimpleDemo.java>
public static int id;
public static int number;
public SimpleDemo() { // <init> //()V
<localVar:index=0 , name=this , desc=LSimpleDemo;, sig=null, start=L1, end=L2>
L1 {
aload0 // reference to self
invokespecial java/lang/Object.<init>()V
return
}
L2 {
}
}
static { // <clinit> //()V
L1 {
iconst_1
putstatic SimpleDemo.id:int
}
L2 {
iconst_4
putstatic SimpleDemo.number:int
}
L3 {
return
}
}
}
可以看到,生成的<clinit>方法中,整合了这个类的static赋值语句和static语句块,先后对id和number进行赋值。
前面提到过加载一个类之前,虚拟机总会尝试加载该类的父类,因此父类的<clinit>方法总在子类的<clinit>方法之前被调用,也就是说,子类的static块是在父类之后执行。
并非所有的类都会产生<clinit>方法,如果一个类没有赋值语句,也没有static语句块,那么编译器就不会为该类生成<clinit>方法。
还有一个重要的点是,虚拟机会确保<clinit>方法执行的线程安全,当多个线程试图初始化同一个类时,只有一个线程可以进入<clinit>方法,如果第一个线程成功执行,则后面的线程就不会再执行该方法了。
也是由于<clinit>是带锁线程安全的,所以在多线程环境下进行类初始化时,可能引发死锁,并且这种死锁很难发现。下面有一个死锁的例子
class StaticA{
static{
try {
Thread.sleep(1000);
} catch (InterruptedException e){
}
try {
Class.forName("com.blog.test.StaticB");
} catch (ClassNotFoundException e){
}
System.out.println("com.blog.test.StaticA init success");
}
}
class StaticB {
static{
try {
Thread.sleep(1000);
} catch (InterruptedException e){
}
try {
Class.forName("com.blog.test.StaticA");
} catch (ClassNotFoundException e){
}
System.out.println("com.blog.test.StaticB init success");
}
}
public class StaticDeadLockMain extends Thread {
private char flag;
public StaticDeadLockMain(char flag){
this.flag = flag;
this.setName("Thread"+flag);
}
@Override
public void run() {
try {
Class.forName("com.blog.test.Static" + flag);
} catch (ClassNotFoundException e){
e.printStackTrace();
}
System.out.println(getName() + "over");
}
public static void main(String[] args) {
StaticDeadLockMain loadA = new StaticDeadLockMain('A');
loadA.start();
StaticDeadLockMain loadB = new StaticDeadLockMain('B');
loadB.start();
}
}
上面的代码简单来说就是一个类在静态代码块中初始化另一个类,形成相互初始化抢占资源。上述代码中执行main方法,main方法一直不停止,也没有输出,通过jstack查看堆栈信息如下
堆栈信息中也没有足够信息可以判断发生死锁,但是死锁确实存在,所以我们在初始化类时,要格外小心这种情况的死锁。