✝️ 类加载

吞佛童子2022年6月20日
  • Java
  • JVM
大约 11 分钟

✝️ 类加载

1. 什么是类加载

虚拟机的类加载机制

  • Java 虚拟机将描述类的数据从 Class 文件加载到内存,
  • 并对数据进行 校验转换解析 & 初始化
  • 最终形成能被虚拟机直接使用的 Java 类型

2. 类型的生命周期

[注:] “类型”是指 类 | 接口,之后统称为 类

类的生命周期

后面打 Y 表示类加载过程中,开始的顺序确定,但并不意味着前一个没有结束就不能开始后一个


1) 加载

  • 任务:
    • 通过类的全限定名来获取定义该类的二进制字节流
      • 可从 zip 压缩包、 jar、 war、加密文件等中获取二进制字节流
    • 将二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构
      • 将 Class 文件中的类型信息,例如有哪些常量、字段有哪些、方法有哪些这些信息放入方法区
    • 在内存中生成一个代表该类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
      • 这时还没有调用 new 创建对象,为什么就在堆上生成了一个对象?
      • 注意这里生成的是 java.lang.Class 对象,它是为了反射时用的
Hello obj = new Hello();
Class<?> clz = obj.getClass();
  • 特点:
    • 非数组类型的加载阶段是开发人员可控性最强的阶段
    • 即可以使用虚拟机内置的启动类加载器完成,还可以自定义类加载器完成
  • 数组类的加载
    • 数组类本身通过类加载器创建,而是又 Java 虚拟机直接在内存中动态构造生成
    • 但数组内部元素所在的类仍需要通过类加载器来完成加载
  • 如何实现从网络上加载? - 来自ClassLoader 类的示例
     ClassLoader loader= new NetworkClassLoader(host,port);
     Object main= loader.loadClass("Main", true).newInstance();

     class NetworkClassLoader extends ClassLoader {
         String host;
         int port;

         // 重写 findClass 方法
         public Class findClass(String name) {
             byte[] b = loadClassData(name);
             return defineClass(name, b, 0, b.length);
         }

         // 自定义方法,实现根据名称从网络上加载该类,得到 byte[] 数组
         private byte[] loadClassData(String name) {
             // load the class data from the connection
             ...
         }
     }

2) 验证

  • 目的:
    • 确保 Class 文件的字节流中的信息符合虚拟机规范,不会对虚拟机产生危害
  • 验证内容:
    • 文件格式的验证
      • CA FE BA BE 开头
      • 次、主版本号是否在有效范围内
      • 常量池中的常量类型是否都能识别,常量的索引值能否正确指向等
    • 元数据的验证
      • 这个类是否有父类,除 java.lang.Object 外,所有类均应有父类
      • 是否继承了不能被继承的类 - final 类
      • 是否为抽象类,是否实现了父类或接口中要求实现的所有方法
      • 类中是否覆盖父类的 final 字段,是否出现不符合规则的方法重载
    • 字节码验证
      • 对类的方法进行校验,即 Class 文件中的 Code 属性,确保方法的跳转、类型转换等无误
    • 符号引用验证
      • 能否通过符号引用中的类的全限定名访问到需要的外部类
      • 能否访问需要的外部类的字段 & 方法

3) 准备

  • 任务:
    • 为类静态变量分配内存 & 赋初值
  • 补充:
    1. 分配到哪里的内存
    • 理论上说,类静态变量属于方法区
    • 但方法区只是一个逻辑概念
    • JDK 7 时将 类静态变量移到了堆中,因此此时分配内存是分配到了堆内存中
    1. 赋初值
    • 这里的初值的指,类静态变量所属的类的初始值,例如 0 值
    • 而赋实际值的操作发生在初始化阶段的 <clinit>()
    • 但特殊情况下,若类静态变量被 final 修饰,则会直接赋实际值

4) 解析

  • 任务:
    • 将常量池中的 符号引用 转换为 直接引用 的过程
      • 符号引用:以一组符号来描述所引用的目标,可以是任何形式的字面量,只要能无歧义定位到目标即可
      • 直接引用:可以直接指向目标的指针 | 相对偏移量 | 一个间接定位到目标的句柄
  • 解析内容:
    • 主要针对 类或接口、字段、方法、方法句柄、调用点限定符的相关符号引用进行解析
  • 解析前提:
    • 并不能将所有符号引用转换为直接引用,
    • 只有方法在程序真正运行之前就能确定调用哪个版本时,即运行期间不可改变的这部分方法的调用才能被解析出来
    • 必须满足 编译期已知,运行期不可变
      • 而满足这一前提的方法,只有:
        • 静态方法 - 与类直接关联 - 字节码指令 invokestatic
        • 私有方法 - 只有自身类可访问,外部类不可访问 - invokespecial
        • 实例构造器 - <init>() - invokespecial
        • 父类中的方法 - invokespecial
        • final 修饰的方法 - invokevirtual
  • 举例:

5) 初始化

  • 目标:
    • 执行类构造器 <clinit>() 的过程
  • 初始化时机:
    • main 的主类
    • 遇到 new | getstatic | putstatic | invokestatic 这四条字节码指令时
      • 遇到 new 关键字实例化对象时
      • 读取 | 设置一个类的非 final 修饰的类静态变量
      • 调用一个类的静态方法
    • 使用 java.lang.reflect 包下的方法对类进行反射调用时,若该类未初始化,则需要先初始化
    • 初始化类时,发现父类还未进行初始化,先初始化父类

<clinit>()

  • 如何生成
    • Javac 编译期自动收集类中的所有类静态变量的赋值操作 & 静态代码块的语句合并生成
    • 若没有 类静态变量 | 静态代码块,那么不会生成 <clinit>()
  • 收集顺序
    • 静态语句块中只能访问到定义在它之前的变量
    • 定义在它之后的变量,在前面的静态代码块中可以赋值,但不可访问
  • 执行顺序:
    • Java 虚拟机会保证该类的 <clinit>()执行之前,父类的 <clinit>()已经执行完毕
    • 因此 第一个被执行的 <clinit>()的类肯定是 java.lang.Object
  • 是否必须
    • 若一个类中并没有静态语句块,也没有类静态变量的赋值操作,则编译器不会为这个类生成 <clinit>()
  • 接口的特殊情况
    • 接口中没有静态代码块,但可能有静态变量的赋值操作,因此也可能生成 <clinit>()
    • 接口执行 <clinit>()时,无需先执行父类的 <clinit>(),只有父接口定义的变量被使用时,父接口才会进行初始化
    • 接口的实现类在初始化时也不会执行接口的 <clinit>()
  • 线程安全
    • Java 虚拟机必须保证 一个类的 <clinit>() 在多线程下的加锁同步,只有一个类能够去执行这个类的 <clinit>(),其他线程阻塞等待直到完成
    • 其他线程唤醒后,不会再次进入 <clinit>(),同一类加载器下,一个类只会被初始化一次

3. 双亲委派模型

1) 类加载器

  • 通过类的全限定名来获取定义该类的二进制字节流,实现这个操作的代码被称为 “类加载器”
    • 比较 2 个类是否 “相等”,必须在这两个类由同一类加载器加载的前提下,才有效
    • 否则,即使这两个类来自于同一个 Class 文件,且被同一个 Java 虚拟机加载,若是类加载器不同,则这两个类必不相等
    • “相等” 指的是代表该类的 Class 对象的 equals() isInstance() 等的返回结果相等

2) 双亲委派模型

① 三层类加载器

  • 启动类加载器 [Bootstrap Class Loader]
    • 负责加载存放在 <JAVA_HOME>\lib 目录 | -Xbootclasspath 参数指定的路径中存放的,且为 Java 虚拟机识别[根据名称]的类库
    • HotSpot 中使用 C++ 实现,J9 & JRockit 中虽然有 Java 类的存在,但关键方法仍是通过 JNI 回调 C 语言
  • 扩展类加载器 [Extension Class Loader]
    • 以 Java 代码实现,可直接在程序中使用扩展类加载器来加载 Class 文件
    • 负责加载存放在 <JAVA_HOME>\lib\ext 目录 | 被 java.ext.dirs 系统变量指定路径中的所有类库
  • 应用程序类加载器 [Application Class Loader]
    • 负责加载用户类路径 [ClassPath] 上的所有类库
    • 可直接在代码中使用这个类加载器
    • 若没有自定义类加载器,那么一般情况下,该类加载器就是程序中默认的类加载器

② 双亲委派模型如下:

③ 双亲委派模型的代码实现

public abstract class ClassLoader {
    private final ClassLoader parent;
    
    // ...
    
    // 使用 指定的二进制名称 加载类
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }
    
    // 使用 指定的二进制名称 加载类
    // 合法的 二进制名称 如下:
    // "java.lang.String"
    // "javax.swing.JSpinner$DefaultEditor"
    // "java.security.KeyStore$Builder$FileBuilder$1"
    // "java.net.URLClassLoader$3$1"
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 1. 检查请求的类是否已经被加载过
            Class<?> c = findLoadedClass(name);
            // 2. 类未被加载过
            if (c == null) { 
                long t0 = System.nanoTime();
                try {
                    if (parent != null) { // 2.1 父加载器不为空,调用父加载器 loadClass() 方法处理
                        c = parent.loadClass(name, false); 
                    } else { // 2.2 父加载器为空,使用启动类加载器 BootstrapClassLoader 加载
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                // 3. 如果仍未成功加载,调用 findClass(name) 尝试
                if (c == null) {
                    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;
        }
    }
    
    // 返回类加载操作的锁对象
    // 若此类加载器对象注册时具有并行能力,则该方法将返回与指定类名关联的专用对象;否则,该方法返回 当前 类加载器对象
    protected Object getClassLoadingLock(String className) {
        Object lock = this;
        if (parallelLockMap != null) {
            Object newLock = new Object();
            lock = parallelLockMap.putIfAbsent(className, newLock);
            if (lock == null) {
                lock = newLock;
            }
        }
        return lock;
    }

    // 查找具有指定二进制名称的类,可被重写
    // 可通过重写该方法,在双亲委派的情况下,仍无法加载该类时,从 网络 | 压缩件 等文件中加载该类
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }
}

④ 双亲委派模型优点:

  • Java 中的类随着类加载器一起具备了优先级的层次关系,防止同一个类被不同类加载器加载后得到不相等的结果,保证核心 API 不被篡改
  • 例如, java.lang.Object 存在于 rt.jar 中,不论被哪种类加载器加载,最后总能被启动类加载器加载,从而保证同一性

⑤ 打破双亲委派

  1. Tomcat 打破双亲委派
  • 背景
    • 主流 Web 服务器应能够解决以下问题:
    • 部署在同一服务器下的 2 个 Web 应用程序所使用的的 Java 类库可以相互隔离
    • 部署在同一服务器下的 2 个 Web 应用程序所使用的的 Java 类库可以相互共享 - 节约资源
    • 服务器应尽可能保证自身的安全不受部署的 Web 应用程序的影响
    • 支持 JSP 应用的 Web 服务器,几乎都支持 热替换 功能
      • 一种动态网页开发技术。它使用JSP标签在HTML网页中插入Java代码。标签通常以 <% 开头以 %> 结束
  • 如何打破
    • Tomcat 总是先尝试去加载某个类,如果找不到再用上一级的加载器,跟双亲加载器顺序正好相反
上次编辑于: 2022/10/10 下午8:43:48
贡献者: liuxianzhishou