探索Java虚拟机的类加载机制

我们知道Java程序在编译的过程中需要先经过javac将Java文件编译成字节码文件才能被虚拟机执行。而类加载指的就是将编译好的字节码(不仅仅指.class文件中的字节码,任意的字节码流都可以被读取到JVM)读取到JVM的内存中的过程。虚拟机在加载.class文件时会对数据进行校验、转换解析和初始化。最终形成可以被虚拟机直接使用的Java类型。这个过程称作虚拟机的类加载机制。类加载机制是虚拟机中很重要的一部分内容,在面试中出现的频率也是比较高。因此,作为一个Java程序员,JVM的类加载机制是我们必须掌握的知识。

为了更好的理解类加载的过程,我们先来看一道类加载的面试题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Person.java
public class Person{
static{
System.out.println("I'm a person");
}
}

// Stuent.java
public class Student extends Person{
public static String indentity="Student";
static{
System.out.println("I'm a student");
}
}

public class Ryan extends Student{
static{
System.out.println("I'm Ryan");
}
}

接下来我们我们写一个测试类:

1
2
3
4
5
public class Test {
public static void main(String[] args) {
System.out.println("Ryan.indentity=" + Ryan.indentity);
}
}

大家可以先不要看下边的答案,试着分析一下会输出什么样的结果。

好了,公布答案,上述代码结果输出如下:

1
2
3
I'm a persion
I'm a student
Ryan.indentity=Student

是否和你的答案一样呢?如果不一样,那说明你还没有清楚的理解JVM的类加载机制。接下来不妨就来学习一下JVM的类加载吧。

一、类加载的过程

一个类从被加载到虚拟机内存中开始,到卸载出虚拟机内存为止,它的声明周期会经历加载(Loading)、连接(Linking)、初始化(Initialization)、使用(Using)和卸载(Unloading)这几个阶段。而连接阶段又包含验证(Verification)、准备(Preparation)、解析(Resolution)三个阶段。如下图所示:
在这里插入图片描述
其中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始。接下来,我们来详细的了解Java虚拟机中类加载的过程,即加载、验证、准备、解析和初始化这五个阶段。

1.加载阶段

加载阶段是类加载过程的第一个阶段。这一阶段JVM会通过类的全限定名(可能来自.class文件,也可能来自ZIP压缩包、网络等,甚至可以是运行时生成。)来读取类的二进制字节流。并将读取到的二进制字节流转化为方法区的运行时数据结构,然后在内存中生成一个代表这个类的java.lang.Class对象。

简单的来说,这一阶段就是将类的字节码二进制流读取到JVM,并生成代表这个类的Class对象。

2.连接

连接阶段包含了验证、准备、解析三个过程。

(1)验证

这一阶段的目的是为了保证class文件字节流符合当前虚拟机的要求。并且保证这些数据代码运行后不会危害虚拟机自身安全。验证阶段大致会完成四个阶段的检验:文件格式验证、元数据验证、字节码验证、符号引用验证。

文件格式验证

这一阶段验证字节流是否符合Class文件格式的规范,并且能被当前的虚拟机处理。

元数据验证

这一阶段是对字节码描述的信息进行语义分析,保证其描述的信息符合Java语言规范的要求。

字节码验证
这一阶段会通过数据流和控制流分析,确定程序语义是否合法,符合逻辑。这个阶段对类的方法体就行校验分析,保证被校验的类的方法在运行时不会做危害虚拟机安全的事情。

符号引用验证
最后一个阶段的校验发生在虚拟机符号引用转化为直引用的时候,这个转化动作将在连接的第三阶段–解析阶段发生。符号引用校验可以看做是对类自身以外(常量池的各种符号引用)的信息进行匹配性校验。

(2)准备

准备阶段是类加载机制中很重要的一个阶段。 这一阶段是正式为类中定义的静态变量(被static修饰的变量)分配内存并设置类变量初始值的阶段。这些变量所使用的内存都应该在方法区中进行分配。我们知道,在JDK7之前,Hotspot虚拟机使用永久代来实现方法区。而在JDK8之后,方法区被放在了Java堆中。因此,类变量也会随着Class对象一起存放在Java堆中。

另外,关于准备阶段有两点需要注意:

为变量分配内存 我们知道,Java类中的变量可以分为成员变量类变量,类变量是指被static修饰的变量,其他类型的变量都属于成员变量。而准备阶段的内存分配仅包括类变量,不包括成员变量成员变量只有在对象实例化的时候随着对象一起分配到Java堆中。

例如下面的代码在准备阶段只会为value分配内存,而不会为str分配内存。

1
2
3
4
public class Test {
public static int value = 123;
public String str = "123";
}

为类变量赋初始值 在准备阶段,JVM会为类变量分配内存,并对其初始化。而初始化的值并非我们在代码中赋予的值,而是数据类型的零值。 例如上述代码中经过准备阶段后value的值是0,而并非123。但如果给value再加一个final修饰符,那么经过准备阶段,value的值就是123(因为此时的value相当于一个常量),这是因为在编译时Javac会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

(3)解析

解析阶段是虚拟机将常量池的符号引用替换为直接引用的过程。这一阶段不太重要,了解即可。

3.初始化

初始化是类加载的最后一个阶段,也是类加载过程中最重要的一个阶段。在这一阶段用户定义的Java程序代码(字节码)才真正开始执行。什么意思呢?刚才提到在准备阶段JVM会为类变量赋默认的初始值,而初始化阶段类变量才会被赋予我们在代码中声明的值。JVM会根据语句执行顺序对类对象进行初始化

在《Java虚拟机规范》中并没有强制约束在什么情况下开始执行类加载的第一个“加载”阶段,但是对于初始化阶段《Java虚拟机规范》中却有着严格的约束。一般来说当 JVM 遇到下面 6 种情况的时候会触发类加载的初始化(在执行初始化阶段之前需要先执行加载、验证和准备阶段):

① 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。

② 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

③ 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

④ 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

⑤ 当使用 JDK1.7 动态语言支持时,如果一个 java.lang.invoke.MethodHandle实例最后的解析结果
REF_getstatic,REF_putstatic,REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。

⑥ 当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

二、类加载例题分析

在了解了类加载的过程后,我们通过几个例题分析来深入的理解类加载。

1.开篇例题分析

开篇的面试题中在main方法中调用了Ryan.indentity,而indentity是位于Ryan父类Student中的类变量。根据初始化阶段中的第①点我们可以知道:

读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候会触发类的初始化。

因此,此时会首先去加载并初始化Ryan类,而从初始化③中我们知道:

当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

因此,最先被加载并被初始化的类应该是Person类,Person类的初始化导致了首先输出 I’m a person语句。
接着Student类被加载,所以第二行输出 了I’m a student 。最后完成Ryan类的加载并输出 Ryan.indentity=Student

2.例题二

给出Singleton 类如下代码所示,请分析程序的输出结果。大家可以先不要答案,试着自己分析一下结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Singleton {
private static Singleton singleton = new Singleton();
public static int x;
public static int y = 0;

private Singleton() {
++x;
++y;
System.out.println("Singleton构造方法执行,x = " + x +",y = " + y);
}

public static void main(String[] args) {
System.out.println("singleton.x = " + singleton.x);
System.out.println("singleton.x = " + singleton.y);
}
}

输出结果:

1
2
3
Singleton构造方法执行,x = 1,y = 1
singleton.x = 1
singleton.x = 0

如果不了解类加载的过程,会觉得这是一个很奇怪的输出结果。x、y的初始值都是0,在构造方法种经过了同样的“++”操作,而最后的输出结果为什么不一样呢?我们还是先从出发类初始化的几个条件着手。

1)从触发类初始化的第④ 个条件可知,虚拟机启动时会先加载包含main方法的类。因此Singleton 首先会触发类加载流程。

2)而经过加载、验证流程后,进入类加载的准备阶段,这一阶段虚拟机会为类变量分配内存和并对其进行初始化赋值。注意,准备阶段只会给类变量赋默认值,经过准备阶段后结果如下:

1
2
3
4
5
public class Singleton {
private static Singleton singleton = null;
public static int x = 0;
public static int y = 0;
}

3)初始化阶段会根据代码顺序为类变量赋代码中声明的值。因此,首先会实例化Singleton ,并将实例化后的值赋给singleton。而此时,由于x、y还没有被赋值。因此x、y均为0。所以,在经过“++”操作后输出x、y的值均为1。

4)接下来为x、y赋代码中声明的值,而在我们的代码中x没有赋初始值,y则被赋值为0。因此,此时x仍然为1,而y则被赋值为0.

5)类加载完成后打印x、y的值。

经过以上两个例题的分析,相信大家对JVM的类加载机制有了一个更清楚的认识。而在类加载机制中除了类加载的过程,还有一个很重要的知识点,那就是类加载器,我们接着往下看。

三、类加载器

前两章我们了解了类加载的过程,而类加载的过程则是由类加载器来完成的。类加载器在Java程序中起到的作用可以说远超类加载阶段。我们在程序中使用到的任意一个类都需要类加载器将其加载到虚拟机,并且由类加载器保证被加载类的唯一性。这里我们需要明白一点:两个类是否相等的前提条件是这两个类是由同一个类加载器加载的。如果两个类来自同一个Class文件,但是被同一个虚拟机下不同的类加载器加载,那么这两个类必定不相等。

那么问题来了,虚拟机是如何保证同一个Class文件只能被同一个类加载器加载的呢?要解答这个问题首先要了解类加载器的划分。

1.类加载器的分类

在Java中类加载器分为启动类加载器(Bootstrap Class Loader)、扩展类加载器(Extension Class Loader)、应用类加载器(Application Class Loader)以及自定义类加载器(User Class Loader)。接下来我们就分别来认识这几种类加载器。

1)启动类加载器(Bootstrap Class Loader)

这个类加载器是虚拟机的一部分,使用C++语言实现。这个类加载器只负责加载存放在\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的Java虚拟机能够识别的(按照文件名识别,如rt.jar、tool.jar。名字不符合的类库即使放在lib目录下也不会被加载)类库加载到虚拟机中。

2)扩展类加载器(Extension Class Loader)

这个类加载器位于类sun.miss.Launcher$ExtClassLoader中,并且是由Java代码所实现的。它负责加载\lib\ext目录中,或被java.ext.dirs系统变量所指定的路径中所有的类库。开发者可以直接在程序中使用扩展类加载器来加载Class文件。

3)应用程序类加载器(Application Class Loader)

这个类加载器位于sun.misc.Launcher$AppClassLoader中,同样是由Java语言实现。他负责加载用户类路径(ClassPath)上所有的类库。开发者同样可以直接在代码中使用这个类加载器。如果程序中没有自定义的类加载器,一般情况下这个就是程序中默认的类加载器。

4)自定义类加载器(User Class Loader)

除了上述三种Java系统中的类加载器外,很多情况下用户还会通过自定义类加载器加载所需要的类。诸如增加除了磁盘位置之外的Class文件来源,或者通过类加载器实现类的隔离、重载等功能

2.双亲委派模型

在本章开头我们已经提到两个类相等的前提条件应该是这两个类是由同一个类加载器加载的。既然Java种存在这么多的类加载器,那么Java是如何保证同一个类都是由同一个类加载器加载的呢?这主要得益于类加载器的“双亲委派模型”。接下来我们就来认识一下什么是“双亲委派模型”。

如下图所示,展示了各个类加载器之间的层次关系就是本节要讲的“双亲委派模型”
在这里插入图片描述
双亲委派模型要求除了顶层启动类加载器外,其余的类加载器都应该有自己的父类加载器。而这里类加载器之间的父子关系不是通过继承来实现的,而是通过组合的关系来复用父加载器的代码。

双亲委派模型的工作过程如下:

如果一个类加载器收到了类加载的请求,首先它不会自己尝试加载这个类,而是把这个请求委派给父类加载器来完成,每个层次的类加载器都是如此。因此,所有的类加载请求最终都会被传送到最顶层的启动类加载器种,只有当父加载器无法找到这个加载请求的类时,子类加载器才会尝试去完成加载。

双亲委派模型的代码实现非常简单,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected synchronized Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {
// 首先检查该类型是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) { //如果这个类还没有被加载,则尝试加载该类
try {
if (parent != null) { // 如果存在父类加载器,就委派给父类加载器加载
c = parent.loadClass(name, false);
} else { // 如果不存在父类加载器,就尝试使用启动类加载器加载
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {// 父类加载器找不到要加载的类,则抛出ClassNotFoundException
// 尝试调用自身的findClass方法进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}

这段代码的逻辑非常清晰易懂,代码中已经做了详细的注释说明。正是因为双亲委派模型具备一种带有优先级的层次关系,使得无论哪个类加载最终都会委派给处于最顶层的启动类加载器进行加载,因此保证了在程序种各个类加载器环境中都能够保证是同一个类。

四、小结

类加载机制通常是很多面试者的噩梦,碰到类加载的面试题只能束手无策。而通过本篇文章的学习,你会发现其实类加载是一个很简单的过程,只要记住类加载的几个重要阶段,就能轻松驾驭大多数相关试题。另外,类加载器也是一个重要的知识点。虽然重要,但也简单,用短短几行代码通过”双亲委派模型“就实现了类加载的过程。不得不让我们佩服Java设计的精妙。

五、参考&推荐阅读

《深入理解Java虚拟机 第三版》 作者周志明

两道面试题,带你解析Java类加载机制


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!