深入理解Java类加载

时间:2021-08-05 03:00:26

本文目的:

  1. 深入理解Java类加载机制;
  2. 理解各个类加载器特别是线程上下文加载器;

Java虚拟机类加载机制

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

在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的

类加载的过程

类的个生命周期如下图:

深入理解Java类加载

为支持运行时绑定,解析过程在某些情况下可在初始化之后再开始,除解析过程外的其他加载过程必须按照如图顺序开始。

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

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

  1. 文件格式验证:如是否以魔数 0xCAFEBABE 开头、主、次版本号是否在当前虚拟机处理范围之内、常量合理性验证等。

    此阶段保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java类型信息的要求。
  2. 元数据验证:是否存在父类,父类的继承链是否正确,抽象类是否实现了其父类或接口之中要求实现的所有方法,字段、方法是否与父类产生矛盾等。

    第二阶段,保证不存在不符合 Java 语言规范的元数据信息。
  3. 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。例如保证跳转指令不会跳转到方法体以外的字节码指令上。
  4. 符号引用验证:在解析阶段中发生,保证可以将符号引用转化为直接引用。

可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备

类变量分配内存并设置类变量初始值,这些变量所使用的内存都将在方法区中进行分配。

解析

虚拟机将常量池内的符号引用替换为直接引用的过程。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。

初始化

到初始化阶段,才真正开始执行类中定义的 Java 程序代码,此阶段是执行 <clinit>() 方法的过程。

<clinit>() 方法是由编译器按语句在源文件中出现的顺序,依次自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的。(不包括构造器中的语句。构造器是初始化对象的,类加载完成后,创建对象时候将调用的 <init>() 方法来初始化对象)

静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如下程序:

public class Test {
static {
// 给变量赋值可以正常编译通过
i = 0;
// 这句编译器会提示"非法向前引用"
System.out.println(i);
} static int i = 1;
}

<clinit>() 不需要显式调用父类(接口除外,接口不需要调用父接口的初始化方法,只有使用到父接口中的静态变量时才需要调用)的初始化方法 <clinit>(),虚拟机会保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

<clinit>() 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法。

虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行 <clinit>() 方法完毕。

类加载的时机

对于初始化阶段,虚拟机规范规定了有且只有 5 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  1. 遇到new、getstatic 和 putstatic 或 invokestatic 这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。对应场景是:使用 new 实例化对象、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)、以及调用一个类的静态方法。
  2. 对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化类的父类还没有进行过初始化,则需要先触发其父类的初始化。(而一个接口在初始化时,并不要求其父接口全部都完成了初始化)
  4. 虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),

    虚拟机会先初始化这个主类。
  1. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

第5种情况,我暂时看不懂。

以上这 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用,例如:

  1. 通过子类引用父类的静态字段,不会导致子类初始化。
  2. 通过数组定义来引用类,不会触发此类的初始化。MyClass[] cs = new MyClass[10];
  3. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

类加载器

把实现类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作的代码模块称为“类加载器”。

将 class 文件二进制数据放入方法区内,然后在堆内(heap)创建一个 java.lang.Class 对象,Class 对象封装了类在方法区内的数据结构,并且向开发者提供了访问方法区内的数据结构的接口。

目前类加载器却在类层次划分、OSGi、热部署、代码加密等领域非常重要,我们运行任何一个 Java 程序都会涉及到类加载器。

类的唯一性和类加载器

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

即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类也不相等。

这里所指的“相等”,包括代表类的 Class 对象的 equals() 方法、 isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。

双亲委派模型

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

深入理解Java类加载

这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。

Bootstrap 类加载器是用 C++ 实现的,是虚拟机自身的一部分,如果获取它的对象,将会返回 null;扩展类加载器和应用类加载器是独立于虚拟机外部,为 Java 语言实现的,均继承自抽象类 java.lang.ClassLoader ,开发者可直接使用这两个类加载器。

Application 类加载器对象可以由 ClassLoader.getSystemClassLoader() 方法的返回,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

双亲委派模型对于保证 Java 程序的稳定运作很重要,例如类 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。

双亲委派模型的加载类逻辑可参考如下代码:

    // 代码摘自《深入理解Java虚拟机》
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 = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载的时候
// 再调用本身的findClass方法来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
破坏双亲委派模型

双亲委派模型主要出现过 3 较大规模的“被破坏”情况。

  1. 双亲委派模型在引入之前已经存在破坏它的代码存在了。

    双亲委派模型在 JDK 1.2 之后才被引入,而类加载器和抽象类 java.lang.ClassLoader 则在 JDK 1.0 时代就已经存在,JDK 1.2之后,其添加了一个新的 protected 方法 findClass(),在此之前,用户去继承 ClassLoader 类的唯一目的就是为了重写 loadClass() 方法,而双亲委派的具体逻辑就实现在这个方法之中,JDK 1.2 之后已不提倡用户再去覆盖 loadClass() 方法,而应当把自己的类加载逻辑写到 findClass() 方法中,这样就可以保证新写出来的类加载器是符合双亲委派规则的。

  2. 基础类无法调用类加载器加载用户提供的代码。

    双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),但如果基础类又要调用用户的代码,例如 JNDI 服务,JNDI 现在已经是 Java 的标准服务,它的代码由启动类加载器去加载(在 JDK 1.3 时放进去的 rt.jar ),但 JNDI 的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的 ClassPath 下的 JNDI 接口提供者(SPI,Service Provider Interface,例如 JDBC 驱动就是由 MySQL 等接口提供者提供的)的代码,但启动类加载器只能加载基础类,无法加载用户类。

为此 Java 引入了线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread.setContextClassLoaser() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

如此,JNDI 服务使用这个线程上下文类加载器去加载所需要的 SPI 代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java 中所有涉及 SPI 的加载动作基本上都采用这种方式,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。

  1. 用户对程序动态性的追求。

    代码热替换(HotSwap)、模块热部署(Hot Deployment)等,OSGi 实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。

在 OSGi 环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGi 将按照下面的顺序进行类搜索:

1)将以 java.* 开头的类委派给父类加载器加载。

2)否则,将委派列表名单内的类委派给父类加载器加载。

3)否则,将 Import 列表中的类委派给 Export 这个类的 Bundle 的类加载器加载。

4)否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载。

5)否则,查找类是否在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器加载。

6)否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载。

7)否则,类查找失败。

上面的查找顺序中只有开头两点仍然符合双亲委派规则,其余的类查找都是在平级的类加载器中进行的。OSGi 的 Bundle 类加载器之间只有规则,没有固定的委派关系。

自定义类加载器

Java 默认 ClassLoader,只加载指定目录下的 class,如果需要动态加载类到内存,例如要从远程网络下来类的二进制,然后调用这个类中的方法实现我的业务逻辑,如此,就需要自定义 ClassLoader。

自定义类加载器分为两步:

  1. 继承 java.lang.ClassLoader
  2. 重写父类的 findClass() 方法

针对第 1 步,为什么要继承 ClassLoader 这个抽象类,而不继承 AppClassLoader 呢?

因为它和 ExtClassLoader 都是 Launcher 的静态内部类,其访问权限是缺省的包访问权限。

static class AppClassLoader extends URLClassLoader{...}

第 2 步,JDK 的 loadCalss() 方法在所有父类加载器无法加载的时候,会调用本身的 findClass() 方法来进行类加载,因此我们只需重写 findClass() 方法找到类的二进制数据即可。

下面我自定义了一个简单的类加载器,并加载一个简单的类。

首先是需要被加载的简单类:

// 存放于D盘根目录
public class Test { public static void main(String[] args) {
System.out.println("Test类已成功加载运行!");
ClassLoader classLoader = Test.class.getClassLoader();
System.out.println("加载我的classLoader:" + classLoader);
System.out.println("classLoader.parent:" + classLoader.getParent());
}
}

并使用 javac -encoding utf8 Test.java 编译成 Test.class 文件。

类加载器代码如下:

import java.io.*;

public class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 加载D盘根目录下指定类名的class
String clzDir = "D:\\" + File.separatorChar
+ name.replace('.', File.separatorChar) + ".class";
byte[] classData = getClassData(clzDir); if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
} private byte[] getClassData(String path) {
try (InputStream ins = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream()
) { int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead = 0;
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}

使用类加载器加载调用 Test 类:

public class MyClassLoaderTest {
public static void main(String[] args) throws Exception {
// 指定类加载器加载调用
MyClassLoader classLoader = new MyClassLoader();
classLoader.loadClass("Test").getMethod("test").invoke(null);
}
}

输出信息:

Test.test()已成功加载运行!
加载我的classLoader:class MyClassLoader
classLoader.parent:class sun.misc.Launcher$AppClassLoader

线程上下文类加载器

如上所说,为解决基础类无法调用类加载器加载用户提供代码的问题,Java 引入了线程上下文类加载器(Thread Context ClassLoader)。这个类加载器默认就是 Application 类加载器,并且可以通过 java.lang.Thread.setContextClassLoaser() 方法进行设置。

// Now create the class loader to use to launch the application
try {
loader = AppClassLoader.getAppClassLoader(extcl);
} catch (IOException e) {
throw new InternalError(
"Could not create application class loader" );
} // Also set the context class loader for the primordial thread.
Thread.currentThread().setContextClassLoader(loader);

那么问题来了,我们使用 ClassLoader.getSystemClassLoader() 方法也可以获取到 Application 类加载器,使用它就可以加载用户类了呀,为什么还需要线程上下文类加载器?

其实直接使用 getSystemClassLoader() 方法获取 AppClassLoader 加载类也可以满足一些情况,但有时候我们需要使用自定义类加载器去加载某个位置的类时,例如Tomcat 使用的线程上下文类加载器并非 AppClassLoader ,而是 Tomcat 自定义类加载器。

以 Tomcat 为例,其每个 Web 应用都有一个对应的类加载器实例,该类加载器使用代理模式,首先尝试去加载某个类,如果找不到再代理给父类加载器这与一般类加载器的顺序是相反的。

这是 Java Servlet 规范中的推荐做法,其目的是使得 Web 应用自己的类的优先级高于 Web 容器提供的类。

更多关于 Tomcat 类加载器的知识,这里暂时先不讲了。

new一个对象过程中发生了什么?

  1. 确认类元信息是否存在。当 JVM 接收到 new 指令时,首先在 metaspace 内检查需要创建的类元信息是否存在。 若不存在,那么在双亲委派模式下,使用当前类加载器以 ClassLoader + 包名+类名为 Key 进行查找对应的 class 文件。 如果没有找到文件,则抛出 ClassNotFoundException 异常 , 如果找到,则进行类加载(加载 - 验证 - 准备 - 解析 - 初始化),并生成对应的 Class 类对象。
  2. 分配对象内存。 首先计算对象占用空间大小,如果实例成员变量是引用变量,仅分配引用变量空间即可,即 4 个字节大小,接着在堆中划分—块内存给新对象。 在分配内存空间时,需要进行同步操作,比如采用 CAS (Compare And Swap) 失败重试、 区域加锁等方式保证分配操作的原子性。
  3. 设定默认值。 成员变量值都需要设定为默认值, 即各种不同形式的零值。
  4. 设置对象头。设置新对象的哈希码、 GC 信息、锁信息、对象所属的类元信息等。这个过程的具体设置方式取决于 JVM 实现。
  5. 执行 init 方法。 初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

最后,推荐与感谢:

深入理解Java虚拟机(第2版)

码出高效:Java开发手册

java new一个对象的过程中发生了什么 - 天风的文章 - 知乎

深入探讨类加载器

Class.forName()用法详解

真正理解线程上下文类加载器(多案例分析)