《深入理解Java虚拟机》读书笔记 - 类加载机制

此篇为《深入理解Java虚拟机》第七章7.2、7.3、7.4部分的读书笔记

概述

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。

类加载时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Loading)、准备(Loading)、解析(Loading)、初始化(Loading)、使用(Loading)、卸载(Loading)7个阶段。其中验证、准备、解析3个部分统称为连接(Linking),这7个阶段的发生顺序如下所示。



注意两点:

  • 加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类加载过程必须按照这个顺序进行,而解析阶段因为需要支持 Java语言的运行时绑定,可以在初始化阶段之后开始。
  • 类加载时,这7个阶段虽然必须是要按顺序开始,但是并不要求7个阶段按顺序结束,它们通常以交叉混合式进行的。

类加载过程

加载

“加载”是“类加载”(Class Loading)过程的一个阶段。在加载阶段,虚拟机需要完成以下3件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

注意这里不一定非要从 Class 文件中获取,在这个阶段中,既可以从以下几个不同地方获取:

  • 从ZIP 包中读取(JAR、EAR、WAR 格式的包也可以)。
  • 从网络中获取。一般应用场景为RMI。
  • 运行时计算生成。一般应用场景为动态代理。
  • 由其他文件生成。一般应用场景为 JSP 应用。
  • 从数据库中读取。

验证

验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

验证阶段是非常重要的,这个阶段是否严谨,直接决定了 Java 虚拟机是否能承受恶意代码的攻击,从执行性能的角度上讲,验证阶段的工作量是在虚拟机的类加载子系统中又占了相当大的一部分。

在验证阶段中,大致会完成下面4个阶段的校验工作:

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

准备

准备阶段是正式为类变量分配内存并设置类变量初始化值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段需要注意以下两点:

  • 准备阶段进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
  • 这个阶段的初始化值,在通常情况下,是数据类型的所对应的零值,假设一个类变量的定义为:
1
public static int value = 123;

那变量 value 在准备阶段过后的初始值为0而不是123,真正的赋值操作将延迟到初始化阶段进行。但若上述的类变量 value 的定义变为:

1
public static finla value = 123;

那么,在编译时虚拟机将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为123。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用通常以 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等类型的常量出现。下面将解释符号引用和直接引用的关系:

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标不一定已经加载到内存中。各个虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
  • 直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经存在于内存中。

初始化

类初始化阶段是类加载过程的最后一步,前面的类加载过程之中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其他动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。

初始化阶段是执行类加载器<clinit>()方法的过程。

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。

为什么能够赋值,但不能访问呢?个人认为在运行期间的准备阶段时,类变量已经经过了零值的初始化了,所以赋值操作是正常进行的,但是在编译期间,编译器认为这种操作是错误的(非法向前引用)。

  • <clinit>()方式与类的构造函数(或者说是实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是 java.lang.Object。
  • 由于父类的<clinit>()方法优先执行,那么父类中的静态语句块也优先于子类执行。
  • <clinit>()方法对于类和接口并不是必需的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口和类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化阶段也一样不会执行接口的<clinit>()方法。
  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁,同步,如果多线程同时去初始化一个类,那么只有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。

类加载器

虚拟机设计团队把类加载阶段中的加载动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为”类加载器“。

类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立在其 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立类名称空间。

双亲委派模型

从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++语言实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由 Java 语言实现的,独立于虚拟机外部,并且全部继承自抽象类 java.lang.ClassLoader。

从 Java 开发人员的角度来看,绝大部分 Java 程序都会使用以下3种系统提供的类加载器:

  • 启动类加载器(Bootstrap ClassLoader):这个类加载器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使存放在 lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接饮用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,那直接使用 null 代替即可。
  • 扩展类加载器(Extension ClassLoader):这个类加载器有sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext,目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库。开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader实现,它负责加载用户类路径(ClassPath)上所指定的类库。开发者可以直接使用应用程序类加载器。

如下图所示,以上的类加载器之间的层次关系,称之为类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型要求除了顶层的启动类加载器外,其余类加载器都应当有自己的父类加载器。这里的类加载器之间的父子关系一般不会以继承的关系来实现,而都是通过组合关系来复用父类加载器的代码。



类加载的双亲委派模型不是虚拟机中强制性的约束模型,而是 Java 设计者推荐给开发者的一种类加载实现方式。

双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试去加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(即他的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

采用双亲委派模型的好处在于,Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。无论是哪一个类加载器要加载某个类,最终都是委派给处于模型最顶端的的启动类加载器进行加载,这样保证了 Java 类在程序的各种类加载器环境中都是同一个类。

下面为简单解释一下实现双亲委派模型的关键代码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
27
28
29
30
31
32
33
34
35
36
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 首先,检查类是否已经被加载到内存中
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 这一步中运用了双亲委派模型。
if (parent != null) {

// 当该类加载器中存在父类加载器,那么就调用其父类加载器来响应加载类的请求
c = parent.loadClass(name, false);
} else {
// 如果没有父类加载器,那么说明该类加载器为启动类加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// ...其余代码
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

破坏双亲委派模型

具体操作实现在P231。