JVM 整理(二) 类加载器

在Java虚拟机的世界里,类加载器(ClassLoader)扮演着"搬运工"的角色,它负责将编译好的.class字节码文件加载到内存中,为后续的执行引擎提供"原料"。理解类加载器的工作原理,是掌握JVM底层机制的关键一环。本文将带你全面了解类加载器的概念、类的加载过程、类加载器的分类以及著名的双亲委派模型。

1. 类加载器的基本概念

类加载器是JVM的一个子系统,它的核心任务是根据一个类的全限定名来读取对应的.class文件(可以是文件系统、网络、数据库等来源),将其转换为JVM内部的数据结构,并在方法区中生成一个代表该类的java.lang.Class对象,作为访问该类信息的入口。

值得注意的是,类加载器只负责加载,至于这个类能否被正确执行,则由执行引擎(Execution Engine)来决定。

类加载器遵循"懒加载"原则,即只有在程序运行中真正需要使用某个类时,才会触发该类的加载过程。

2. 类的加载过程(生命周期)

一个类从被加载到虚拟机内存开始,直到卸载出内存为止,它的整个生命周期包括七个阶段:加载、验证、准备、解析、初始化、使用、卸载 。其中,验证、准备、解析合称为链接。下面重点介绍前五个阶段。

2.1 加载(Loading)

加载是类加载的第一个阶段,虚拟机需要完成三件事:

  • 通过一个类的全限定名获取定义此类的二进制字节流(可以从ZIP包、网络、运行时计算生成、JSP文件等获取)。

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

  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

这个Class对象相当于是类在方法区中的"镜像",通过它可以获取类的所有元数据信息。

2.2 链接(Linking)

链接阶段的主要任务是将加载阶段得到的二进制字节流整合到JVM的运行时状态中,包括三个子步骤:

  • 验证(Verification):确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。例如,检查文件格式、元数据验证、字节码验证等。

  • 准备(Preparation) :为类的静态变量分配内存,并将其初始化为默认值(零值)。注意:

    • 这里的分配内存仅包括静态变量(static修饰),不包括实例变量。实例变量将在对象实例化时随对象一起分配到Java堆中。

    • 对于final修饰的静态常量,在准备阶段就会直接完成赋值(即显式初始值),而不是默认值。

  • 解析(Resolution) :将常量池内的符号引用 替换为直接引用。

    • 符号引用:以一组符号描述所引用的目标,可以是任何形式的字面量,只要能够无歧义地定位到目标即可。

    • 直接引用:可以是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用的目标必须已经在内存中存在。

2.3 初始化(Initialization)

初始化阶段是类加载的最后一个步骤,它真正开始执行类中定义的Java程序代码。在初始化阶段,JVM会执行类的<clinit>()方法,该方法由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而成。

初始化是程序员对类变量进行显式赋值的过程,例如:

java 复制代码
public class MyClass {
    static int a = 10;          // 在初始化阶段执行赋值
    static {                     // 静态代码块在初始化阶段执行
        a = 20;
    }
}

初始化阶段触发的时机包括:

  • 遇到new、getstatic、putstatic、invokestatic这四条字节码指令时,如果类未初始化则触发。

  • 使用java.lang.reflect包的方法对类进行反射调用时。

  • 当初始化一个类时,如果其父类尚未初始化,则先触发父类初始化。

  • 虚拟机启动时,包含main()方法的主类会首先被初始化。

  • 当使用JDK 7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,且该类未初始化则触发。

3. 类加载器的作用与层次结构

类加载器的主要作用就是实现上述的"加载"阶段。加载完成后,类的信息被存放到方法区,并在堆中生成对应的Class对象。我们可以通过Class对象的getClassLoader()方法获取加载它的类加载器。

类加载器之间存在着逻辑上的父子关系,但这种父子关系并不是通过继承实现的,而是通过组合关系来维护。

3.1 查看类加载器的层级

下面这段代码可以打印出当前类的类加载器及其父加载器:

java 复制代码
public class ClassLoaderDemo {
    public static void main(String[] args) {
        ClassLoaderDemo demo = new ClassLoaderDemo();
        
        // 应用程序类加载器
        System.out.println(demo.getClass().getClassLoader()); 
        System.out.println(ClassLoader.getSystemClassLoader()); 
        
        // 平台类加载器(扩展类加载器)
        System.out.println(demo.getClass().getClassLoader().getParent()); 
        
        // 启动类加载器(由于是C++实现,Java中返回null)
        System.out.println(demo.getClass().getClassLoader().getParent().getParent()); 
        
        // String类由启动类加载器加载,因此getClassLoader()返回null
        String s = new String();
        System.out.println(s.getClass().getClassLoader()); 
    }
}

运行结果类似:

java 复制代码
jdk.internal.loader.ClassLoaders$AppClassLoader@...
jdk.internal.loader.ClassLoaders$AppClassLoader@...
jdk.internal.loader.ClassLoaders$PlatformClassLoader@...
null
null

4. 类加载器的分类

从Java虚拟机的角度讲,只存在两种不同的类加载器:启动类加载器(Bootstrap ClassLoader) 和 其他所有类加载器。但为了方便描述,我们通常将类加载器分为以下四种:

4.1 启动类加载器(Bootstrap ClassLoader)

  • 实现:由C++语言实现,是虚拟机自身的一部分。

  • 职责 :负责加载存放在<JAVA_HOME>\lib目录中的核心类库,或被-Xbootclasspath参数指定的路径中的类库。例如rt.jar(Java 9之前)、java.base模块(Java 9及之后)等。

  • 特点 :由于是C++实现,在Java代码中获取启动类加载器的引用时,会得到null。

4.2 扩展类加载器(Extension ClassLoader)/ 平台类加载器(Platform ClassLoader)

在JDK 9之前,称为扩展类加载器(Extension ClassLoader),由sun.misc.Launcher$ExtClassLoader实现,负责加载<JAVA_HOME>\lib\ext目录下的类库,或由-Djava.ext.dirs指定路径的类库。

从JDK 9开始,Java引入了模块化系统(Project Jigsaw),原来的扩展类加载器被重新设计为平台类加载器(Platform ClassLoader) ,负责加载一些平台相关的模块,例如java.scripting、java.compiler等。

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

也称为系统类加载器(System ClassLoader),由sun.misc.Launcher$AppClassLoader(JDK 8)或jdk.internal.loader.ClassLoaders$AppClassLoader(JDK 9+)实现。它负责加载用户类路径(ClassPath)上指定的类库,即我们开发中编写的绝大多数类都是由它加载的。可以通过ClassLoader.getSystemClassLoader()方法获取它。

4.4 自定义类加载器

如果上述三种加载器不能满足需求,开发者还可以自定义类加载器,通过继承java.lang.ClassLoader类并重写findClass()方法来实现。自定义类加载器可以用于加载非标准路径下的类、实现类版本控制、加密解密等高级功能。

5. 双亲委派模型(Parent Delegation Model)

双亲委派模型是Java类加载器工作的重要机制,它保证了Java平台的稳定性和安全性。

5.1 什么是双亲委派模型?

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

这个过程可以概括为:从下往上委托,至上而下尝试加载。

5.2 双亲委派模型的实现

在java.lang.ClassLoader的loadClass()方法中,已经实现了双亲委派模型的逻辑:

java 复制代码
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) {
                // 如果父类加载器抛出ClassNotFoundException,说明父加载器无法完成加载
            }

            if (c == null) {
                // 如果父加载器加载失败,则调用自己的findClass方法尝试加载
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

5.3 为什么要使用双亲委派模型?

  • 避免类的重复加载:父加载器已经加载过的类,子加载器不会再重复加载,保证了同一个类只被加载一次。

  • 保证核心类库的安全 :防止用户自定义的类覆盖核心API。例如,如果用户编写了一个名为java.lang.String的类,试图覆盖JDK核心类,由于双亲委派模型,该请求会一直向上委派给启动类加载器,而启动类加载器会加载标准的String类,从而保证核心类库的安全。这就是所谓的沙箱安全机制。

5.4 双亲委派模型的破坏

在某些场景下,双亲委派模型可能被打破,例如:

  • 线程上下文类加载器:JDBC、JNDI等服务提供者接口(SPI)的加载,通常使用线程上下文类加载器来加载第三方实现类。

  • 热部署/热替换:如Tomcat等Web容器,每个Web应用使用独立的类加载器,可以加载不同版本的类,而不影响其他应用。

  • OSGi模块化系统:每个模块有自己的类加载器,加载规则更加灵活。

6. 总结

类加载器是JVM的重要组成部分,它负责将字节码文件加载到内存中,为程序的运行提供基础。理解类的加载过程、类加载器的层次结构以及双亲委派模型,不仅有助于我们诊断类冲突、NoClassDefFoundError等问题,还能为编写自定义类加载器、实现热部署等高级功能打下基础。

Java的类加载机制在设计上兼顾了性能、安全性和灵活性,是Java平台稳定运行的基石之一。希望本文能帮助你更深入地理解这一机制,在未来的开发中游刃有余。

相关推荐
Li_RuiQi10 小时前
DAgger方法演进_独立学习笔记
笔记·学习·计算机视觉
Li_RuiQi15 小时前
DAgger与交互式模仿学习_ACT学习笔记
笔记·学习
He BianGu15 小时前
【笔记】C# 中BindingFlags的详细作用
笔记·c#
AI视觉网奇15 小时前
turtle 入门教程笔记
笔记
跨知易知识产权16 小时前
美国专利确权是什么,和专利诉讼有什么不一样?
笔记
杨逢昌工厂6S管理16 小时前
126-杨逢昌6S三环体系:越做越乱的现场熵增机理与基准化治理——一线现场诊断笔记
经验分享·笔记·职场和发展·学习方法
悟道书生16 小时前
若依 RBAC 权限控制学习笔记
java·网络·笔记·sql·学习·vue
陈哈哈111116 小时前
折腾了半天,总算把 Vim 9.2 装明白了
笔记
陈哈哈111116 小时前
折腾了半天,总算把 OpenGL 的开发环境给装明白了
笔记
深圳老胡17 小时前
STM32F103 硬件 SPI 与软件 IO 模拟 SPI 最高速度差异详解
笔记·stm32·单片机·代码规范·1024程序员节