JVM 类加载

本文将介绍 JVM 中类加载机制的各种细节。

一、什么是类加载?

类文件中描述的信息,最终都需要加载到虚拟机中才能被使用。

所谓类加载,就是:将数据加载到内存,并对数据进行校验、转换解析、初始化,最终形成可以被 JVM 直接使用的类。

二、类的生命周期

一个类型从被加载到虚拟机内存开始,到卸载出内存为止,它的生命周期会经历加载、验证、准备、解析、初始化、使用、卸载七个阶段,如图所示。

需要注意的是:

  • 验证、准备、解析这三个阶段统称为连接

  • 加载、验证、准备、初始化、卸载这五个阶段将会按顺序 开始,而解析阶段不一定

    • 在某些情况下,解析阶段可以在初始化阶段之后再开始,这是为了支持 Java 的运行时绑定特性
    • 是按顺序 开始 而不是按顺序 进行,是因为这些阶段通常是相互交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段

三、加载

1. 说明

在加载阶段,JVM 需要完成 3 件事情:

  • 通过类的全限定名来获取定义此类的二进制字节流
  • 将二进制字节流(静态存储结构)转化为方法区的类型数据(运行时数据结构)
  • 在内存(堆)中生成一个 Class 对象,程序将通过这一对象访问方法区中的类型数据

2. 加载的时机

《Java 虚拟机规范》中并没有强制规范,由虚拟机自行把握。

3. 类加载器

(1) 类加载器的两大作用

  • 加载:类加载器负责完成加载阶段的工作,具体来说,类加载器会根据全限定名加载二进制字节流,转化生成 Class 对象
  • 标识:类通过 类本身 + 类加载器 作为唯一标识

(2) 系统类加载器

在 JDK8 及以前,系统提供了以下三个类加载器:

  • 启动类加载器 Bootstrap Class Loader:

    • 是 JVM 的一部分
    • 可能由 C++ 代码书写,也可能由 Java 代码书写,视 JVM 具体实现而定
    • 负责加载 <Java_HOME>\lib 目录 + 被 -Xbootclasspath 参数所指定的目录 中的类库
    • 无法在 Java 程序中访问
  • 扩展类加载器 Extension Class Loader:

    • 由 Java 代码书写

    • 负责加载 <Java_HOME>\lib\ext 目录 + 被 java.ext.dirs 参数所指定的目录 中的类库

      JDK 允许用户将具有通用性的类库放置在 ext 目录中,以扩展 Java SE 的功能

    • 可以在 Java 程序中访问,从而通过它加载 Class

  • 应用程序类加载器 Application Class Loader:

    • 又被称作系统类加载器
    • 由 Java 代码书写
    • 负责加载 ClassPath 中的类库
    • 可以在 Java 程序中访问,访问方式为 ClassLoader.getSystemClassLoader(),从而通过它加载 Class

(3) 自定义类加载器

用户也可以自定义自己的类加载器,从而实现从网络中获取类、运行时计算生成类等需求。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
class NetworkClassLoader extends ClassLoader {

public Class findClass(String name) {
byte[] b = loadClassData(name);
return defineClass(name, b, 0, b.length);
}

private byte[] loadClassData(String name) {
// ···从网络中加载类···
}
}

如果不想打破双亲委派机制,重写 findClass() 即可;如果希望打破双亲委派机制,应该重写 loadClass()

具体请看下文。

(4) 双亲委派机制

所谓双亲委派机制,其实就是 “叫父帮忙” 机制。

需要注意的是,

  • “父子” 关系并不是通过继承实现,而是通过 “组合复用” 实现的
  • 所谓 “父子” 关系,严格来说应该是层级关系

双亲委派机制具体为:

  • 所有的加载器构成如上图所示的层级关系
  • 如果有加载请求,应该优先递归交由上级加载,当上级无法加载时,才尝试自己加载。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public abstract class ClassLoader {

protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
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);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

}

双亲委派机制的优点是:

  • 安全:通过双亲委派机制,可以保证 “基础类” 都由系统加载器加载,使得任意情况下通过任何类加载器加载所得的 “基础类” 都是相同的

    而不是加载器 A 加载一份 String,加载器 B 加载一份 String。。。导致系统中一片混乱

  • 避免重复加载:双亲委派机制可以保证类只被加载一次,避免重复加载

(5) 线程上下文类加载器

线程上下文类加载器与双亲委派机制

双亲委派机制是 Java 设计者的建议,而非强制规定,线程上下文类加载器便是对双亲委派机制的破坏。

为什么需要线程上下文加载器?

为什么需要线程上下文类加载器来完成SPI调用外部实现? - 知乎

假设有类 C,C 中定义了一个接口,希望获取这个接口的实例。期望的具体做法是,提供一个方法让用户传入全限定名,在类 C 中加载类、实例化。

此时便会遇到问题,C 无法预知应该用什么类加载器加载,将导致类加载失败。

为此,JDK 提供了线程上下文加载器,可以将其作为传递类加载器的通道。

具体做法
  • 获取并保存旧 ContextClassLoader:

    ClassLoader oldContextClassLoader = Thread.currentThread().getContextClassLoader()

  • 设置 ContextClassLoader:

    Thread.currentThread().setContextClassLoader(contextClassLoader)

  • 调用第三方方法,在第三方方法中,应该获取 ContextClassLoader 并使用

  • 还原 ContextClassLoader:

    Thread.currentThread().setContextClassLoader(oldContextClassLoader)

四、验证

对类的二进制字节流做一系列检查,确保其符合要求。

五、准备

为类的静态变量分配内存并赋初始值。

需要注意的是:

  • 假设有静态变量 static int value = 123,在此阶段结束后,value 的值应该是 0

    经编译后,value = 123 将被放置于类构造方法 <clinit>() 中,在类的初始化阶段才会被执行

  • 假设有静态变量 static final int value = 123,在此阶段结束后,value 的值应该是 123

    经编译后,value 字段的属性表中将会有 ConstantValue 属性,并且其值为 123,准备阶段中便可以依据它设置初始值为 123

六、解析

1. 说明

将常量池内的符号引用替换为直接引用。

2. 解析的时机

《Java 虚拟机规范》并没有规定解析阶段发生的具体时间。因此,JVM 既可以在加载时就解析,也可以待被引用需要被使用时才解析。

3. 符号引用与直接引用

具体请看:

JVM 引用

  • 符号引用:一个字符串,用于描述被引用的目标

  • 直接引用:reference,用于定位对象

4. 类或接口的解析

假设当前类为 D,需要解析将符号引用 N 解析为类或接口 C 的直接引用,

  • 加载类:
    • 如果 C 不是数组类型,将 N 代表的全限定名传递给 D 的类加载器加载
    • 如果 C 是数组类型,首先按照第一步加载 C 的元素类型,接着由 JVM 生成 C
  • 权限校验:确保 D 对 C 有的访问权限

5. 字段的解析

  • 解析类:首先解析字段所在的类 C
  • 搜索字段:
    • 在 C 中搜索该字段,若找到,搜索结束
    • 如果 C 实现了接口,按照继承关系递归搜索接口及其父接口,若找到,搜索结束
    • 按照继承关系递归搜索父类,若找到,搜索结束
  • 权限校验:确保具有对字段的访问权限

6. 方法的解析

  • 解析类:首先解析方法所在的类或接口 C
  • 检查:判断 C 是否是类
  • 搜索方法:
    • 在 C 中搜索该方法,若找到,搜索结束
    • 按照继承关系递归搜索父类,若找到,搜索结束
    • 如果 C 实现了接口,则按照继承关系递归搜索接口及其父接口,若找到,搜索结束,抛出 java.lang.AbstractMethodError 错误
  • 权限校验:确保具有对方法的访问权限

因为方法的解析和接口方法的解析分开,所有需要检查方法是否是类方法

7. 接口方法的解析

  • 解析类:首先解析方法所在的类或接口 C
  • 检查:判断 C 是否是接口
  • 搜索方法:
    • 在 C 中搜索该方法,若找到,搜索结束
    • 按照继承关系递归搜索父接口,若找到,搜索结束
    • 如果 C 实现了接口,则按照继承关系递归搜索接口及其父接口,若找到,搜索结束

由于接口中的所有方法默认为 pubulic,因此不需要进行权限校验

七、初始化

1. 说明

执行程序员定义的初始化行为,即执行类构造方法 <clinit>()

2. 初始化的时机

于初始化阶段何时进行,《Java 虚拟机规范》进行了严格的规定,当且仅当 以下六种情况发生并且类型还未初始化时,必须进行初始化:

  • 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时

    能生成这四条指令的 Java 代码有:

    • 使用 new 关键字实例化对象

    • 读取或设置一个类型的静态字段

      被 final 修饰的除外,它将会被编译器优化为常量,直接获取到结果后放入常量池,因此实际上访问的是常量,而不是某个类型的某个静态字段

    • 调用一个类型的静态方法

  • 对类型进行反射调用时

  • 类型的子类初始化时

  • 接口定义了默认方法,其实现类初始化时

  • 虚拟机启动时,指定该类型为要执行的主类

    即执行其 main() 方法

  • 当使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄时

    关于动态语言支持,具体请看:

    解析JDK 7的动态类型语言支持_Java_周志明_InfoQ精选文章

3. 类构造方法

  • 类构造方法由 类变量初始化时赋值 + 静态语句块 构成
  • 与构造方法不同,类构造方法中不需要显示父类的类构造方法,其调用将由 JVM 负责
  • 类构造方法并不是必须的,如果没有类变量初始化赋值,也没有静态语句块,则类构造方法可以没有

参考

  • 深入理解 Java 虚拟机