logo头像

路漫漫兮其修远兮

Java虚拟机(六):类加载机制

大家都知道,我们编写的Java类经过编译器编译后会生成class文件,class文件描述了类的各种信息,最终都要加载到内存中才能运行使用,那虚拟机是如何加载这些class文件的呢?加载又有哪些过程呢?是否程序一启动就把所有的类都加载到内存中呢?下面我们就来讨论这些问题。

上面说到Java类经过编译器编译后会生成class文件,这个说法在现在可能有些不准确了,更准确的说法应该是“Java类经过前端编译器编译后会生成class文件”,因为现在的Java会有一个JIT机制,JIT属于后端编译,JIT编译器会在Java程序运行期间将“热点代码”编译成机器码,以提高运行效率。本文所提到的编译都是前端编译,不涉及后端编译,关于后端编译,会在下一篇文章中详细讨论。

1 什么是类加载

编写好Java代码经Java编译器(Javac)编译之后会生成class字节码文件,这是我们从刚开始学习Java就知道的事。实际上,这仅仅是Java程序运行的第一步,JVM还必须在运行时将字节码载入到内中,然后验证、分析字节码文件,并执行相应的指令,最后该class文件对应的Java类才能被使用,这就是类加载机制。

那为什么会有这个类加载机制呢?学过C++的朋友应该知道,C++程序要运行大体有编译和链接两个过程,这样的好处是运行时效率非常高,不需要在做额外的操作,但大型的C++程序的编译速度会慢得令人发指。Java就不这样干,它会先编译源代码成字节码,然后在运行时动态的将字节码加载到内存中,这样的效果是大大降低了编译速度和启动速度(我们发现即使大型的Java程序,编译和启动过程都不会太慢),只有需要用到某个类的时候才会将其字节码加载到内存中,但运行时效率就会受到影响(不过随着JIT技术的成熟,这个方面的性能问题已经得到了很大的改善),这是Java程序运行时性能不如C++程序的一方面原因。

2 类加载过程

iMLsNd.png

上图是Java类的生命周期,从加载到卸载。我们主要关注的是加载、验证、准备、解析和初始化5个阶段,使用和卸载暂不讨论。这些阶段都是交叉运行的,例如加载阶段可能还没完成,验证就已经开始了,这有点像流水线作业一样。Java虚拟机没有明确规定什么时候应该开始加载一个类,但规定了什么时候应该开始初始化一个类,而加载的开始必须要发生在初始化开始之前(但初始化开始并不就一定需要等待加载阶段结束)。虚拟机规定了如下5种情况必须立即对类进行初始化:

  1. 遇到new、getstatic、putstatic和invokestatic这四条指令时,如果该类没有进行过初始化,就必须先触发其初始化操作。
  2. 使用java.util.reflect包的方法对类进行反射调用的时候,如果该类没有进行过初始化,就必须先触发其初始化操作。
  3. 当初始化一个类时,如果其父类没有进行过初始化,就先触发其父类的初始化。
  4. 当虚拟机启动时,会先启动用户指定的主类(包含main方法的类)。
  5. 当使用动态代理相关技术时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getStatic、REF_pubSttic、REF_invokeStatic的方法句柄,并且这个方法所对应的类没有进行过初始化,那么必须先触发其初始化。

“有且仅有”上述5种情况才会触发初始化,这5中情况的行为被称作“主动引用”,其他引用类的方式都不会触发初始化,被称作“被动引用”。只有根据这5种情况来判断类是否初始化才是正确的,根据其他的诸如“经验法则”等会很容易出错,所以正确理解5种情况所要表达的意义才是关键。

2.1 加载阶段

加载阶段,主要完成以下3个事情:

  1. 通过一个类的全限定类名来获取该类对应的二进制字节流。
  2. 将这个字节流转换成方法区的运行时数据结构。
  3. 在内存中生成代表这个类的class对象,作为方法区这个类的各种数据访问的入口。

这里的二进制字节流并不一定就是class文件,只要是二进制字节流就行,也没有规定该字节流从哪获取,所以其实获取字节流的方式有很多,例如从压缩包中获取、网络传输通道中获取,运行时生成(动态代理等技术)等。加载阶段是类加载整个过程中唯一可以由开发人员掌控的,开发人员可以通过重写Classloader的loadClass()方法来更改加载的方式,但最好要遵循双亲委派模型。

数组类和普通类的加载阶段有一些差别。数组类是由虚拟机直接创建的,而不是由类加载器创建的,但数组类和加载器仍然有密切的关系。如果数组的元素类型是引用类型,那么就根据普通类的加载规则去加载该类,数组类将与加载该类的加载器建立唯一关系标识,如果数组元素类似引用类型,那么数组类将与bootstrap加载器建立唯一关系标识。

加载完成之后,会在方法区中生成一个代表该类的Class对象,class对象虽然是对象,但确实是存储在方法区里,这算是一个特例,主要目的应该是方便直接在方法区里访问Class对象。

2.2 验证

验证和加载阶段是交叉运行的,即加载阶段可能刚刚开始加载字节流,验证阶段就开始对字节流进行验证了,但验证开始时机仍然发生在加载开始之后。

Java号称是一门安全的语言,所以验证阶段就显得尤为重要,在验证阶段中,虚拟机会验证字节码是否符号虚拟机规范,是否存在恶意的字节码指令,逻辑是否符合Java语言规范等。如果验证失败,虚拟机应该抛出一个java.lang.VerifyError异常或者其子类并停止类加载过程。验证阶段大致分为4个校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

2.2.1 文件格式验证

文件格式验证即验证字节流是否符合Class文件的格式规范,例如开头的CAFEBABE魔数,版本号、常量池等各种信息的先后顺序、有UTF-8要求的字符串是否满足UTF-8字符编码等等。这个阶段的操作目标是字节流,只有通过了这个阶段的验证,并将字节流描述的类存储到方法区中,才能进行后面的验证,因为后面的几个验证动作都是基于方法区的数据结构来做的。

2.2.2 元数据验证

这个阶段就是对字节码描述的信息做语义分析,验证字节码是否符合Java语言的规范,例如这个类是否有父类,这个类是否被设置成不可继承的类,如果该类不是抽象类且实现了接口,是否实现了接口的抽象方法等等。这里还要说一下,Java语言规范和Java虚拟机规范是两码事,不能一概而论。

2.2.3 字节码验证

这个阶段主要进行的是用数据流和控制流分析程序语义是否合法,保证被校验的类不会做出危害虚拟机的事情。该阶段是很复杂的,也是比较耗时的,所以后期的Java虚拟机对整个步骤做了一些优化,使用“StackMapTable”属性来保存本地变量表和操作数等信息,当需要进行字节码验证的时候,就直接验证“StackMapTable”里的信息即可,大大减少了验证时间。

2.2.4 符号引用验证

这个阶段会严重符号引用是否正确,符号引用是否正确的判断依据主要有以下几个:

  • 是否能通过描述符号引用的全限定类名字符串找到对应的类。

  • 在指定的类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。

  • 符号引用中的类、字段、方法的访问性是否可以被当前类访问。

    …….

只有完成了符号引用验证,后续在解析阶段将符号引用转换成直接引用的时候才可能成功,如果验证失败,将会抛出java.lang.NoSuchMethodError、java.lang.NoSuchFieldError等异常。

对于类加载机制来说,验证阶段虽然是非常重要的,但并不是必须的,如果要运行的代码已经经历过反复验证和使用,那么就可以省略掉验证这个阶段,从而降低类加载的时间。

2.3 准备

准备阶段是为类变量分配内存并初始化的阶段,这些变量所使用内存都是方法区内存,需要注意的是,类变量和实例变量是不同的,准备阶段仅包括类变量(static修饰的)的内存分配和初始化。初始化是给变量赋予对应类型的“零值”,这里的“零值”并不是特点的数字0,对于数字类型来说确实是0或者0.0,对于布尔型变量来说是false,引用类型是null等,下面这个表格给出了各种类型的“零”值:

iQ9oUf.png

即使用户在声明的时候并同时赋值,也不会马上按照程序员的意愿进行操作,如下所示:

1
2
3
public class Main {
private static int a = 123;
}

这里a在仅仅会被赋值成0,而不是123。但有一个例外,就是常量!常量会直接根据程序员的意愿进行操作:

1
2
3
public class Main {
private static final int a = 123;
}

a被final修饰了,所以他是一个常量,在准备阶段,虚拟机会根据常量值做赋值操作,即准备阶段完成后,a的值是123而不是0。

2.4 解析

解析过程的作用是将符号引用转换成虚拟机可以直接使用的直接引用。在之前的文章中,有不少地方提到过符号引用,但一直没有详细解释什么是符号引用,在此就详细介绍一下吧:

  • 符号引用。符号引用可以是任何形式的字面值常量,只要能在使用时无歧义的定位到目标即可。在HotSpot虚拟机中,是以字符串的形式存在的,而且往往是一组字符串。这组字符串所代表的可能是某个类、某个接口等,无论代表的是什么,只要能唯一的定位到目标,那就是一个正确的符号引用。关于符号引用的更多,推荐看看知乎上这个问题:JVM里的符号引用如何存储
  • 直接引用。直接引用可以是指向目标的指针、相对偏移量或者一个能间接定位到目标的句柄等,直接引用和虚拟机的内存布局是有关的,同一个符号引用在不同的虚拟机里翻译处理的直接引用往往不相同,如果成功将符号引用转换成直接引用了,那么直接引用的目标肯定是已经存在于内存中的。

解析阶段的对象不仅仅是类,还包括接口、方法、字段等。因为要访问一个接口或者方法、字段都需要有一个直接引用,而直接引用又是由符号引用转换而成的。关于解析更加详细的内容建议细节看看《深入理解Java虚拟机》的7.3.4节内容。

2.5 初始化

初始化阶段是执行类构造器()方法的过程。需要注意的是这里的()方法不包括实例构造器,实例构造器属于()方法,换句话说,这里不会执行实例构造器或者初始化代码块里的内容。这是因为这里的初始化阶段还属于类加载的过程,没有涉及到实例化的过程,实例构造器或者初始化代码块的代码会在类被实例化成对象的时候执行。

()方法由静态变量的赋值语句和static代码块里的语句构成,出现在前的语句在合并后仍然出现在前,即顺序保持源码中的顺序。有一个比较奇怪的现象,在我们编写源码的时候,static块里能对声明在后面的变量做赋值操作,只是不能访问。

()方法不需要显式的调用父类的()方法(实例构造器需要显示的调用,只是大多数时候,编译器会帮我们在第一行添上了),虚拟机会保证在调用子类的()方法之前调用父类的()方法。基于这个机制,父类的静态变量的赋值操作优先于子类的静态变量赋值。

()方法并不是必须的,如果一个类没有任何静态变量和静态块,那么虚拟机就不会为该类生成()方法。还有就是虽然接口不能定义静态块,但可以定义静态变量,所以接口也是可能有(),但和类的()不同,接口执行()方法之前不需要执行父接口的()方法,只有在父接口中定义的变量被使用时,才会调用父接口的方法。

虚拟机还会保证方法是线程安全的,即多个线程同时要去执行()方法也仅仅有一个方法能真正执行,其他线程会被阻塞,当能执行()方法的线程执行完毕之后,其他线程被唤醒,但不会再去尝试执行方法,这避免了重复执行()。我们可以写一些代码尝试一下:

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 ClinitTest {

static class Test {
private static int a = 32;

static {
a = 42;
System.out.println(Thread.currentThread().getName() + "execute static");

}
}


public static void main(String[] args) throws InterruptedException {
Runnable r = () -> {
System.out.println(Thread.currentThread().getName() + " started");
Test test = new Test();
System.out.println(Thread.currentThread().getName() + "end");
};
Thread thread1 = new Thread(r);
Thread thread2 = new Thread(r);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
}

结果如下所示:

1
2
3
4
5
Thread-0 started
Thread-1 started
Thread-1execute static
Thread-1end
Thread-0end

可见,()方法只被执行了一次,符合我们上面说到的规则。

3 类加载器

类加载器是这么一个东西:可以通过一个类的全限定类名获取描述该类的二进制字节流的代码模块。有了上面的分析,我们知道这其实是“加载”阶段的一个步骤,虚拟机设计团队之所以单独将其抽离出来,是为了方便应用程序自己决定如何获取需要的字节流。

我们可以通过重写ClassLoader的loadClass()方法来改变加载类的方式,如下代码所示:

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
public class ClassLoaderTest {
public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {

ClassLoader classLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
try {
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
}
byte[] bytes = new byte[is.available()];
is.read(bytes);
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException();
}
}
};

Class<?> clz = classLoader.loadClass("top.yeonon.ch11.ClassLoaderTest");
System.out.println(clz);
System.out.println(clz.newInstance() instanceof ClassLoaderTest);
}
}

代码重写了loadClass方法,只是简单的通过class文件来获取。其中最后一行代码的返回结果是false,为什么呢?因为我们用自己重写了loadClass()方法的classLoader来加载类,这里获取到的类和虚拟机加载类是不一样的,即使它们是同一个类。那为什么会不一样呢?因为类加载器不一样,现在虚拟机中有两个ClassLoaderTest类,一个是由系统的类加载器加载的(更准确的应该是ApplicationClassLoader),一个是由我们自己实现的classloader加载,虚拟机判断两个类是否是同一个类不仅仅是通过他们的全限定类名来判断,还通过加载他们的类加载器是否一样来判断,只有满足上述两个条件,虚拟机才会认为两个类是相同的。

既然讲到了ApplicationClassLoader,接下来就讨论一下双亲委派模型。

3.1 双亲委派模型

在JDK8及以下的版本中(JDK9之后有不小的改动),默认的有三种类加载器:

  1. BootStrap ClassLoader(引导类加载器)
  2. Extension ClassLoader(扩展类加载器)
  3. Application ClassLoader(应用类加载器)

引导类加载器负责加载$JAVA_HOME/lib下的,或者被-Xbootclasspath参数指定的路径下的,并且是虚拟机识别的(有些类即使在上述两个路径下,也不会被加载)类。这个类加载器是由C++实现的(HotSpot虚拟机),所以使用Java代码无法获取,只会返回null,当我们需要将类加载委托给它时,用null代替接口。

扩展类加载器负责加载$JAVA_HOME/lib/ext目录,或者被java.ext.dirs系统变量指定的路径下的类。这个类加载器是由Java语言实现的,用户可以直接使用该类加载器。

应用类加载器负责加载classpath中指定的路径中的类,一般我们编写的Java类都是由这个类加载的。

有了上述三个概念,我们就可以看看双亲委派模型的定义了:如果一个类加载器收到了类加载请求,它不会自己马上去尝试加载这个类,而是将这个请求委托给父类加载器完成,如果父类加载器上面还有父类加载器,那么会继续将委托向上提交,直到引导类加载器,如果引导类加载器无法加载这个类,就会将请求往下传,只要中途有一个类加载器加载成功了,就不会继续往下走了。

为了理解这个过程,举个例子。假设我们现在编写了一个top.yeonon.Test类,当需要加载这个类的时候,如果没有其他类加载器,默认就先将请求发送到Application ClassLoader,Application ClassLoader有父类加载器Extension ClassLoader,所以它就将请求发送到Extension ClassLoader,Extension ClassLoader也同理,最终请求达到最顶层的BootStrap ClassLoader,BootStrap ClassLoader发现top.yeonon.Test这个类自己不能加载,然后将请求原路返回,到Extension ClassLoader的时候,Extension ClassLoader发现自己也不能加载,然后再回到Application ClassLoader,这时候没地方去了,Application ClassLoader才会尝试去加载该类,如果加载成功(该类确实在classpath路径下),那么就完成了类加载,如果加载失败,就会抛出异常。下面是双亲委派模型的示意图:

iQkRB9.png

那为什么Java要搞这么一套双亲委派模型呢?为了保证安全,试想一下,假设我们现在编写了一个java.lang.String类,在这类里加入了一些恶意代码,如果没有双亲委派模型,这个类就会直接被Application ClassLoader加载,当用户使用String类的时候,就会用到这个含有恶意代码的类,从而造成应用程序崩溃或者重要信息泄露。

4 小结

本文介绍了什么是类加载、类加载过程已经类加载器和双亲委派模型,类加载是一个比较独特的特性,这个机制使得Java程序更加安全、高效,理解类加载过程也有助于解决各种由于类加载导致的问题。

5 参考资料

《深入理解Java虚拟机》