57、类加载
在上上节中我们讲到了 Java 代码编译执行的整个过程,其中包括:前端编译、类加载、解释执行、JIT 编译、AOT 编译,在上一节中我们详细介绍了 JIT 编译
本节我们详细介绍类加载,主要包括:类加载过程、类加载机制
- 类加载过程包括:验证、准备、解析、初始化
- 类加载机制包括:类加载器、双亲委派机制(类加载机制是 Java 类加载比较特殊的地方,也是面试中经常被问到的知识点)
1、类加载过程
类加载指的是:虚拟机将类的二进制字节码加载到内存中,以便创建类的对象或者执行类上的方法
类加载的过程可以细分为:验证、准备、解析、初始化这 4 个阶段,接下来我们就依次简单介绍一下这 4 个阶段
1.1、验证
这一阶段是验证所加载的类字节码格式符合 JVM 规范
- 尽管经过 javac 编译工具编译得到的 .class 文件,在格式上基本没有什么问题,但这只是类加载的其中一个数据来源
- 基于 Java 灵活的类加载机制,虚拟机还可以加载:来自网络的类字节码、通过字节码生成工具动态生成的类字节码
因为类字节码的来源不可控,被加载的类字节码可能被恶意篡改,所以为了安全起见,虚拟机在加载类字节码时,需要做合法性的校验
1.2、准备
在准备阶段,虚拟机为类的静态变量分配内存,并将其初始化为默认值
- 对于 static final 修饰的静态常量,虚拟机直接将其初始化为指定值,比如:private static final int a = 25,虚拟机在准备阶段将变量 a 初始化为 25
- 对于只有 static 修饰的静态变量,虚拟机将其初始化为所属类型的默认值而非指定值
比如:private static int a = 25,虚拟机会在准备阶段将变量 a 初始化为 int 类型的默认值 0,指定值 25 会在初始化阶段赋值给变量 a
需要注意的是,静态变量归属于类,非静态变量归属于对象
因此在类的加载过程中,虚拟机只处理类的静态变量,类的非静态变量在对象的创建过程中处理,并且存储在对象所占用的内存空间中
1.3、解析
解析类似 C++ 中的链接(不熟悉 C++ 语言的可以忽略这句话),把类字节码的常量池中的符号引用(也被称为间接引用)转换为直接引用
常量池中存储了所涉及的:类、方法、变量等的描述符,这一步就是将描述符转化为可以直接访问的内存存储地址(也就是刚刚提到的直接引用)
1.4、初始化
在初始化阶段,虚拟机会执行静态变量的初始化代码,包括初始化语句(private static int a = 25)、静态代码块(static { a = 13; })
2、类加载机制
2.1、介绍
简单了解了类加载过程之后,我们再来看下本节的重点:类加载机制
类加载由类加载器来完成
- 通过全类名获取定义此类的二进制字节流
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表该类的 Class 对象,用于在运行时获取类的信息,这个 Class 对象包含了类的方法、字段、构造函数等信息
虚拟机定义了几种不同类型的类加载器:启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)、应用程序类加载器(Application ClassLoader)
- 启动类加载器:负责加载 $JAVA_HOME/jre/lib/rt.jar 包中的类
- 扩展类加载器:负责加载 $JAVA_HOME/jre/lib/ext 目录下的 jar 包中的类
- 应用程序类加载器:也叫做系统类加载器,负责加载其它 classpath 所指定路径(除去启动类加载器和扩展类加载器所负责的路径)下的类
尽管每个加载器所负责的路径是明确的
但是当虚拟机无法根据类的全限定名(包含 package 信息的类名,比如 java.lang.StringUtils),得知类处于哪个路径下,归属于哪个类加载器负责
虚拟机会通过双亲委派模型来解决
为了明确某个类应该由哪个加载器加载,虚拟机需要通过在各个类加载器所负责的路径下查找这个类,然而不同的路径下可能存在相同的类
比如假设在 $JAVA_HOME/jre/lib/ 下和 ./ 下都存在 java.lang.StringUtils 这个类,那么在有重复类的情况下,虚拟机还需要有一定的机制来确定到底加载其中的哪个类
针对以上类的查找和去重,虚拟机设计了双亲委派机制,在双亲委派机制中,虚拟机首先定义了类加载器之间的父子关系,如下图所示
在下图中,我们还看到一种新的类加载器:自定义类加载器(我们稍后详细讲解)
2.2、验证
我们通过代码来验证一下上图中各个类加载器之间的父子关系,代码如下所示,下述代码打印的结果已经标注在了注释中
- ClassLoaderB 的父类加载器为 ClassLoaderA
- ClassLoaderA 的父类加载器为 AppClassLoader
- AppClassLoader 的父类加载器为 ExtClassLoader
- ExtClassLoader 的父类加载器为 null(实际为 BootstrapClassLoader,这是因为 BootstrapClassLoader 由 C++ 代码实现,因此无法在打印结果中显示)
public class Demo { // 默认父类加载器为 AppClassLoader public static class ClassLoaderA extends ClassLoader { } // 通过构造函数指定父类加载器 public static class ClassLoaderB extends ClassLoader { public ClassLoaderB(ClassLoader parent) { super(parent); } } public static void main(String[] args) { ClassLoaderB loader = new ClassLoaderB(new ClassLoaderA()); // Demo$ClassLoaderB@70dea4e System.out.println(loader); // Demo$ClassLoaderA@5c647e05 System.out.println(loader.getParent()); // sun.misc.Launcher$AppClassLoader@2a139a55 System.out.println(loader.getParent().getParent()); // sun.misc.Launcher$ExtClassLoader@33909752 System.out.println(loader.getParent().getParent().getParent()); // null System.out.println(loader.getParent().getParent().getParent().getParent()); } }
在某个类加载器接收到某个类的加载请求时(在代码中使用 new 或者反射创建类的对象时,默认应用程序类加载器 AppClassLoader 加载对应的类)
- 如果这个类加载器之前没有加载过这个类,那么它便委托父类加载器加载这个类
- 如果父类加载器之前也没有加载过这个类,那么父类加载器又会委托父类的父类加载器加载这个类,以此类推,继续往上委托
- 如果在往上委托的过程中,某个类加载器已经加载了这个类,那么类加载过程结束
- 如果在往上委托的过程中,直到到达最顶层父类加载器,都没有找到已经加载这个类的加载器
那么虚拟机会再从上往下,请求各个加载器在自己所负责的路径下,查找并加载这个类 - 如果某个加载器所负责的类路径中存在这个类,那么这个类就由这个加载器来负责加载
2.3、类加载过程图示
对于上述加载过程,我们举个例子解释一下,如下图所示
以上双亲委派类加载机制可以有效的防止对核心类的恶意篡改
比如:我们在自己的路径下定义一个新的 java.lang.String 类,请求应用程序类加载器来加载,意图覆盖核心类库中的 String 类
但是基于双亲委派机制,应用程序类加载器会委托父类加载器来加载 java.lang.String 类,因此最终仍然会由启动类加载器加载核心类库中的 String 类
2.4、类加载过程源码
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 首先, 检查该类是否已经加载过 Class c = findLoadedClass(name); if (c == null) { // 如果 c 为 null, 则说明该类没有被加载过 long t0 = System.nanoTime(); try { if (parent != null) { // 当父类的加载器不为空, 则通过父类的 loadClass 来加载该类 c = parent.loadClass(name, false); } else { // 当父类的加载器为空, 则调用启动类加载器来加载该类 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 非空父类的类加载器无法找到相应的类, 则抛出异常 } if (c == null) { // 当父类加载器无法加载时, 则调用 findClass 方法来加载该类 // 用户可通过覆写该方法, 来自定义类加载器 long t1 = System.nanoTime(); c = findClass(name); // 用于统计类加载器相关的信息 sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { // 对类进行 link 操作 resolveClass(c); } return c; } }
3、自定义类加载器
上一小节中,我们提到自定义类加载器,接下来我们就来看下自定义类加载器
可以对 Java 类的字节码(.class 文件)进行加密,加载时再利用自定义的类加载器对其解密
3.1、介绍
前面讲到,在代码中使用 new 或者反射创建类的对象时,默认应用程序类加载器 AppClassLoader 来加载对应的类
但是如果某些特殊的类在加载的过程中需要特殊处理,我们是无法将特殊处理逻辑插入到应用程序类加载器中的
这个时候我们就需要自定义类加载器,将特殊处理逻辑插入自定义类加载器中,通过调用自定义类加载器上的 loadClass() 函数来加载这个类,达到对这个类特殊加载的目的
自定义类加载器非常简单,我们只需要定义一个继承自 ClassLoader 类的子类,并且重写其中的 findClass() 函数即可,findClass() 函数在 ClassLoader 类中的定义如下所示
findClass() 函数默认抛出 ClassNotFoundException 异常,相当于抽象函数,需要在子类中重新实现才能使用
实际上 ClassLoader 类是一个模板方法模式类,其中的 loadClass() 函数是模板方法,里面包含类加载的整个逻辑,比如双亲委派机制的实现逻辑
findClass() 函数为模板方法模式中的抽象方法,被 loadClass() 函数使用,用来根据类名查找类
protected Class<?> findClass(String name) throws ClassNotFoundException { throw new ClassNotFoundException(name); }
3.2、示例 1
默认情况下,自定义类加载器的父类为应用程序类加载器,当然我们也可以在构造函数中指定自定义类加载器的父类加载器,如下示例代码所示
// 默认父类加载器为 AppClassLoader public static class ClassLoaderA extends ClassLoader { @Override protected Class<?> findClass(String name) throws ClassNotFoundException { // ... } } // 通过构造函数指定父类加载器 public static class ClassLoaderB extends ClassLoader { public ClassLoaderB(ClassLoader parent) { super(parent); } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { // ... } }
3.3、示例 2
我们通过一个例子来看下,具体如何自定义类加载器,示例代码如下所示
自定义类加载器中的 findClass() 函数从文件系统的绝对路径下读取类的二进制字节码
通过调用 ClassLoader 的 defineClass() 函数将二进制的字节码转化成 Class 对象,以此来实现一个加载特定路径下的类的加载器
public class FileSystemClassLoader extends ClassLoader { private String rootDir; public FileSystemClassLoader(String rootDir) { this.rootDir = rootDir; } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { String path = rootDir + File.separatorChar + name.replace('.', File.separatorChar) + ".class"; byte[] bytecode = null; try (InputStream input = new FileInputStream(path)) { ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); byte[] buffer = new byte[4096]; int readSize = 0; while ((readSize = input.read(buffer)) != -1) { byteStream.write(buffer, 0, readSize); } bytecode = byteStream.toByteArray(); } catch (IOException e) { e.printStackTrace(); } if (bytecode == null) { throw new ClassNotFoundException("class name: " + name); } else { return defineClass(name, bytecode, 0, bytecode.length); } } }
当我们使用自定义类加载器 FileSystemClassLoader 加载类时
如果在其它父类加载器所负责的路径下不存在这个要加载的类,那么这个类就由自定义类加载器 FlieSystemClassLoader 来加载,示例代码如下所示
public class Demo { public static void main(String[] args) throws ClassNotFoundException { ClassLoader classLoader = new FileSystemClassLoader("/Users/wangzheng"); Class<?> clazz = classLoader.loadClass("com.xzg.Hello"); System.out.println(clazz.getClassLoader()); // 打印 FileSystemClassLoader 对象信息 } }
4、课后思考题
1、Class.forName() 和 ClassLoader.loadClass() 两种类加载方式的区别在哪里?
2、请编程验证:是否可以定义一个父类为 "扩展类加载器或启动类加载器" 的自定义类加载器?
3、是否可以禁止双亲委派机制,让某个类直接由自定义类加载器加载?
可以,重写模板方法 loadClass() 函数就行
4、当一个 Tomcat 部署多个项目时,如果多个项目包含全限定名相同的类该怎么办?
Tomcat 使用不同的类加载器加载不同的项目,并且打破了双亲委派机制,这样就可以实现为不同的项目加载全限定名相同的不同类
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17497733.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步