JVM 类加载

引言

本文着重介绍 JVM 中类加载相关的内容,更多关于 JVM 的文章均收录于<JVM系列文章>

类加载机制

类装载流程

Class 通常以文件的形式存在,当然也可以是任何二进制流,只有被 Java 虚拟机装载的 Class 才能被程序使用。系统装载 Class 可以分为加载,连接和初始化这 3 个步骤。
load-class

加载

Class 只有在必须要使用的时候才会被装载, Java 虚拟机不会无条件地装载 Class 类型。Java 虚拟机规定, 一个类或接口在初次使用前, 必须要进行初始化。这里指的“使用”, 是指主动使用, 主动使用只有下列几种情况:

  • 当创建一个类的实例时, 比如使用 new 关键字, 或者通过反射、克隆、反序列化。
  • 当调用类的静态方法时, 即当使用了字节码 invokestatic 指令。
  • 当使用类或接口的静态字段时(final 常量除外), 比如, 使用 getstatic 或者 putstatic 指令。
  • 当使用 java.lang.reflect 包中的方法反射类的方法时。
  • 当初始化子类时, 要求先初始化父类。
  • 作为启动虚拟机, 含有 main 方法的那个类。

加载类处于类装载的第一个阶段。在加载类时, Java 虚拟机必须完成以下工作:

  • 通过类的全名, 获取类的二进制数据流。
  • 解析类的二进制数据流为方法区内的数据结构。
  • 创建 java.lang.Class 类的实例, 表示该类型。

对于类的二进制数据流, 虛拟机可以通过多种途径产生或获得。一般是通过文件系统读入一个class后缀的文件, 或者也可能读入 JAR、ZIP 等归档数据包, 提取类文件。除了这些形式外, 任何形式都是可以的。比如, 事先将类的二进制数据存放在数据库中, 或者通过网络进行加载,甚至使用上述字节码增强工具,在运行时生成。在 Java 虚拟机中,完成类加载工作的组件就是 ClassLoader,我们稍后就介绍它。

连接

当类加载到系统后, 就开始连接操作, 验证是连接操作的第一步。它的目的是保证加载的字节码是合法、合理并符合规范的。验证的步骤比较复杂, 实际要验证的项目也很繁多, 大体上 Java 虚拟机需要做以下检查。
check-class

  1. 必须判断类的二进制数据是否是符合格式要求和规范的。比如, 是否以魔数OxCAFEBABE 开头, 主版本和小版本号是否在当前 Java 虚拟机的支持范围内, 数据中每一个项是否都拥有正确的长度等等。
  2. Java 虛拟机会进行字节码的语义检查, 比如是否所有的类都有父类的存在(在 Java 里, 除了 Object 外, 其他类都应该有父类), 是否一些被定义为 final 的方法或者类被重载或继承了, 非抽象类是否实现了所有抽象方法或者接口方法, 是否存在不兼容的方法(比如方法的签名除了返回值不同, 其他都一样, 这种方法会让虛拟机无从下手调度), 但凡在语义上不符合规范的, 虚拟机也不会给予验证通过。
  3. Java 虛拟机还会进行字节码验证, 字节码验证也是验证过程中最为复杂的一个过程。它试图通过对字节码流的分析, 判断字节码是否可以被正确地执行。比如, 在字节码的执行过程中, 是否会跳转到一条不存在的指令, 函数的调用是否传递了正确类型的参数, 变量的赋值是不是给了正确的数据类型等。在前面介绍的栈映射帧 (StackMapTable) 就是在这个阶段, 用于检测在特定的字节码处, 其局部变量表和操作数栈是否有着正确的数据类型。但是, 100% 准确地判断一段字节码是否可以被安全执行是无法实现的, 因此, 该过程只是尽可能地检查出可以预知的明显的问题。如果在这个阶段无法通过检查, 虚拟机也不会正确装载这个类。但是, 如果通过了这个阶段的检查, 也不能说明这个类是完全没有问题的。
  4. 校验器还将进行符号引用的验证。Class 文件在其常量池会通过字符串记录自己将要使用的其他类或者方法。因此, 在验证阶段, 虚拟机就会检查这些类或者方法确实是存在的, 并且当前类有权限访问这些数据, 如果一个需要使用类无法在系统中找到, 则会抛出 NoClassDefFoundError, 如果一个方法无法被找到, 则会抛出 NoSuchMethodError。

当一个类验证通过时, 虚拟机就会进入准备阶段。在这个阶段, 虚拟机就会为这个类分配相应的内存空间, 并设置初始值。Java 虚拟机为各类型变量默认的初始值如下所示。
field-default-value
如果类存在常量字段, 那么常量字段也会在准备阶段被附上正确的值, 这个赋值属于 Java 虛拟机的行为, 属于变量的初始化。事实上, 在准备阶段, 不会有任何 Java 代码被执行,值得一提的当且仅当一个字段被标记为 static final 时,才会在准备阶段直接赋值,而且其他字段都会被 JVM 先赋上默认值。

在准备阶段完成后, 就进入了解析阶段。解析阶段的工作就是将类、接口、字段和方法的符号引用转为直接引用。符号引用就是一些字面量的引用, 和虚拟机的内部数据结构和内存布局无关。就比如我们前面的一个方法引用类型的常量池项,就以符号引用的方式引用了其他内容。
test-class-reference
但是在程序执行的时候,我们不可能根据这些字符串的内容去查询对应的对象或者函数指针是什么,这样效率太低了。所以,解析阶段就是将这些字符串描述的引用关系,转变为指针描述的引用关系。

初始化

类的初始化是类装载的最后一个阶段。如果前面的步骤都没有问题, 那么表示类可以顺利装载到系统中。此时, 类才会开始执行 Java 字节码。初始化阶段的重要工作是执行类的初始化方法 <clinit>。方法 <clinit> 是由编译器自动生成的, 它是由类静态成员的赋值语句以及 static 语句块合并产生的。例如,如下代码:

1
2
3
4
5
6
7
8
9
package bbm.jvm;

public class TestStatic {
public static int id = 1;
public static int number;
static {
number = 4;
}
}

在编译后,上述代码会被转化成 <clinit> 函数:
clinit

ClassLoader

ClassLoader 是 Java 的核心组件, 所有的 Class 都是由 ClassLoader 进行加载的, ClassLoader 负责通过各种方式将 Class 信息的二进制数据流读入系统, 然后交给 Java 虚拟机进行连接、初始化等操作。因此, ClassLoader 在整个装载阶段, 只能影响到类的加载, 而无法通过 ClassLoader 去改变类的连接和初始化行为。

从代码层面看, ClassLoader 是一个抽象类, 它提供了一些重要的接口, 用于自定义 Class 的加载流程和加载方式。在 ClassLoader 的结构中, 还有一个重要的字段 parent, 它也是一个 ClassLoader 的实例, 这个字段所表示的 ClassLoader 也称为这个 ClassLoader 的双亲。在类加载的过程中, ClassLoader 可能会将某些请求交予自己的双亲处理。

ClassLoader 分类

在标准的 Java 程序中, Java 虚拟机会创建 3 类 ClassLoader 为整个应用程序服务。它们分别是: BootStrap ClassLoader(启动类加载器)、Extension ClassLoader(扩展类加载器)和 App ClassLoader(应用类加载器, 也称为系统类加载器)。此外, 每一个应用程序还可以拥有自定义的 ClassLoader, 扩展 Java 虚拟机获取 Class 数据的能力。

各个 ClassLoader 的层次和功能如下图所示, 从 ClassLoader 的层次自顶往下为启动类加载器、扩展类加载器、应用类加载器和自定义类加载器。其中, 应用类加载器的双亲为扩展类加载器, 扩展类加载器的双亲为启动类加载器。当系统需要使用一个类时, 在判断类是否已经被加载时, 会先从当前底层类加载器进行判断。当系统需要加载一个类时, 会从顶层类开始加载, 依次向下尝试, 直到成功。
class-loader-order
在这些 ClassLoader 中, 启动类加载器最为特别, 它是完全由 C 代码实现的, 并且在 Java 中没有对象与之对应。系统的核心类就是由启动类加载器进行加载的, 它也是虛拟机的核心组件。扩展类加载器和应用类加载器都有对应的 Java 对象可供使用。当我们访问一个类的 ClassLoader 时,如果得到的是 null,比如 String.class.getClassLoader(), 并不是说该类没有对应的加载器,而是说该类的加载器为启动加载器。

在虚拟机设计中, 使用这种分散的 ClassLoader 去装载类是有好处的, 不同层次的类可以由不同的 ClassLoader 加载, 从而进行划分, 这有助于系统的模块化设计。一般来说, 启动类加载器负责加载系统的核心类, 比如 rt.jar 中的 Java 类。扩展类加载器用于加载 %JAVA_HOME%/lib/ext/*.jar 中的 Java 类。应用类加载器用于加载用户类, 也就是用户程序的类。自定义类加载器用于加载一些特殊途径的类, 一般也是用户程序类。

双亲委派模型

系统中的 ClassLoader 在协同工作时, 默认会使用双亲委托模式。即在类加载的时候, 系统会判断当前类是否己经被加载, 如果已经被加载, 就会直接返回可用的类, 否则就会尝试加载, 在尝试加载时, 会先请求双亲处理, 如果双亲请求失败, 则会自己加载。如下是 ClassLoader 的 loadClass 函数默认实现。当目标类尚未加载时,它会先让 parent 尝试加载,当 parent 加载失败时,自己才会进行加载。

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
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;
}
}

在前文中己经提到, 检查类是否已经加载的委托过程是单向的。这种方式虽然从结构上说比较清晰, 使各个 ClassLoader 的职责非常明确, 但是同时会带来一个问题, 即顶层的 ClassLoader 无法访问底层的 ClassLoader 所加载的类, 如下图所示。
check-loaded-order
通常情况下, 启动类加载器中的类为系统核心类, 包括一些重要的系统接口, 而在应用类加载器中, 为应用类。按照这种模式, 应用类访问系统类自然是没有问题, 但是系统类访问应用类就会出现问题。比如, 在系统类中, 提供了一个接口, 该接口需要在应用中得以实现, 该接口还绑定一个工厂方法, 用于创建该接口的实例, 而接口和工厂方法都在启动类加载器中。这时, 就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题。拥有这种问题的组件有很多, 比如 JDBC、Xml Parser 等。

在 Java 平台中, 把核心类(rt.jar)中提供外部服务, 可由应用层自行实现的接口, 通常可以称为 Service Provider Interface, 即 SPI。对于这类问题,一般的解决方案是,在 META-INF/services 目录下,以目标接口名创建一个文件,文件内记录该接口的所有实现类。
meta-inf-example
这样系统通过读取 jar 包中的 META-INF/services/TargetService 文件就能获取到所有实现类类名。紧接着,在使用这部分系统接口前,我们要将应用 ClassLoader 存入 ThreadLocal 变量中,这个 ThreadLocal 对象就是 java.lang.Thread.contextClassLoader。这样系统类库就能从 ThreadLocal 中获取应用 ClassLoader,并用它加载 META-INF/services/TargetService 中的实现类并使用。
thread-local-class-loader

突破双亲模型

双亲模式的类加载方式是虚拟机默认的行为, 但并非必须这么做, 通过重载 ClassLoader 可以修改该行为。事实上, 不少应用软件和框架都修改了这种行为, 比如 Tomcat 和 OSGi 框架, 都有各自独特的类加载顺序。举了最简单的例子,我们可以通过覆写 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
package bbm.jvm;

public class TestClassLoader extends ClassLoader {
@Override
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) {
c = findClass(name);
if (c == null) {
super.loadClass(name, resolve);
}
}
return c;
}
}

public static void main(String[] args) throws ClassNotFoundException {
System.out.println("Use default class loader");
System.out.println(new ClassLoader() {}.loadClass("java.lang.Object"));
System.out.println("Use my class loader");
TestClassLoader testClassLoader = new TestClassLoader();
testClassLoader.loadClass("java.lang.Object");
}
}

在上例中,我们没有覆写 ClassLoader 的 findClass 函数,就让其执行默认内容,即 throw new ClassNotFoundException(name);。通过上述的返回值,你会发现,如果我们直接使用 ClassLoader 的默认实现去加载 java.lang.Object,那么该类的 findClass 并不会被执行,它会优先使用双亲加载器的 findClass 函数。而当我们覆写 loadClass 函数,并在其中优先调用自己的 findClass 函数,实际上就实现了和双亲模型正好相反的加载流程。

1
2
3
4
5
6
7
8
Use default class loader
class java.lang.Object
Use my class loader
Exception in thread "main" java.lang.ClassNotFoundException: java.lang.Object
at bbm.jvm.TestClassLoader.findClass(TestClassLoader.java:24)
at bbm.jvm.TestClassLoader.loadClass(TestClassLoader.java:13)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
at bbm.jvm.TestClassLoader.main(TestClassLoader.java:32)

热更新

热更新是指在程序的运行过程中, 不停止服务, 只通过替换程序文件来修改程序的行为。热更新的关键需求在于服务不能中断, 修改必须立即表现在正在运行的系统之中。基本上大部分脚本语言都是天生支持热更新的, 比如 PHP, 只要替换了 PHP 源文件, 这种改动就会立即生效, 而无须重启 Web 服务器。但对 Java 来说, 热更新并非天生就支持, 如果一个类已经加载到系统中, 通过修改类文件, 并无法让系统再来加载并重定义这个类。因此, 在 Java 中实现这一功能的一个可行的方法就是灵活运用 ClassLoader。

在 Java 中,不同的 ClassLoader 加载的同名类属于不同的类型,不能相互转化和兼容,即两个不同的 ClassLoader 加载同一个类,在虚拟机内部,会认为这两个类是完全不同的。利用这种特性,每当我们想要进行热更新时,只需要新建一个 ClassLoader 然后用这个新的 ClassLoader 去加载更新后的 Class 文件,然后从这个新 ClassLoader 中创建类的对象,然后运行这个新对象的方法,就达到了热更新的效果。
hot-update
基于上述原理,我们现在实现一个简单地 demo,让它可以热更新如下 Test 类,使其在运行时动态的更新为打印 test1

1
2
3
4
5
6
7
package bbm.jvm;

public class Test {
public void test() {
System.out.println("test");
}
}

为了达到上述目的,我们要实现自己的 ClassLoader,让其加载 Test 类时,从指定的文件中读取字节码。然后在测试函数中,我们不断的创建新的 HotUpdateClassLoader 实例,使其每次实例化时都是基于最新的 Test 文件,然后我们调用新 Test 实例的 test 方法。并观察其是否发生变化。

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
42
43
44
45
46
47
48
49
50
51
52
53
package bbm.jvm;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.WritableByteChannel;

public class HotUpdateClassLoader extends ClassLoader{

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class clazz = this.findLoadedClass(name);
if (clazz == null) {
try(FileInputStream fileInputStream = new FileInputStream("/path/to/Test.class");
FileChannel fileChannel = fileInputStream.getChannel();
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
WritableByteChannel writableByteChannel = Channels.newChannel(byteArrayOutputStream)) {
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
while (true) {
int i = fileChannel.read(byteBuffer);
if (i == 0 || i == -1) {
break;
}
byteBuffer.flip();
writableByteChannel.write(byteBuffer);
byteBuffer.clear();
}
byte[] bytes = byteArrayOutputStream.toByteArray();
clazz = defineClass(name, bytes, 0, bytes.length);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
return clazz;
}

public static void main(String[] args) throws IllegalAccessException, InstantiationException, ClassNotFoundException, InterruptedException {
while (true) {
HotUpdateClassLoader classLoader = new HotUpdateClassLoader();
Class clz = classLoader.loadClass("bbm.jvm.Test");
Test demo = (Test) clz.newInstance();
demo.test();
Thread.sleep(10000);
}
}
}

执行起测试函数后,我们改写 Test 类,让其打印 test1, 然后重新编译 Test 类。

1
2
3
4
5
6
7
package bbm.jvm;

public class Test {
public void test() {
System.out.println("test1");
}
}

重新编译 Test 类之后,测试函数的输入发生了变化,开始打印 test1,也就是说我们的热更新测试成功了。

1
2
3
4
5
test
test
test
test1
test1

参考内容

[1]《实战 Java 虚拟机》
[2]《深入理解 Java 虚拟机》
[3] GC复制算法和标记-压缩算法的疑问
[4] Java中什么样的对象才能作为gc root,gc roots有哪些呢?
[5] concurrent-mark-and-sweep
[6] 关于 -XX:+CMSScavengeBeforeRemark,是否违背cms的设计初衷?
[7] Java Hotspot G1 GC的一些关键技术
[8] Java 垃圾回收权威指北
[9] [HotSpot VM] 请教G1算法的原理
[10] [HotSpot VM] 关于incremental update与SATB的一点理解
[11] Java线程的6种状态及切换
[12] Java 8 动态类型语言Lambda表达式实现原理分析
[13] Java 8 Lambda 揭秘
[14] 字节码增强技术探索
[15] 不可不说的Java“锁”事
[16] 死磕Synchronized底层实现–概论
[17] 死磕Synchronized底层实现–偏向锁
[18] 死磕Synchronized底层实现–轻量级锁
[19] 死磕Synchronized底层实现–重量级锁

贝克街的流浪猫 wechat
您的打赏将鼓励我继续分享!
  • 本文作者: 贝克街的流浪猫
  • 本文链接: https://www.beikejiedeliulangmao.top/java/jvm/load-class/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议,转载请注明出处!
  • 创作声明: 本文基于上述所有参考内容进行创作,其中可能涉及复制、修改或者转换,图片均来自网络,如有侵权请联系我,我会第一时间进行删除。