珊珊老师 的笔记

做最负责任的教育~我是执行者珊珊

2025-05-23 11:38

Java 类加载机制

珊珊老师

Java后端

(48)

(0)

收藏

一、类的生命周期

类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。

image.png

1.类加载过程

系统加载 Class 类型的文件主要为散布:加载 -> 连接 -> 初始化。连接过程又可分为三步:验证 -> 准备 -> 解析。


在这五个阶段中,加载、验证、准备、初始化 这四个阶段发生的顺序是确定的,而 解析 阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持 Java 语言的运行时绑定(也称为动态绑定或晚期绑定)。


注意:这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是相互交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。


1.1. 加载

加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三个事情:


通过一个类的全限定名来获取其定义的二进制字节流。

将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

在 Java 堆中生成一个代表这个类的 java.lang.class 对象,作为对方法区中这些数据的访问入口。

虚拟机规范上面这 3 点并不具体,因此是较为灵活的。比如:"通过一个类的全限定名来获取其定义的二进制字节流"并未指明具体从哪里获取、怎样获取。


加载这一步主要是通过 类加载器完成的。类加载器有很多种,当我们想加载一个类时,具体是哪个类加载器加载由 双亲委派模型 决定。


每个 Java 类都有一个引用指向加载它的 ClassLoader。 不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过 getClassLoader() 方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。


一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段(相比于其它阶段),这一步我们既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载(重写一个类加载器的 loadClass() 方法)。


加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉运行的,加载阶段尚未结束,连接阶段可能就已经开始了。加载 .class 文件的方式:

  • 从 ZIP 包读取,成为 JAR、EAR、WAR 格式的基础。

  • 从网络中获取,最典型的应用是 Applet。

  • 运行时计算生成,例如动态代理技术,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 的代理类的二进制字节流。

  • 由其它文件生成,例如由 JSP 文件生成对应的 Class 类。

1.2. 验证

验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。


验证阶段大致会完成 4 个阶段的校验动作:


文件格式验证(Class 文件格式检查):验证字节流是否符合 Class 文件格式的规范;例如:是否以 0xCAFFBABE 开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持类型。


元数据验证(字节码语义检查):对字节码描述的信息进行语义分析(注意:对比 javac 编译阶段的语义分析),以保证其描述的信息符合 Java 语言规范的要求;例如:这个类是否有父类,除了 java.lang.Object 之外。


字节码验证(程序语义检查):通过数据流和控制流分析,确定程序语义是合法、符合逻辑的。


符号引用验证(类的正确性检查):验证该类的正确性,确保解析动作能正确执行。


文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。除了这一阶段之外,其余三个阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。

符号引用验证发生在类加载过程中的解析阶段,具体点说是 JVM 将符号引用转化为直接引用的时候。


符号引用的验证的主要目的是确保解析阶段能正常执行,如果无法通过符号引用验证,JVM 会抛出异常,比如:


  • java.lang.IllegalAccessError:当类试图访问或修改它没有权限访问的字段,或调用它没有权限访问的方法时,抛出该异常。

  • java.lang.NoSuchFiledError:当类试图访问或修改一个指定的对象字段,而该对象不再包含该字段时,抛出该异常。

  • java.lang.NoSuchMethodError:当类试图访问一个指定的方法,而该方法不存在时,抛出该异常。


1.3. 准备

准备阶段是正式成为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:


这时候进行内存分配的仅包含类变量(Class Variables,即静态变量,被 static 关键字修饰的变量,只与类相关,因此被称为类变量),而不包含实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。

从概念上讲,类变量所使用的内存都应当在方法区中进行分配。不过有一点需要注意的是:JDK7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符号这种逻辑概念的。而在 JDK7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。


这里所设置的初始值通常情况下是数据类型默认的零值(0、0L、null、false 等),而不是被在 Java 代码中被显式地赋予的值。

假设一个类变量的定义为:public static int value = 3;,那么变量 value 在准备阶段过后的初始值为 0,而不是 3(初始化阶段才进行赋值)。特殊情况:比如给变量 value 加上 final 关键字 public static final int value = 3;,那么准备阶段 value 的值就被赋值为 3。


基本数据类型的零值:

image.png

1.4. 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。


符号引用就是一组符号来描述目标,可以是任何字面量。


直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。


1.5. 初始化

初始化阶段才是真正开始执行类中定义的 Java 程序代码,主要是对类变量进行初始化。初始化阶段是虚拟机执行类构造器 <clinit>()1 方法的过程。


对于 <clinit>() 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 <clinit>() 方法是带锁线程安全,所以在多线程环境下进行类初始化的话,可能会引起多个线程阻塞,并且这种阻塞很难被发现。


对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类初始化(只有主动去使用类才会初始化类):


当遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。

  • 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。

  • 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。

  • 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。

  • 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。

使用 java.lang..reflect 包的方法对类进行反射调用时。如 Class.forname("...").newInstance 等等。如果类没初始化,需要触发其初始化。

初始化一个类,如果父类还未初始化,则先触发该父类的初始化。

当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。

MethodHandle 和 VarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用,就必须先使用 findStaticVarHandle 来初始化要调用的类。

当一个接口定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

2. 使用

类访问方法区内的数据结构的接口,对象是 Heap 区的数据。


3. 卸载

卸载类即该类的 Class 对象被 GC。


卸载类需要满足 3 个要求:


该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。

该类没有在其它任何地方被引用。

该类的类加载器的实例已被 GC。

所以,在 JVM 生命周期内,由 JVM 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。因此,JDK 自带的 BoostrapClassLoader、ExtClassLoader、AppClassLoader 负责加载 JDK 提供的类,所以它们(类加载的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。


二、类加载器

1. 介绍

类加载器从 JDK1.0 就出现了,最初只是为了满足 Java Applet(已淘汰)的需要。后来,慢慢成为 Java 程序中的一个重要组成部分,赋予了 Java 类可以被动态加载到 JVM 中并执行的能力。

从上面介绍可知:

类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。

每个 Java 类都有一个引用指向加载它的 ClassLoader。

数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。

简单来说,类加载器的主要作用就是加载 Java 类的字节码到 JVM 中(在内存中生成一个代表该类的 Class 对象)。字节码可以是 Java 源程序经过 javac 编译得来,也可以是通过工具动态生成或者通过网络下载得来。但不仅限于加载类,除此之外还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等文件资源。

2. 加载规则

JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。


对于已经加载的类会放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。

3. 层次

image.png

 从 JVM 的角度来讲,只存在两种不同的类加载器:


启动类加载器(BootstrapClassLoader),使用 C++ 实现,是虚拟机自身的一部分。

所有其它类的加载器,使用 Java 实现,独立于虚拟机,继承自抽象类 java.lang.ClassLoader。

从 Java 开发人员的角度看,类加载器可以大致划分为以下三类:


  • BoostrapClassLoader(启动类加载器):最顶层的加载类,由 C++ 实现,通常表示为 null(在编写自定义加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 替换即可),并且没有父级,主要用来加载 JDK 内部的核心类库(%JRE_HOME%\lib 目录下的 rt.jar、resources.jar、charsets.jar 等 jar 包和类)以及被 -Xbootclasspath 参数指定的路径下的所有类。

  • ExtensionClassLoader(拓展类加载器):该加载器由 sun.misc.Launcher$ExtClassLoader 来实现,它主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下所有的类。

  • ApplicationClassLoader(应用程序类加载器):该加载器由 sun.misc.Launcher$AppClassLoader 来实现,它是面向我们用户的加载器,它负责加载当前应用 ClassPath 下所有的 jar 包和类。开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

4. 寻找类加载器

寻找类加载器示例如下:

public class Test {
    public static void main(String[] args) {
        ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
        System.out.println(classLoader);
        System.out.println(classLoader.getParent());
        System.out.println(classLoader.getParent().getParent());
    }
}

结果如下:

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1b6d3586
null

通过结果可以也可以看出,AppClassLoader 的父 ClassLoader 是 ExtClassLoader,ExtClassLoader 的父 ClassLoader 是 BoostrapClassLoader,由于 BoostrapClassLoader 是 C++ 实现的,在 Java 中没有与之对应的类,所以拿到的结果是 null。


三、类加载

1. 加载方式

类加载有三种方式:


命令行启动应用时由 JVM 初始化加载。

通过 Class.forName() 方法动态加载。

通过 ClassLoader.loadClass() 方式动态加载。

2. 双亲委派模型

2.1. 相关策略

全盘负责:当一个类加载器负责加载某个 Class 时,该 Class 所依赖和引用的其它 Class 也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。

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

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

2.2. 介绍

从上面的介绍可以看出:


ClassLoader 类使用委托模型来搜索类和资源

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。

ClassLoader 实例会在试图亲自查找类和资源之前,将搜索类或资源的任务委托给其父类加载器。

下面展示的各种类加载器之间的层次关系,被称为类加载器的 “双亲委派模型(Parents Delegation Model)”。

image.png

注意:双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。因为某些特殊需求想要打破双亲委派机制,也是可以的。另外,类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父类加载器的代码。


2.3. 代码实现

双亲委派模型的实现代码非常简单,逻辑也非常清晰,都集中在 java.lang.ClassLoader 的 loadClass() 中,相关代码如下:

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;
        }
    }

2.4. 执行流程

每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。

结合上边源码,简单总结双亲委派模型的执行流程:


在类加载的时候,系统会首先判断当前类是否已被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会进行一遍此流程)。

类加载器在进行类加载时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器的 loadClass() 方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BoostrapClassLoader 中。

只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需要的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。

如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundExecption 异常。

2.5. 优势

双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。


如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会存在一些问题,例如 java.lang.Object 存放在 rt.jar 中,如果编写另外一个 java.lang.Object 并存放到 ClassPath 中,程序可以编译通过。双亲委派模型可以保证加载的是 JRE 里的那个 Object 类,而不是 ClassPath 中的 Object 类。这是因为 AppClassLoader 在加载 ClassPath 中的 Object 类时,会委托给 ExtClassLoader 去加载,而 ExtClassLoader 又会委托给 BoostrapClassLoader,由于 BoostrapClassLoader 已经加载过了 Object 类,会直接返回,不会再加载 ClassPath 中的 Object 类。

四 、自定义类加载器

自定义类加载器一般都是继承自 ClassLoader 类。


ClassLoader 类有两个关键的方法:


protected Class<?> loadClass(String name, boolean resolve):加载指定二进制名称的类,实现了双亲委派机制。name 为类的二进制名称,resolve 如果为 true,在加载时调用 resolveClass(Class<?> c) 方法解析该类。

protected Class<?> findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。


0条评论

点击登录参与评论