《深入理解 Java 虚拟机》阅读笔记 - 类加载机制

本文深入探讨Java类加载过程,包括加载、验证、准备、解析和初始化五个阶段,以及类加载器与双亲委派模型的工作原理。同时,介绍了如何破坏双亲委派模型和自定义类加载器的方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

  • 类加载的时机
  • 类加载的过程
    • 加载
    • 验证
    • 准备
    • 解析
    • 初始化
  • 类加载器与双亲委派模型
    • 类加载器
    • 双亲委派模型
    • 破坏双亲委派模型
    • 自定义类加载器
  • Tomcat 类加载器
  • 参考

 

在 Java 语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为 Java 应用程序提供高度的灵活性,Java 里天生可以动态扩展的语言特性就是依赖运行期间动态加载和动态连接这个特点实现的。

 

类加载的时机


在这里插入图片描述
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,解析阶段在某些情况下可以在初始化阶段之后再开始(“开始”,而不是“进行”或“完成”)。
在以下五种情况下必须立即对类进行“初始化”:

  • 遇到 new, getstatic, putstatic 或 invokestatic 字节码指令时。(使用 new 实例化对象、读取或设置一个类的静态字段以及调用一个类的静态方法时。)
  • 使用 java.lang.reflect 包的方法对类进行反射调用时。
  • 初始化一个类时,其父类还没有进行初始化。
  • 虚拟机启动时,用户需要制定一个要执行的主类(包含 main 方法的类)。
  • 当使用 JDK1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后解析的结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄对应的类没有初始化,则需要先触发其初始化。

以上五类场景的初始化称为对一个类进行主动引用,除此之外,所有的引用类的方式都不会触发初始化,称为被动引用。

被动引用示例:

  • 通过子类引用父类的静态字段,不会导致子类初始化。
  • 通过数组定义来引用类,不会触发此类的初始化。
  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

接口也需要初始化。但接口在初始化时,并不要求其父接口全部都已经初始化过了,只有在使用父接口时才会初始化。

 

类加载的过程


包括加载、验证、准备、解析、和初始化这 5 个阶段。


加载

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。(Class 对象并没有明确规定是在堆中,对于 HotSpot 而言,Class 对象存放在方法区里)

数组类本身不通过类加载器创建,它是由 Java 虚拟机直接创建的,但是数组类与类加载器仍然有很密切的关系,因为数组类的元素类型最终要靠类加载器创建。

“加载”是“类加载”过程的一个阶段,两者不是同一概念。


验证

这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

检验动作包括以下 4 个阶段:

文件格式验证

验证字节流是否符合 Class 文件格式的规范。保证输入的字节流能正确地解析并存储于方法区之内。

通过此阶段后,字节流才会进入内存的方法区中进行存储。后面三个阶段全部都是基于方法去的存储结构进行的,不会再直接操作字节流。

元数据验证

对字节码描述的信息进行语义分析,保证且信息符合 Java 语言规范的要求。

  • 是否有父类(除了 Object,所有类都有父类)。
  • 是否继承了不允许被继承的类。
  • 抽象类是否实现了父类或接口的所有方法。

字节码验证

确定程序语义是合法的。
对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。

  • 保证跳转指令不会跳转到方法体以外的字节码指令上

符号引用验证

对类自身以外(常量池中的何种符号引用)的信息进行匹配性校验。

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段

准备

正式为类变量分配内存并设置类变量初始值的阶段。
这里进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
这里所说的“初始值”通常情况下是数据类型的零值。

数据类型零值
int0
long0L
short(short)0
char‘\u0000’
byte(byte)0
booleanfalse
float0.0f
double0.0d
referencenull

final 声明的字段在准备阶段就会被赋值。


解析

将常量池中的符号引用替换为直接引用。

符号引用:以一组符号来描述所引用的目标。符号引用于虚拟机的实现的内存布局无关,引用的目标不一定已经加载到内存中。各种虚拟机实现的内存布局各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
直接引用:直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。同一个符号引用在不同虚拟机实例上翻译的直接引用一般不会相同。如果有了直接引用,那引用的目标必定是已经在内存中存在的。


初始化

类初始化阶段是类加载过程的最后一步。
根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者说,初始化阶段是执行类构造器 < clinit >() 方法的过程。

  • < clinit >() 方法是由编译器自动收集类中的所有类变量的复制动作和静态语句块中的语句合并产生的。
  • < clinit >() 方法与类的构造函数不同,类构造器不需要显式的调用父类构造器,虚拟机会保证在子类的类构造器调用之前,父类的类构造器已经执行完毕。因此在虚拟机第一个被执行类构造器的类肯定是 Object。
  • 父类的 < clinit >() 方法先执行,即父类中定义的静态语句块要优于子类的变量赋值操作。
  • < clinit >() 方法不是必须的,如果一个类中没有静态代码块或对类变量的赋值操作,那么编译器可以不为这个类生成 < clinit >()
  • 接口中不能使用静态代码块,但仍然有类变量初始化的赋值操作,所以接口也有 < clinit >() 方法。但跟类不同的是,接口的 < clinit >() 方法不需要先执行于其父接口的 < clinit >() 方法。只有当父接口中定义的变量被使用时,父接口才会初始化。接口的实现类在初始化时也不会执行接口的 < clinit >() 方法。
  • 虚拟机会保证一个类的 < clinit >() 方法在多线程中能被正确的加锁,同步。如果多个线程初始化一个类,那么只会有一个线程去执行这个类的 < clinit >() 方法,其他线程都需要阻塞等待。

静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。

 

类加载器与双亲委派模型


类加载器

把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

对于任何一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。即比较两个类是否“相等”(Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法,instanceof 等),只有在这两个类是由同一个类加载器加载的前提下才有意义。

从虚拟机角度讲,只存在两种不同的类加载器:

一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现(HotSpot 虚拟机),是虚拟机的一部分;
另一种是所有其他的类加载器,这些类加载器都由 Java 语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。

从开发人员的角度看,存在以下 3 种类加载器:

  • 启动类加载器(Bootstrap ClassLoader):负责将存放在< HAVA_HOME >\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的类库(如rt.jar)加载到虚拟机内存中。
  • 扩展类加载器(Extension ClassLoader):负责加载 < JAVA_HOME >\lib\ext 目录中的,或者被 java.ext.dirs 系统变量指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader):负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果用户没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

除此之外,还有自定义类加载器(User ClassLoader),指的是继承 java.lang.ClassLoader 类自定义类加载器。


双亲委派模型

在这里插入图片描述
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围内没有找到所需的类)时,子加载器才会尝试自己去加载。

双亲委派模型的好处

  • 有效保证了 Java 程序的稳定运行。Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object,存放在 rt.jar 中,无论哪一个类启动器要加载这个类,最终都是委派给处于模型顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。
  • 避免自己编写的类动态替换 java 的核心类,如果用户自己编写了一个称为 java.lang.Object 的类,永远不会被加载运行。
  • 避免了类的重复加载,因为 JVM 区分不同类的方式不仅仅根据类名,相同的 class 文件被不同的类加载器加载产生的是两个不同的类。

双亲委派模型并不是一个强制性的约束模型,而是 Java 设计者推荐给开发者的类加载器实现方式。


破坏双亲委派模型

双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载)。但是在有的情况下,基础类又需要调用回用户的代码。

Java 核心类(rt.jar)中提供的外部服务,可由应用层自行实现的接口,通常称为 Service Provider Interface,即 SPI。Java 提供了很多 SPI,允许第三方为这些接口提供实现,最常见的 SPI 实现有 JDBC、JNDI 等等,根据类加载器的双亲委派模型,Bootstrap ClassLoader 是不能加载 SPI 的实现类的,因为这些实现类根本就不在它的负责范围内(不在 rt.jar 中),SPI 的实现类只能由 Application ClassLoader 加载。

线程上下文类加载器正好解决了这个问题,默认情况下,Java应用的线程上下文类加载器默认是Application ClassLoader,这样ServiceLoader 就可以成功加载 SPI 的实现类了。

这种行为打破了双亲委派魔性的层次结构来逆向使用类加载器,简单来说,就是应用程序类加载器加载了本来应该由启动类加载器加载的类。


自定义类加载器

应用场景

加密:如果你不想自己的代码被反编译的话。(类加密后就不能再用ClassLoader进行加载了,这时需要自定义一个类加载器先对类进行解密,再加载)。

从非标准的来源加载代码:如果你的字节码存放在数据库甚至是云端,就需要自定义类加载器,从指定来源加载类。

实现

ClassLoad 类的 loadClass 类(简化版)如下所示:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 检查类是否已经被加载
            Class<?> c = findLoadedClass(name);
            // 还没被加载
            if (c == null) {
                try {
                    // 委托父类加载
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                }

                if (c == null) {
                    // 子类加载器加载
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
    
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }

可以看出确实是先委托给父类加载器,然后子加载器才尝试自己加载。自定义的类加载器只需要继承 ClassLoader,并覆盖 findClass 方法。如果想要破坏双亲委派原则,需要继承 ClassLoader,并覆盖 loadClass 方法。

 

Tomcat 类加载器


容器需要解决的问题:

  • 隔离:类的唯一性是通过类加载器+全限定名完成的,不同的 WEB 项目难免会出现相同全限定名的类,如果使用 JVM 的默认类加载器,那么无法将这两个同名的类区分开;WEB 容器也有自己依赖的类库,不能和应用程序的类库混淆;容器还需要支持 jsp 修改后不用重启。
  • 共享:部署在同一个容器类的项目可以共享相同版本的相同类库,如果每一个项目都各自加载一份类库到虚拟机,将会极大地耗费资源。

Tomcat 容器的类加载机制结构图如下所示:

在这里插入图片描述

其中 WebApp 类加载器和 Jsp 类加载器通常会存在多个实例,每一个 Web 应用程序对应一个 WebApp 类加载器,每一个 JSP 文件对应一个 Jsp 类加载器。

CommonLoader:Tomcat 最基本的类加载器,加载路径中的 class 可以被 Tomcat 容器本身以及各个 Webapp 访问;
CatalinaLoader:Tomcat 容器自身的私有的类加载器,加载路径中的 class 对于 Webapp不可见;
SharedLoader:各个 Webapp 共享的类加载器,加载路径中的 class 对于所有 Webapp 可见,但是对于 Tomcat 容器不可见;
WebappClassLoader:各个 Webapp 私有的类加载器,加载路径中的 class 只对当前 Webapp 可见;

CommonClassLoader 能加载的类都可以被 Catalina ClassLoader 和SharedClassLoader 使用,从而实现了公有类库的共用,而 CatalinaClassLoader 和 Shared ClassLoader 自己能加载的类则与对方相互隔离。

WebAppClassLoader 可以使用 SharedClassLoader 加载到的类,但各个WebAppClassLoader 实例之间相互隔离。

而 JasperLoader 的加载范围仅仅是这个 JSP 文件所编译出来的那一个 .Class 文件,它出现的目的就是为了被丢弃:当 Web 容器检测到 JSP 文件被修改时,会替换掉目前的 JasperLoader 的实例,并通过再建立一个新的 Jsp 类加载器来实现 JSP 文件的 HotSwap 功能。

HotSwap 指的是只替换被修改过的类,开发时使用,因为开发中需要频繁的调试代码。如果每次修改都需要手动重启项目那会严重影响开发效率,我们希望的是在修改后能够只重新加载被修改过的文件,其他没有修改的不懂。

很明显 Tomcat 违背了双亲委派模型,每个 WebApp 类加载器直接加载自己项目的类文件,不会委托给父加载器。

共享资源(如 Spring)由 SharedClassLoader 进行加载,而加载用户程序的类加载器是 WebAppClassLoader,Spring 容器需要访问到用户程序中的类。此时需要用到线程上下文类加载器,可以让父类加载器请求子类加载器去完成类加载的动作,这也会打破双亲委派模型的层次结构来逆向使用类加载器。通过 Spring 使用线程上下文加载器来加载类,而线程上下文加载器默认设置为WebAppClassLoader,那么这时是哪一个Web应用调用了 Spring,Spring 就会用该应用的 WebAppClassLoader 来加载需要的 bean。

 

参考


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值