java的类加载机制
java的类加载机制
概要
Class文件是Java 编译器(如 javac)将 Java 源代码(.java 文件)编译而生成的。编译器将 Java 代码转换成字节码,存储在 Class 文件中,Class 文件需要加载到虚拟机中之后才能运行和使用。
JVM 需要将编译后的字节码文件加载到其内部的运行时数据区域中进行执行。而虚拟机如何加载这些Class文件,Class文件中的信息进入到虚拟机后会发生什么变化。这个过程涉及到了这篇文章将要介绍的 - Java 的类加载机制。
一、类加载的时机
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。
如下图:
系统加载 Class 文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。
其中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)
- 解析阶段并不是每次都必须进行,只有在需要解析符号引用时才会发生。
- 按部就班地开始,而不是进行或者执行,是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。
二、类加载过程
接下来详细介绍Java虚拟机中类加载的全过程,即加载、验证、准备、解析和初始化这五个阶段所执行的具体动作。
1. 加载 (Loading)
“加载”阶段是整个“类加载”过程中的一个阶段。在加载阶段,Java虚拟机需要完成以下三件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
说明:
1)JVM 在该阶段的目的是将字节码从不同的数据源(可能是 class 文件、也可能是 jar 包,甚至网络)转化为二进制字节流加载到内存中,并生成一个代表该类的 java.lang.Class 对象。
2)加载这一步主要是通过类加载器完成的。类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载由双亲委派模型决定(不过,我们也能打破由双亲委派模型)。
2. 链接(Linking)
1)验证(Verification):检查字节码的正确性。
验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段主要由四个检验阶段组成:
- 文件格式验证(Class 文件格式检查)
- 元数据验证(字节码语义检查)
- 字节码验证(程序语义检查)
- 符号引用验证(类的正确性检查)
如下图:
2)准备(Preparation):分配静态字段的内存并初始化
JVM 会在该阶段对类变量(也称为静态变量,static 关键字修饰的)分配内存并初始化,对应数据类型的默认初始值,如 0、0L、null、false 等。
比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)
特殊情况:比如给 value 变量加上了 final 关键字 public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111。
举个例子:
1 public String chenmo = "沉默"; 2 public static String wanger = "王二"; 3 public static final String cmower = "沉默王二";
分析:
chenmo 不会被分配内存,而 wanger 会;但 wanger 的初始值不是“王二”而是 null。
需要注意的是,static final 修饰的变量被称作为常量,和类变量不同(这些在讲 static 关键字就讲过了)。常量一旦赋值就不会改变了,所以 cmower 在准备阶段的值为“沉默王二”而不是 null。
3)解析(Resolution): 将符号引用转换为直接引用。
解析阶段是 Java 虚拟机将常量池内的符号引用 替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。
举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。
3. 初始化(Initialization)
该阶段是类加载过程的最后一个步骤。之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的 java 程序代码,将主导权移交给应用程序。
在准备阶段,类变量已经被赋过默认初始值,而在初始化阶段,类变量将被赋值为代码期望赋的值。换句话说,初始化阶段是执行类构造器方法(javap 中看到的 <clinit>() 方法)的过程。
为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:
- 声明类变量是指定初始值
- 使用静态代码块为类变量指定初始值
三、类加载器
Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名获取描述该类的二进制字节流”这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器” (Class Loader)。
类加载器虽然只用于实现类的加载动作,但它在 Java 程序中起到的作用却远超类加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间。也就是说,如果两个类的加载器不同,即使两个类来源于同一个字节码文件,那这两个类就必定不相等(比如两个类的 Class 对象不 equals)。
1. JVM 中内置了三个重要的 ClassLoader
1) 启动类加载器(Bootstrap ClassLoader)
最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库。除了 Bootstrap ClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader 抽象类。
启动类加载器在类加载过程中起着重要的安全保障作用。它确保只有JDK核心类被加载,这样可以防止恶意代码影响Java虚拟机的基本功能。
2) 扩展类加载器(Extension ClassLoader)
加载 Java 扩展库。
3) 应用程序类加载器(Application ClassLoader)
面向用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。加载系统类路径 java.class.path 上指定的类库,通常是应用类和第三方库。
类加载器的层次,如下图:
说明:如果某些类在层次结构上依赖于核心类,启动类加载器必须首先完成加载。
2. 自定义加载器
一般情况下,以上3种加载器能满足我们日常的开发工作,不满足时,我们还可以自定义加载器。
比如应用是通过网络来传输 Java 类的字节码,为保证安全性,这些字节码经过了加密处理,这时以上3中系统类加载器就无法对其进行加载,这样则需要自定义类加载器来实现。
1) 自定义加载器实现步骤
继承 java.lang.ClassLoader 类,重写findClass()方法。
如果没有太复杂的需求,可以直接继承URLClassLoader类,重写loadClass方法,具体可参考AppClassLoader和ExtClassLoader。
说明:
Java 允许用户创建自己的类加载器,通过继承 java.lang.ClassLoader 类的方式实现。这在需要动态加载资源、实现模块化框架或者特殊的类加载策略时非常有用。
四、双亲委派模型(Parents Delegation Model)
如下图:
上图展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。
在面向对象编程中,有一条非常经典的设计原则:组合优于继承,多用组合少用继承。
ClassLoader 类部分代码如下:
1 public abstract class ClassLoader { 2 ... 3 4 // 组合 5 private final ClassLoader parent; 6 7 protected ClassLoader(ClassLoader parent) { 8 this(checkCreateClassLoader(), parent); 9 } 10 ... 11 }
双亲委派模型的工作过程是:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 java.lang.ClassLoader 的 loadClass() 中,相关代码如下所示:
1 protected Class<?> loadClass(String name, boolean resolve) 2 throws ClassNotFoundException 3 { 4 // 使用类加载锁,确保线程安全 5 synchronized (getClassLoadingLock(name)) { 6 // 首先,检查请求的类是否已经被加载过了 7 Class<?> c = findLoadedClass(name); 8 if (c == null) { 9 try { 10 // 如果存在父类加载器,就委派给父类加载器加载 11 if (parent != null) { 12 c = parent.loadClass(name, false); 13 } else { 14 // 如果没有父类加载器,尝试查找启动类加载器中的类 15 c = findBootstrapClassOrNull(name); 16 } 17 } catch (ClassNotFoundException e) { 18 // 如果父类加载器抛出 ClassNotFoundException,说明父类加载器无法完成加载请求 19 // 记录异常信息,便于后续处理 20 } 21 22 // 如果父类加载器无法加载该类 23 if (c == null) { 24 // 调用本类的 findClass 方法尝试加载 25 c = findClass(name); 26 } 27 } 28 29 // 如果 resolve 参数为 true,进行类的解析 30 if (resolve) { 31 resolveClass(c); 32 } 33 34 // 返回加载的类对象 35 return c; 36 } 37 }
使用双亲委派模型有一个很明显的好处,那就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系,这对于保证 Java 程序的稳定运作很重要。
上文中曾提到,如果两个类的加载器不同,即使两个类来源于同一个字节码文件,那这两个类就必定不相等——双亲委派模型能够保证同一个类最终会被特定的类加载器加载。
五、破坏双亲委派模型
双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。在Java的世界中大部分的类加载器都遵循双亲委派模型,但也有例外的情况。比如:SPI 场景。
1. SPI(服务提供者接口)
SPI允许开发者为某个接口提供不同的实现。这些实现通常由第三方提供,例如数据库驱动、消息中间件等
2. 问题
主要是加载顺序和优先级的问题。
1)SPI的核心接口可能由启动类加载器加载,但实现类则通常位于应用程序的classpath下。
2)如果只依赖双亲委派模型,启动类加载器会首先尝试加载实现类,但它无法找到在classpath中的实现类,从而导致加载失败。
3. 解决方案
为了让应用程序能加载这些实现类,通常需要自定义类加载器来打破双亲委派模型或在类加载逻辑中加入一些策略来动态加载和管理这些实现类,以便能直接从应用的classpath中找到并加载这些实现类。
4. 打破双亲委派模型的方法
ClassLoader 类有两个关键的方法:
1 package java.lang; 2 3 public abstract class ClassLoader { 4 5 private final ClassLoader parent; 6 7 // 加载指定二进制名称的类,实现了双亲委派机制 。name 为类的二进制名称,resolve 如果为 true,在加载时调用 resolveClass(Class<?> c) 方法解析该类。 8 protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException 9 { 10 //... 11 12 } 13 14 15 // 根据类的二进制名称来查找类 16 protected Class<?> findClass(String name) throws ClassNotFoundException { 17 throw new ClassNotFoundException(name); 18 } 19 20 }
自定义类加载器时,如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。
这里“被破坏”这个词是用于形容不符合双亲委派模型原则的行为,但这里“被破坏”并不一定是带有贬义的,只要有明确的目的和充分的理由,突破旧有原则无疑是一种创新。
参考链接:
《深入理解Java虚拟机》
https://javabetter.cn/jvm/class-load.html
https://javaguide.cn/java/jvm/class-loading-process.html