Snipaste_20211222_222944.png

1. 类的生命周期

类加载的过程包括了加载、验证、准备、解析、初始化五个阶段,在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,某些情况下可以在初始化阶段之后开始解析,这是为了支持 java 语言的运行时绑定(也称为动态绑定或晚期绑定),另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段

1.1 加载

它是 java 将字节码数据从不同的数据源读取到 JVM 中,并映射为 JVM 认可的数据结构(Class 对象),这里的数据源可能是各种各样的形态,如 jar 文件、class 文件,甚至是网络数据源等;如果输入数据不是 ClassFile 的结构,则会抛出 ClassFormatError
加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程

  • 通过类的全限定名来读取类的二进制字节流
  • 把类的二进制流转为方法区数据结构,并存放到方法区
  • 在堆中产生 java.lang.Class 对象,作为方法区数据的访问入口
1.2 验证

验证 class 文件是否符合规范,并且不会危害虚拟机自身的安全,可使用 -Xverify:none 关闭验证以缩短虚拟机类加载的时间

  • 文件格式验证
    • 是否以 0xCAFEBABE 开头
    • 版本号是否合理
  • 无数据验证
    • 是否有父类
    • 是否继承了 final 类(final 类不能被继承,如果继承了就说明有问题)
    • 非抽象类实现了所有抽象方法
  • 字节码验证
    • 运行检查
    • 栈数据类型和操作码操作参数吻合(比如栈空间只有 2 字节,但其实却需要大于 2 字节,此时就认为这个字节码有问题)
    • 跳转指令是不是指向了合理位置
  • 符号引用验证
    • 常量池中描述类是否存在
    • 访问的方法或字段是否存在且有足够的权限
1.3 准备

创建类或接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的,侧重点在于分配所需要的内存空间,不会去执行更进一步的 JVM 指令

final static 修饰的变量(不涉及方法调用):直接赋值为用户定义的值,比如 private final static int value = 123,直接赋值 123
如果是 private static int value = 123,则该阶段的值依然是 0

1.4 解析

将类、接口、字段和方法的符号引用转为直接引用

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中
  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那说明引用的目标必定已经存在于内存之中了
1.5 初始化

真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑

  • 执行 <clinit> 方法,<clinit> 方法由编译器自动收集类里面的所有静态变量的赋值动作及静态语句块合并而成,也叫类构造器方法
    • 初始化的顺序和源文件中的顺序一致
    • 子类的 <clinit> 被调用前,会先调用父类的 <clinit>
    • JVM 会保证 <clinit> 方法的线程安全性
  • 初始化时,如果实例化一个新对象,会调用 <init> 方法对实例变量进行初始化,并执行对应的构造方法内的代码

2. 类初始化的时机

  • 当创建一个类的实例时,比如使用 new 关键字,或者通过反射、克隆、反序列化
  • 当调用类的静态方法时,即当使用了字节码 invokestatic 指令
  • 当使用类、接口的静态字段时(final 修饰特殊考虑),比如,使用 getstatic 或者 putstatic 指令
  • 当使用 java.lang.reflect 包中的方法反射类的方法时,比如:Class.forName("ckx.inkjava.Test")
  • 当初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  • 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类) ,虚拟机会先初始化这个主类
  • 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类(涉及解析 REF_getStatic、REF_putStatic、REF_invokeStatic 方法句柄对应的类)

3. 类加载器

Snipaste_20211222_223609.png

类加载器是 JVM 执行类加载机制的前提

ClassLoader 是 java 的核心组件,所有的 Class 都是由 ClassLoader 进行加载的,ClassLoader 负责通过各种方式将 Class 信息的二进制数据流读入 JVM 内部,转换为一个与目标类对应的 java.1ang.Class 对象实例,然后交给 java 虚拟机进行链接、初始化等操作

因此, ClassLoader 在整个装载阶段,只能影响到类的加载,而无法通过 ClassLoader 去改变类的链接和初始化行为,至于它是否可以运行,则由 Execution Engine 决定

类加载器通常由 JVM 提供,JVM 提供的这些类加载器通常被称为系统类加载器,除此之外,还可以通过继承 ClassLoader 来创建自己的类加载器

注意: 这里父类加载器并不是通过继承关系来实现的,而是采用组合实现的(在下层加载器中包含着上层加载器的引用)

3.1 类的加载分类

class 文件的显式加载与隐式加载的方式是指 JVM 加载 class 文件到内存的方式

  • 显式加载:指的是在代码中通过调用 ClassLoader 加载 class 对象,如直接使用 Class.forName(name) 或 this.getClass().getClassLoader().loadClass() 加载 class 对象
  • 隐式加载:则是不直接在代码中调用 ClassLoader 的方法加载 class 对象,而是通过虚拟机自动加载到内存中,如在加载某个类的 class 文件时,该类的 class 文件中引用了另外一个类的对象,此时额外引用的类将通过 JVM 自动加载到内存中
3.1 启动类加载器(Bootstrap ClassLoader)

用来加载 java 的核心类,是由 C++ 实现的,并不继承自 java.lang.ClassLoader,无法被 java 程序直接引用,主要负责加载 jre/lib/rt.jar 里所有的 class,或被 -Xbootclasspath 参数指定的路径中能被 JVM 识别的类库

3.2 扩展类加载器(Extension ClassLoader)

该加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载 jre 的扩展目录,lib/ext 或者由 java.ext.dirs 系统变量指定的路径中的所有类库,开发者可以直接使用扩展类加载器

3.3 应用程序类加载器(Application ClassLoader)

该类加载器由 sun.misc.Launcher$AppClassLoader 来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

3.4 自定义类加载器

如果以上三种类加载器不满足使用的时候,我们可以使用自定义类加载器,继承 java.lang.ClassLoader 类,重写 findClass() 方法

public class MyClassLoader extends ClassLoader {

    private String root;

    // 如果不想打破双亲委派模型,那么只需要重写 findClass 方法即可
    // 如果想打破双亲委派模型,那么就重写整个 loadClass 方法
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        }
        // 直接生成 class 对象
        return defineClass(name, classData, 0, classData.length);
    }

    private byte[] loadClassData(String className) {
        String fileName = root + File.separatorChar + className.replace('.', File.separatorChar) + ".class";
        try (InputStream inputStream = new FileInputStream(fileName);
             ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
            byte[] buffer = new byte[1024];
            int length;
            while ((length = inputStream.read(buffer)) != -1) {
                byteArrayOutputStream.write(buffer, 0, length);
            }
            return byteArrayOutputStream.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    public static void main(String[] args) throws ClassNotFoundException {
        String rootDir = "D:\\project\\study\\src\\main\\java";

        UserClassLoader loader1 = new UserClassLoader(rootDir);
        Class clazz1 = loader1.findClass("ink.ckx.test.User");
        System.out.println(clazz1.getClassLoader());

        UserClassLoader loader2 = new UserClassLoader(rootDir);
        Class clazz2 = loader2.findClass("ink.ckx.test.User");
        System.out.println(clazz2.getClassLoader());

        Class clazz3 = ClassLoader.getSystemClassLoader().loadClass("ink.ckx.test.User");
        System.out.println(clazz3.getClassLoader());

        // clazz1 与 clazz2 对应了不同的类模板结构
        System.out.println(clazz1 == clazz2);
        System.out.println(clazz1.getClassLoader().getParent() == clazz3.getClassLoader());
        System.out.println(clazz2.getClassLoader().getParent() == clazz3.getClassLoader());
    }
}

4. 类加载机制

4.1 全盘负责

当一个类加载器负责加载某个 Class 时,该 Class 所依赖的和引用的其他 Class 也将由该类加载器负责载入,除非指定使用另外一个类加载器来载入

4.2 父类委托

先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类

4.3 缓存机制

缓存机制将会保证所有加载过的 Class 都会被缓存,当程序中需要使用某个 Class 时,类加载器先从缓存区寻找该 Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成 Class 对象,存入缓存区,这就是为什么修改了 Class 后,必须重启 JVM,程序的修改才会生效

4.4 双亲委派机制

当类加载器试图加载某个类型的时候,除非父加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载 java 类型

加载过程:

  • 当 AppClassLoader 加载一个 class 时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器 ExtClassLoader 去完成
  • 当 ExtClassLoader 加载一个 class 时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给 BootStrapClassLoader 去完成,如果 BootStrapClassLoader 加载失败(例如在 $java_HOME/jre/lib 里未查找到该 class),则会使用 ExtClassLoader 来尝试加载
  • 若 ExtClassLoader 也加载失败,则会使用 AppClassLoader 来加载,如果 AppClassLoader 也加载失败,则会报出异常 ClassNotFoundException

优势:

  • 系统类防止内存中出现多份同样的字节码
  • 保证 java 程序安全稳定运行

不适合使用双亲委派的场景:

  • 我们希望一个 JVM 能够同时加载某类的不同版本,那么双亲委派就不合适了,需要的是在不同范围内(例如模块)单独加载

Q.E.D.


盛年不重来,一日难再晨。