JVM类加载机制

本博文对应《深入理解java虚拟机》第七章的内容。

概述

java编译器把java文件变成了字节码,而JVM则是首先把字节码加载到内存,对数据进行校验、转换解析和初始化,最终就可以形成被虚拟机直接使用的java类型。不像C在编译的时候进行了连接工作(这里主要说的是静态链接),在java中加载、连接和初始化其实都在程序运行的时候完成的,虽然会耗时间,但是能够动态加载,这样大大提高了灵活性。

类在内存中的生命周期

类从开始被加载到内存到被从内存中移除为止,一共要经历七个步骤:

  • 加载(Loading)
  • 验证(Verification)
  • 准备(Preparation)
  • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)

其中验证——准备——解析这三个步骤被称为连接(linking);而如果是为了能够支持java的运行时绑定,还可以把解析放到初始化后面。

有且仅有以下的六种情况必须进行初始化:

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令的时候,要是对应的类没有初始化,则需要先让它初始化。很明显,你创建一个类的对象总不能没有这个类吧?你设置一个类的静态属性的时候,总不能没有这个类吧?你调用一个类的静态方法的时候,总不能没有这个类吧?
  2. 对类进行反射调用的时候,肯定也需要对类进行初始化。
  3. 当要初始化一个类的时候,首先需要初始化它的父类。
  4. 主类需要先被初始化。
  5. MethodHandle实例最后的解析结果是REF_getStaticREF_putStaticREF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有初始化,则需要首先触发初始化。
  6. JDK8新增了接口的默认实现,如果这个接口的实现类发生了初始化,那么接口要先初始化。

上面这五种都被称为主动引用,接下来用三个例子说明什么是被动引用。

例子1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SuperClass {
static {
System.out.println("Super Class init");
}

public static int value = 123;
}

public class SubClass extends SuperClass {
static {
System.out.println("Sub Class init");
}
}

public class Test {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}

只会输出Super Class init123,也就是说虽然获取的是subclass类的值,但是并没有初始化subclass。因为对于静态字段,只有直接定义这个字段的类才会被初始化。

例子2:

1
2
3
4
5
public class Test {
public static void main(String[] args) {
SuperClass[] superClasses = new SuperClass[3];
}
}

你会发现什么输出都没有。

例子3:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ConstClass {
static {
System.out.println("Const Class init");
}

public static final String HELLOWORLD = "hello world!";
}

public class Test {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);
}
}

你会发现只输出了hello world!但是没有输出Const Class init那段代码,这是因为在编译阶段及逆行了常量优化,直接把那个字符串常量存储到了Test类中的常量池里面。也就是这两个类在你编译完成之后,就没有任何关系了。

接口和类的加载过程稍微有一点点不同。因为static会触发类的clinit高早起,而接口没有static语句块。但是编译器仍然会为接口生成clinit构造器。一个接口在初始化的时候并不要求其父接口全部完成了初始化。

类的加载过程Class loading

上面那个加载是广义上的加载,具体是加载——验证——准备——解析和初始化这五个步骤。

加载loading

在这个阶段主要要完成3件事情:

  1. 通过类的全限定名字来获取这个类的二进制字节流
  2. 然后把字节流所代表的的静态存储结构转化为方法区(永久代)运行时的数据结构。
  3. 生成一个代表这个类的java.lang.Class对象,这样就可以访问对应的方法区的数据了。

注意这三条都是很宽泛的要求,具体怎么实现完全可以很灵活。比如第一步,我从哪里获得这个class文件呢?可以是从网络中传输过来的(Applet),直接从压缩包里获取(JAR),直接由运算生成(动态代理技术),甚至还可以直接从数据库里获取。

非数组类的加载是比较灵活的,你可以选择重写类加载器的loadClass()方法或者是直接用系统提供的引导类加载器来完成。而数组类就只能由JVM直接创建,但是数组类中的每个元素其实还是需要类加载器来加载这个元素的,而这个元素又有两种可能:

  • 引用类型就用加载过程。
  • 非引用类型(如int),虚拟机会把数组标记为与引导类加载器关联。

加载完成之后,.class文件的内容就被复制了一份到方法区之中。然后在内存中会实例化java.lang.Class类的对象(注意这里说的是内存中,而没有说堆,因为这个对象很特殊)

验证

从这里开始进入连接步骤了。首先问一句,为什么需要进行验证?这是因为class文件可以用任何别的方法来生成,比如可以直接用十六进制编辑器来写class文件,这样就可以写出恶意的代码,一旦恶意的代码没有经过虚拟机检查就直接运行了,后果不堪设想(当然检查也只是为了减少攻击)。但是如果是你自己编写的代码,其实你完全可以-Xverify:none来关掉(因为验证真的很费时间),因为总不至于自己害自己吧。

文件格式验证

首先确认是不是class文件(咖啡宝贝开头),然后看看版本号能不能被虚拟机处理,再看看常量池里有没有问题……总之这一阶段的主要目的是保证字节流能够被正确的解析并且存储在方法区里,而完成了这个步骤之后接下来的验证就都事基于方法区的存储结构了,不再是直接操作字节流了。

元数据验证

主要是对语义进行分析。随便举几个例子:类必须要有父类(除了Object类),不能重载出错等等等。

字节码验证

此步骤确保程序语义是合法的。比如保证跳转指令不会跳转到别的方法体里面。显然这一步其实是非常非常难的,因为其高度复杂。

符号引用验证

符号引用中的全限定名能否找到相应的类,检查一下有没有相对应的字段和方法,各个类和字段的访问性等。

准备

这个阶段是为类变量(注意是类变量,看清楚!)分配内存并设置初始值的,而类变量所用的内存都在方法区开辟。实例变量是和对象一起分配在堆里面的。这一阶段会把所有的变量初始化为零值。比如public static int value = 123;,会把value初始化为0(真正赋值成123是在之后的初始化阶段,不是现在)。有一种情况是有特例的,如果你声明类变量的时候加上了final,那么编译器就会为字段表增加ConstantValue值,这样value就直接等于123而不再变动。

解析

首先需要解释清楚两个概念:

  • 符号引用:用一组符号(字符也是符号,比如a)来描述所引用的目标。最典型的例子就是之前分析class文件的时候,会发现引用了常量池里的某个字符串,那个就是符号引用。
  • 直接引用:被引用的目标必须在内存中已经存在。而直接引用可以是直接指向目标的指针。

其次是解析是有缓存机制的。

解析一个类或接口

假设目前代码所在的类是D,要解析一个从未解析过的符号引用N,将其解析为另外一个类(或者接口)C的直接引用:

  1. 如果C它不是一个数组类型,那么虚拟机首先解析这个符号引用N,然后就得到了全名,并且把全名传递给D的类加载器,让它去加载类C。
  2. 如果C是一个数组类型,那么就需要首先加载数组的元素,然后让虚拟机去生成代表数组维度和元素的数组对象。
字段解析

如果解析一个字段,首先会对字段表里面的class_index中的CONSTANT_Class_info(我知道你已经忘得差不多了,这东西就是在常量池里面)进行解析,如果失败则直接失败,如果成功,那么会对这个字段所属的类的后续的字段进行搜索。

如果这个类本身就含有简单名称和描述符都互相匹配的字段,那么只需要返回这个字段的直接引用即可。没找到的话首先看看这个字段所属的类有没有实现某个接口,然后从下到上开始寻找父接口,同样也是如果找到了简单名称和描述符互相匹配的字段就返回直接引用。然后再看继承关系,和接口一样的找法。如果还是没有,就要抛出NoSuchFieldError异常。但是就算成功找到了,也要进行权限验证,如果不具备对字段的访问权限,那么就会抛出IllegalAccessError异常。

你可能会问要是在父接口和父类中同时出现了同一个字段怎么办?编译器是会拒绝编译的。

方法解析

首先需要解析方法表里面的class_index里面的所属的类或者接口的符号引用。如果发现是个接口,那么就直接抛出IncompatibleClassChangeError异常。通过了的话就能去找是否有简单名称和描述符都匹配的方法,如果有的话就返回直接引用;没有的话就去父类中找,否则再去父接口中找到。如果最后在父接口中找到了,则该类是个抽象类,需要抛出AbstractMethodError异常。

接口方法解析

首先还是在class_index里面找,因为这里是接口了,所以如果发现是个类,则需要抛出IncompatibleClassChangeError异常。然后在找找所属的这个接口中有没有直接匹配的,再去父接口中寻找,最后没找到则抛出NoSuchMethodError异常。

初始化

类初始化是类加载的最后一步了。这里才开始真正执行字节码文件。之前提到过在准备阶段类变量已经赋予过一次零值,初始化阶段则是根据程序员编写的代码来进行初始化。简单讲就是执行clinit()方法(这个方法是类构造器,而不是通常的构造器)的过程。这个方法是编译器自动收集类中的类变量的赋值动作和static语句块(即静态语句块)合并产生的。当然收集的顺序就是出现的顺序决定的,静态语句块比较特殊,它只能访问在它之前的变量,而定义在它后面的变量,它可以赋值但是无法访问。

1
2
3
4
5
6
7
8
9
10
11
public class Test {
static {
i = 0;
}

static int i = 1;

public static void main(String[] args) {
System.out.println(i);
}
}

猜猜这段代码会输出什么?会输出1。这是因为之前也说了,编译器是按照出现的先后顺序(即静态语句块在前,类变量赋值为1在后)的操作来进行收集的,所以当然是后面的会覆盖前面的啦。不知道你会不会有疑问,为什么i=0这句居然不报错呢?因为在准备阶段会为这个类变量进行赋予零值呀。

注意!到这里为止,我们还没有对类的成员变量(也叫实例变量)进行任何的赋值过。

1
2
3
4
5
6
7
8
9
10
11
12
public class Test {
static {
i = 0;
System.out.println(i);
}

static int i = 1;

public static void main(String[] args) {
System.out.println(i);
}
}

上面这段代码是非法的,无法通过编译。

clinit方法被称为类构造器,而与类同名的函数叫做实例构造器init(),虚拟机能够保证在子类的clinit方法执行前父类的已经执行完毕。

init is the (or one of the) constructor(s) for the instance, and non-static field initialization.
clinit are the static initialization blocks for the class, and static field initialization.

显然如果一个类没有类变量和static语句块,那么就不需要clinit方法了。下面看一个实际的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Test {
static class Parent {
public static int A = 1;

static {
A = 2;
}
}

static class Sub extends Parent {
public static int B = A;
}

public static void main(String[] args) {
System.out.println(Sub.B);
}

}

因为Parent类的clinit会首先被执行,所以A是2,自然程序的结果也是2。

接口和类相比只是少了static静态块。但是接口的clinit方法不需要先执行父接口的clinit方法,除非父接口中定义的变量需要使用,父接口才会初始化。同样接口实现类在初始化时也不会去执行接口的clinit方法。

虚拟机会保证一个类的clinit在多线程环境下通过加锁被正确初始化,但是这会出现死锁问题。下面的代码演示了这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Test {
static class DeadLoopClass {
static {
if (true) {
System.out.println(Thread.currentThread() + "init DeadLoopClass");
while (true) {
// loop forever
}
}
}
}

public static void main(String[] args) {
Runnable script = new Runnable() {
@Override
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);
Thread thread2 = new Thread(script);
thread1.start();
thread2.start();
}
}

上面代码有一个if(true)看上去很沙雕的判断条件,但是其实是至关重要的。

类加载器

比较两个类是否相等,大前提就是加载这两个类的类加载器是否相同,如果连类加载器都不同,那么就没有意义。

双亲委派模型

面试中被问烂的问题之一。

从虚拟机的角度来看,只有两种类加载器:一种是启动类加载器(Bootstrap),这个类加载器本身由C++实现,是虚拟机的一部分;另外的类都归为另一类,全部都由java来实现,并不属于虚拟机,全部继承自java.lang.ClassLoader。当然这个是从虚拟机的角度来看的,面试官想要听到的答案是下面的三层类加载器:

  1. 启动类加载器,Bootstrap Class Loader,加载放在$JAVA_HOME/lib下的类库,而且名字必须要符合。如果你希望能够调用这个类加载器,只需要指明加载器为null即可。
  2. 扩展类加载器,Extension Class Loader,加载放在$JAVA_HOME/lib/ext下的类库,JDK9之后被模块化取代,也没有对应的目录存在了。这个加载器可以直接被使用。
  3. 应用程序类加载器,Application Class Loader,用来加载用户类路径上的所有类库。

双亲委派的意思就是,当类加载器收到加载类的请求的时候,它首先先委派给父类去加载,只有当父类反馈说自己无法加载的时候,自己才会去尝试加载。

模块化系统

JDK9中正式引入的一个模块化系统,但是我还没了解过,所以这部分就暂且先跳过了。