类加载

2025 年 8 月 18 日 (已编辑)
2745 字
14 分钟

类加载机制

完整的类生命周期如下: 加载 (验证 准备 解析) 初始化 使用 卸载

三合一: 加载 (连接) 初始化 使用 卸载

我们不讲使用和卸载

加载

  1. 通过全限定名(如com.example.Order)获取二进制字节流
  2. 将字节流代表的静态存储结构转化为方法区中的运行时数据结构
  3. 在堆中生成一个代表该类的class对象, 作为程序访问的入口

这里的字节流来源任意, 英雄不问出处, 电缆不问来路

连接

首先是验证阶段, 虽说用于验证是否安全, 但是嗯, 嗯嗯嗯

然后是准备阶段, 为类变量(被static修饰的变量)分配内存并设置初始的零值(是类型的零值, 比如int的0)

这里说的是类变量(static), 不是实例变量; 实例变量是对象创建时随对象在堆里分配的, 跟类的准备阶段没关系

你知道的, 凡事均有例外, 比如static final修饰的编译期常量准备阶段就是目标值

static int a = 5,赋值动作a = 5是一条字节码指令,必须等到初始化阶段执行时才会运行. 在此之前, a只能是0

static final常量值在编译期就"死"在字节码里了, JVM觉得没必要再等到初始化阶段去执行那条赋值指令, 直接在准备阶段把内存填好目标值就得了; 既省去了初始化阶段执行多余指令的开销, 也防止了在初始化之前读取到零值的风险(保证了常量的绝对安全性)

最后是解析阶段, 把常量池里的符号引用替换为直接引用的过程, 就是占位符变成了指针等有效引用

初始化

初始化就是执行类构造器<clinit>()方法。注意它和对象的构造函数不是一回事; 构造函数是new对象时执行的, 类构造器方法是类初始化时执行的, 一个类只执行一次

编译器会把类中所有的静态变量赋值语句和静态代码块,按照它们在源码中出现的先后顺序,合并成一个<clinit>()方法

java
public class InitOrder {   
    static int a = 1;   
    static {
        System.out.println("static block, a = " + a);
        a = 3;
    }
    static int b = 2;   

    public static void main(String[] args) {
        System.out.println("a = " + a + ", b = " + b); 
    }
}

// static block, a = 1
// a = 3, b = 2

静态语句块只能访问定义在它之前的变量,对定义在它之后的变量,可以赋值但不能访问(读取), 否则编译报"非法向前引用"

java
public class Test {
    // 赋值给后面的变量
    static {
        a = 10; // 允许,因为准备阶段已经给 a 分配内存了
    }
    static int a = 5;

    // 读取后面的变量
    static {
        System.out.println(b); // 报错:非法向前引用
    }
    static int b = 5;

    //先赋值后读取(但变量声明在后面)
    static {
        c = 10;        // 赋值允许
        System.out.println(c); // 读取报错
    }
    static int c = 5;
}

其他你需要注意的:

  1. 父类的静态语句块先于子类执行, 所以第一个被执行的永远是java.lang.Object的
  2. <clinit>()执行不是必须的, 没有静态语句块也没有类变量赋值, 编译器可以不生成
  3. <clinit>() 在多线程下被 JVM 保证同步执行, 多个线程同时初始化一个类时, 只有一个能执行,其他线程阻塞等待, 可能会导致死锁

关于触发初始化

有且只有6种情况会触发类的初始化(我们称之为懒加载)

  1. 遇到 new、getstatic、putstatic、invokestatic 这 4 条字节码指令; 它对应到代码就是: new一个对象, 读写一个类的静态字段(编译期常量除外), 调用一个类的静态方法。
  2. 反射调用: 用反射(java.lang.reflect)对类进行调用时
  3. 子类触发父类: 初始化一个类, 发现它的父类还没初始化, 先触发父类初始化
  4. 虚拟机启动时, 那个含 main() 的主类先初始化
  5. JDK7起的动态语言支持, MethodHandle 解析结果是静态相关方法句柄而对应类没初始化时
  6. JDK8起, 接口定义了默认方法(default), 实现类初始化时, 接口要先初始化

否则就等

架构设计和委派模型

三层类加载器

一切的Java类都必须经过JVM加载后才能运行,而类加载器(ClassLoader)的主要作用就是Java类文件的加载

对于JVM, 有两种类加载器

  1. C++实现的,作为虚拟机自身一部分的启动类加载器
  2. Java实现的,独立于虚拟机并继承java.lang.ClassLoader的加载器

展示JDK8中

类加载器实现负责加载
Bootstrap引导类/启动类加载器C++/lib核心库(如rt.jar)
Extension扩展类加载器Java/lib/ext或java.exe.dirs指定的目录
Application应用程序类/系统类加载器Java用户类路径上的类(工作目录)

ApplicationClassLoader是默认的类加载器,如果类加载时我们不指定类加载器的情况下,默认会使用AppClassLoader加载类; ClassLoader.getSystemClassLoader()返回的系统类加载器也是AppClassLoader

java
// 其实 String 由 Bootstrap 加载
System.out.println(”order”.getClass().getClassLoader());
// null <- Bootstrap 无法被 Java 引用,返回
System.out.println(String.class.getClassLoader());
// AppClassLoader <- 我们自己的类null
System.out.println(OrderService.class.getClassLoader());
// ExtClassLoader
System.out.println(OrderService.class.getClassLoader().getParent());        
// null <- Bootstrap
System.out.println(OrderService.class.getClassLoader().getParent().getParent()); 

值得注意的是某些时候我们获取一个类的类加载器时候可能会返回一个null值, 它不是没有加载器, 而是由Bootstrap(引导类加载器)加载; 它是C++写的, 所以约定用null表示

如java.io.File.class.getClassLoader()将返回一个null对象,因为java.io.File类在JVM初始化的时候会被Bootstrap加载

ClassLoader类有如下核心方法:

  • loadClass(加载指定的Java类)
  • findClass(查找指定的Java类)
  • findLoadedClass(查找JVM已经加载过的类)
  • defineClass(定义一个Java类)
  • resolveClass(链接指定的Java类)

这三层之间的协作方式, 就是双亲委派模型(Parents Delegation Model)

双亲委派模型

一个类加载器收到加载请求后, 自己不加载, 而是先把请求委派给父加载器, 一直向上委派到Bootstrap; 只有当父加载器反馈"我的范围里找不到这个类"时, 子加载器才尝试加载

详细步骤如下:

  1. 先检查这个类是不是已经被加载过了(findLoadedClass),是就直接返回    
  2. 没加载就委派给parent.loadClass(name);如果没有 parent, 就委派给Bootstrap    
  3. 如果父加载器加载失败(抛ClassNotFoundException), 才调用自己的findClass()尝试加载

一个类的身份由类本身+加载它的类加载器决定(同一个.class被两个不同的类加载器加载就是两个互不相等的类), 为了保证类的唯一性, 所以使用双亲委派模型

点名python, 原型链污染

非双亲委派

双亲委派只是loadClass()里的一段逻辑, 重写就能绕过; 有三类经典场景为了解决双亲委派本身解决不了的问题

父加载器使用子加载器

JDBC,java.sql.DriverManager,java.sql.Driver, 这些接口是JDK核心库部分, 由Bootstrap加载; 具体实现是第三方jar, 只能由AppClassLoader加载

于是新增了线程上下文类加载器(Thread Context ClassLoader); 核心库执行的时候用它区加载所需的实现类

隔离Web应用

Tomcat不可能只有一个业务, 若存在依赖库不相同的问题, 会导致业务异常

于是Tomcat给每个Web应用配置了独立的WebAppClassLoader, 加载一个类时优先自己加载类(WEB-INF下); 不过java.*这种核心类还是走委派

热部署

要实现模块级的热部署, 就得能丢弃旧的类并加载新的类

而一个类加载器加载过的类没法单独卸载, 只能连同整个类加载器一起被回收; 于是OSGi这类框架给每个模块(bundle)都配一个自己的类加载器, 换代码时把旧模块的类加载器整个换掉即可

无论怎么破坏双亲委派, 核心库(java.*等)必须由父加载器加载; defineClass方法本身会拒绝加载java.*包下的类,这是 JVM 的底线

类加载例子

想象ClassLoader是一个快递配送系统,负责将类(包裹)送达JVM(客户):

  1. 接收订单 - loadClass("com.anbai.sec.classloader.TestHelloWorld")
    • 你下单要一个"TestHelloWorld"包裹
  2. 检查仓库 - findLoadedClass()
    • 配送中心先查本地仓库:这个包裹是否已送达过?
    • 是 → 直接取出包裹(返回已加载的类)
    • 否 → 继续配送流程
  3. 委托上级 - 父类加载器
    • 默认先让父配送中心处理(双亲委派模型)
    • 如果没上级,直接找总部仓库(Bootstrap ClassLoader)
java
if (parent != null) {
    return parent.loadClass(name); // 让上级配送中心处理
} else {
    return findBootstrapClassOrNull(name); // 总部仓库
}
  1. 自行配送 - findClass()
    • 如果上级都送不了,自己配送:
java
protected Class<?> findClass(String name) {
    // 1. 查找类字节码(找包裹)
    byte[] classBytes = locateClassBytes(name);

    // 2. 注册到JVM(签收包裹)
    return defineClass(name, classBytes, 0, classBytes.length);
}
  1. 拆包检查 - resolveClass() (当resolve=true)

    • 检查包裹完整性(链接:验证、准备、解析)
    • 默认不立即拆包(loadClass的resolve参数默认为false)
  2. 交付客户 - 返回Class对象

    • 包裹成功送达JVM

自行加载时, 先通过findClass找字节码, 随后通过defineClass定义类, 再判断是否需要解析以确定要不要调用resolveClass()

Java8-Java17

JDK9开始引入了模块化系统(Java Platform Module System, JPMS), 重建了JDK8的模型

JDK9移除了lib/ext扩展机制和java.ext.dirs, 其加载器Extension扩展类加载器换成了Platform平台类加载器

JDK9移除了rt.jar和tools.jar, 拆分为多个.jmod, 整合进lib/modules文件夹下

JDK9的平台类加载器和应用类加载器改成了jdk.internal.loader.ClassLoaders里的内部类, 不再是URLClassLoader

双亲委派多了模块的可见性检查, 根据检查可以将类直接委派给正确的加载器而不是逐级上传

自定义ClassLoader

java.lang.ClassLoader是所有的类加载器的父类, 它有非常多的子类加载器, 例如java.net.URLClassLoader;java.net.URLClassLoader通过继承然后重写findClass方法可以实现加载目录class文件甚至是远程资源文件, 这也是官方推荐的

主要是loadClass()中有双亲委派, 你乱写不是捣乱吗

如果一个类不存在于我们的classpath, 我们可以使用自定义类加载器重写findClass方法,然后在调用defineClass方法的时候传入信类的字节码的方式来向JVM中定义一个类, 最后通过反射机制就可以调用该类的方法了


参考文章:

  1. 微信-JVM - 类加载机制与类加载器

文章标题:类加载

文章作者:4reexile

文章链接:https://4reexile.github.io/posts/java/%E7%B1%BB%E5%8A%A0%E8%BD%BD[复制]

最后修改时间:


商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接,您可以自由地在任何媒体以任何形式复制和分发作品,也可以修改和创作,但是分发衍生作品时必须采用相同的许可协议。
本文采用CC BY-NC-SA 4.0进行许可。