实战java虚拟机(五)——Class装载
前言
上一篇文章简单学习了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查看堆栈信息如下
堆栈信息中也没有足够信息可以判断发生死锁,但是死锁确实存在,所以我们在初始化类时,要格外小心这种情况的死锁。