MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java类加载机制与双亲委派模型

2022-04-165.9k 阅读

Java 类加载机制概述

在 Java 中,类加载机制是将字节码文件加载到内存,并将其转化为可以被 JVM 直接使用的 Java 类型的过程。这一机制保证了 Java 程序的动态性和可扩展性,使得类可以按需加载,而不是在程序启动时一次性加载所有类。

类加载过程主要分为三个阶段:加载(Loading)、连接(Linking)和初始化(Initialization)。

加载(Loading)

加载是类加载的第一个阶段,该阶段负责查找并加载类的二进制字节流。字节流的来源可以是本地文件系统、网络、数据库等。在这个阶段,JVM 会根据类的全限定名来获取定义此类的二进制字节流,并将其转化为一个 java.lang.Class 的实例。

例如,我们有一个简单的 Java 类 HelloWorld

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}

当 JVM 要加载 HelloWorld 类时,首先会根据类的全限定名 com.example.HelloWorld(假设在 com.example 包下)去寻找对应的字节码文件 HelloWorld.class。找到后,将字节流读入内存,并创建一个 Class 对象来表示这个类。

连接(Linking)

连接阶段负责将已加载的类的二进制数据合并到 JVM 的运行时状态中。它又细分为三个步骤:验证(Verification)、准备(Preparation)和解析(Resolution)。

  1. 验证(Verification):确保被加载类的字节流符合 JVM 规范,不会危害 JVM 自身安全。例如,检查字节码文件格式是否正确、是否有正确的魔数(Magic Number)、常量池中的常量是否有正确的类型等。如果验证不通过,JVM 会抛出 VerifyError 异常。
  2. 准备(Preparation):为类的静态变量分配内存,并设置默认初始值。例如,对于 public static int num = 10;,在准备阶段,会为 num 分配内存,并将其初始值设为 0(默认值)。这里需要注意的是,只有静态变量会在准备阶段分配内存,实例变量是在创建对象时才会分配内存。
  3. 解析(Resolution):将类、接口、字段和方法的符号引用转化为直接引用。符号引用是以一组符号来描述所引用的目标,而直接引用是可以直接指向目标的指针、相对偏移量或句柄。例如,在字节码中对其他类的方法调用可能是以符号引用的形式存在,在解析阶段会将其转化为直接引用,以便在运行时能够快速定位到目标方法。

初始化(Initialization)

初始化阶段是类加载的最后一步,在这个阶段,JVM 会执行类的静态初始化块以及为静态变量赋值的语句。例如,对于上述 HelloWorld 类中的静态变量 num,在初始化阶段会将其赋值为 10。

静态初始化块只会在类第一次被使用时执行一次,且是线程安全的。例如:

public class StaticInitialization {
    static {
        System.out.println("Static initialization block is executed.");
    }
    public static void main(String[] args) {
        System.out.println("Main method is executed.");
    }
}

当运行 StaticInitialization 类时,首先会执行静态初始化块中的代码,输出 Static initialization block is executed.,然后执行 main 方法中的代码,输出 Main method is executed.

类加载器

类加载器是负责加载类的组件,在 Java 中,有不同类型的类加载器,它们共同构成了类加载器层次结构。

启动类加载器(Bootstrap ClassLoader)

启动类加载器是最顶层的类加载器,它是由 C++ 实现的,负责加载 JVM 运行时核心类库,比如 java.lang 包下的类。这些类是 JVM 正常运行所必需的,存放在 $JAVA_HOME/jre/lib 目录下,或者由 -Xbootclasspath 参数指定的路径中。启动类加载器无法被 Java 程序直接访问。

扩展类加载器(Extension ClassLoader)

扩展类加载器由 Java 实现,继承自 ClassLoader 类,负责加载 $JAVA_HOME/jre/lib/ext 目录下的类库,或者由 java.ext.dirs 系统属性指定的路径中的类库。这些类库通常是对 Java 核心类库的扩展,比如一些安全相关的扩展类。

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

应用程序类加载器也由 Java 实现,同样继承自 ClassLoader 类,它负责加载应用程序的类路径(classpath)下的类。通常我们自己编写的代码以及使用的第三方库都是由应用程序类加载器加载的。它可以通过 ClassLoader.getSystemClassLoader() 方法获取。

用户自定义类加载器

除了上述系统提供的类加载器外,开发者还可以自定义类加载器。自定义类加载器通常用于加载特定来源的类,比如从网络、数据库等加载类。自定义类加载器需要继承自 ClassLoader 类,并实现 findClass 方法。例如:

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

public class CustomClassLoader extends ClassLoader {
    private String classPath;

    public CustomClassLoader(String classPath) {
        this.classPath = classPath;
    }

    private byte[] loadByte(String name) throws IOException {
        name = name.replaceAll("\\.", "/");
        FileInputStream fis = new FileInputStream(new File(classPath + "/" + name + ".class"));
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        int b;
        while ((b = fis.read()) != -1) {
            bos.write(b);
        }
        fis.close();
        return bos.toByteArray();
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            byte[] data = loadByte(name);
            return defineClass(name, data, 0, data.length);
        } catch (IOException e) {
            e.printStackTrace();
            throw new ClassNotFoundException();
        }
    }
}

上述代码定义了一个简单的自定义类加载器 CustomClassLoader,它从指定的路径加载类。使用时可以这样:

public class CustomClassLoaderTest {
    public static void main(String[] args) throws Exception {
        CustomClassLoader classLoader = new CustomClassLoader("/path/to/classes");
        Class<?> clazz = classLoader.loadClass("com.example.MyClass");
        Object obj = clazz.newInstance();
        // 调用 obj 的方法
    }
}

这里假设 com.example.MyClass 类位于 /path/to/classes 路径下。

双亲委派模型

双亲委派模型是 Java 类加载机制的核心设计模式。在这种模型下,除了启动类加载器外,每个类加载器都有一个父类加载器。当一个类加载器收到类加载请求时,它不会立即尝试加载该类,而是先将请求委派给父类加载器,只有当父类加载器无法加载该类时,子类加载器才会尝试自己加载。

双亲委派模型的工作流程

  1. Application ClassLoader 收到加载类的请求时,它首先将请求转发给 Extension ClassLoader
  2. Extension ClassLoader 接着将请求转发给 Bootstrap ClassLoader
  3. Bootstrap ClassLoader 尝试加载类,如果加载成功,则返回对应的 Class 对象;如果加载失败,Extension ClassLoader 会尝试自己加载。
  4. 如果 Extension ClassLoader 也加载失败,Application ClassLoader 才会尝试自己加载。

双亲委派模型的优势

  1. 避免类的重复加载:由于双亲委派模型的存在,使得类只会被加载一次。例如,java.lang.Object 类由 Bootstrap ClassLoader 加载,如果没有双亲委派模型,可能会出现不同的类加载器多次加载 Object 类的情况,从而导致内存中存在多个 Object 类的实例,引发混乱。
  2. 保证安全性:核心类库由 Bootstrap ClassLoader 加载,其他类加载器无法替换核心类库中的类。这就保证了 Java 程序的安全性,防止恶意代码替换核心类库中的关键类,比如 java.lang.String 类。如果没有双亲委派模型,恶意代码可能会自定义一个 String 类,覆盖系统的 String 类,从而造成安全隐患。

打破双亲委派模型

虽然双亲委派模型有诸多优点,但在某些特殊场景下,需要打破这种模型。例如,在实现热部署、类隔离等功能时,可能需要自定义类加载器不遵循双亲委派模型。

在 Java 中,Tomcat 就是一个打破双亲委派模型的典型例子。Tomcat 作为一个 Web 容器,需要支持多个 Web 应用程序,每个应用程序可能使用不同版本的同一个类库。如果遵循双亲委派模型,同一个类库只能被 Application ClassLoader 加载一次,无法满足不同应用程序对类库版本的不同需求。

Tomcat 通过自定义类加载器机制,使得每个 Web 应用程序都有自己独立的类加载器,这些类加载器优先加载应用程序自己的类和类库,而不是委派给父类加载器。这样就实现了类隔离,不同应用程序之间的类库不会相互干扰。

下面以一个简单的示例来展示如何打破双亲委派模型:

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

public class BreakParentDelegationClassLoader extends ClassLoader {
    private String classPath;

    public BreakParentDelegationClassLoader(String classPath) {
        this.classPath = classPath;
    }

    private byte[] loadByte(String name) throws IOException {
        name = name.replaceAll("\\.", "/");
        FileInputStream fis = new FileInputStream(new File(classPath + "/" + name + ".class"));
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        int b;
        while ((b = fis.read()) != -1) {
            bos.write(b);
        }
        fis.close();
        return bos.toByteArray();
    }

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // 首先检查该类是否已经被加载
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (!name.startsWith("java.")) {
                        byte[] data = loadByte(name);
                        c = defineClass(name, data, 0, data.length);
                    } else {
                        c = getParent().loadClass(name);
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                    throw new ClassNotFoundException();
                }
            }
            return c;
        }
    }
}

在上述代码中,BreakParentDelegationClassLoader 重写了 loadClass 方法,当加载的类不是以 java. 开头时,优先自己加载,而不是委派给父类加载器,从而打破了双亲委派模型。使用时可以这样:

public class BreakParentDelegationTest {
    public static void main(String[] args) throws Exception {
        BreakParentDelegationClassLoader classLoader = new BreakParentDelegationClassLoader("/path/to/classes");
        Class<?> clazz = classLoader.loadClass("com.example.MyClass");
        Object obj = clazz.newInstance();
        // 调用 obj 的方法
    }
}

这里假设 com.example.MyClass 类位于 /path/to/classes 路径下。

类加载机制与双亲委派模型的应用场景

  1. 热部署:在一些开发和运维场景中,希望在不重启应用程序的情况下更新部分代码。通过自定义类加载器,结合类加载机制,可以实现热部署功能。例如,当检测到代码有更新时,使用新的类加载器加载更新后的类,替换掉旧的类实例,从而实现应用程序的动态更新。
  2. 插件化开发:在插件化系统中,每个插件可能有自己独立的类库和代码。通过自定义类加载器,可以为每个插件创建独立的类加载空间,实现插件之间的类隔离。同时,利用双亲委派模型的优势,保证核心类库的一致性和安全性。
  3. 模块化开发:随着应用程序规模的不断增大,模块化开发变得越来越重要。类加载机制和双亲委派模型可以帮助实现模块之间的隔离和依赖管理。不同模块可以由不同的类加载器加载,模块之间的类相互隔离,避免了类名冲突等问题。

总结

Java 类加载机制与双亲委派模型是 Java 技术体系中的重要组成部分,它们保证了 Java 程序的动态性、可扩展性和安全性。通过深入理解类加载的各个阶段、类加载器的类型和层次结构以及双亲委派模型的工作原理,开发者可以更好地优化和定制 Java 应用程序的类加载过程,实现诸如热部署、类隔离等高级功能。同时,合理利用类加载机制和双亲委派模型,也有助于提高应用程序的性能和稳定性,避免因类加载问题导致的各种异常和错误。在实际开发中,我们应根据具体的业务需求,灵活运用这些知识,打造出更加健壮和高效的 Java 应用程序。无论是开发小型的桌面应用,还是大型的分布式系统,对类加载机制和双亲委派模型的深入理解都将为开发者提供有力的支持。