Java `Class Loading` 机制:`Bootstrap`, `Extension`, `Application` `ClassLoaders` 与双亲委派模型

各位观众,欢迎来到今天的“Java类加载奥秘大揭秘”讲座!我是你们今天的导游,将带领大家深入探索Java虚拟机(JVM)中神秘的类加载机制。准备好了吗?让我们一起揭开它的面纱!

一、 类加载机制:Java程序的灵魂引擎

想象一下,Java程序就像一栋大楼,而类就是构成这栋大楼的砖块。但是,这些砖块一开始并不在工地上,而是藏在各种各样的文件里(.class文件)。类加载机制,就是负责把这些砖块从文件里搬运到工地上,并且按照一定的规则组装成大楼(运行时的Java程序)。

简单来说,类加载机制就是JVM动态加载class文件到内存,并进行校验、准备、解析和初始化的过程。这个过程让Java拥有了动态性,可以按需加载类,而不是一次性加载所有类,大大提高了程序的灵活性和效率。

二、 类加载器:搬运砖块的工人

既然类加载机制负责搬运砖块,那么谁来执行具体的搬运工作呢?答案就是类加载器(ClassLoader)。类加载器就像一群辛勤的工人,他们负责查找并加载类文件,并将类定义信息加载到JVM中。

Java提供了多种类加载器,它们各司其职,共同完成类加载任务。其中,最核心的是以下三种:

  1. 启动类加载器 (Bootstrap ClassLoader):这是JVM中最特殊的类加载器,它是用C++实现的,而不是Java代码。它负责加载JVM自身需要的核心类库,比如java.lang.*等。你可以把它想象成总指挥,负责最关键的砖块搬运工作。

  2. 扩展类加载器 (Extension ClassLoader):它由Java代码实现,负责加载jre/lib/ext目录下的类库。你可以把它想象成项目经理,负责加载一些扩展的工具类。

  3. 应用程序类加载器 (Application ClassLoader):它也是由Java代码实现,负责加载应用程序classpath下的类。classpath可以通过-classpath参数指定,也可以通过环境变量CLASSPATH设置。它是我们最常用的类加载器,可以把它想象成普通工人,负责加载我们自己编写的类。

这三个类加载器之间存在着一种特殊的层级关系,我们稍后会详细讲解。

三、 双亲委派模型:砖块搬运的秩序

现在我们已经有了搬运砖块的工人,但是如果没有一个明确的秩序,就会出现混乱,比如不同工人搬运同一块砖块,或者工人搬运了错误的砖块。为了避免这种情况,Java引入了双亲委派模型(Parent Delegation Model)。

双亲委派模型规定,当一个类加载器收到类加载请求时,它不会立即尝试加载,而是先将这个请求委派给它的父类加载器。父类加载器如果能够加载,就直接返回加载结果;如果父类加载器无法加载,才会由子类加载器自己尝试加载。

这个过程就像一个汇报机制:

  1. 应用程序类加载器接到加载类的请求。
  2. 它会先问扩展类加载器:“这个类你能加载吗?”
  3. 扩展类加载器又会问启动类加载器:“这个类你能加载吗?”
  4. 如果启动类加载器能加载,就直接加载并返回结果。
  5. 如果启动类加载器不能加载,扩展类加载器才会尝试加载。
  6. 如果扩展类加载器也不能加载,应用程序类加载器才会尝试加载。

用表格来总结一下:

类加载器 父类加载器 负责加载
启动类加载器 (Bootstrap) 无 (null) JVM自身需要的核心类库,例如 java.lang.*, java.util.* 等。
扩展类加载器 (Extension) 启动类加载器 (Bootstrap) jre/lib/ext 目录下的类库。
应用程序类加载器 (Application) 扩展类加载器 (Extension) 应用程序classpath下的类。

双亲委派模型的优点:

  • 安全性: 避免恶意代码替换核心类库,比如你写了一个java.lang.String类,由于启动类加载器优先加载,你的类不会被加载,保证了核心类库的安全。
  • 避免重复加载: 保证一个类只会被加载一次,避免了类的重复定义。

双亲委派模型的缺点:

  • 灵活性不足: 某些情况下,父类加载器无法加载子类加载器需要加载的类,比如JDBC驱动的加载。

打破双亲委派模型:

虽然双亲委派模型有很多优点,但在某些特殊情况下,我们需要打破它。比如:

  • JDBC驱动加载: JDBC驱动的接口定义在java.sql包中,由启动类加载器加载。但是具体的驱动实现是由各个数据库厂商提供的,这些驱动程序通常放在classpath下,由应用程序类加载器加载。如果按照双亲委派模型,应用程序类加载器会将加载请求委派给启动类加载器,而启动类加载器无法加载这些驱动程序。

为了解决这个问题,Java引入了线程上下文类加载器(Thread Context ClassLoader)。线程上下文类加载器可以通过Thread.currentThread().setContextClassLoader()方法设置,并且可以让子类加载器访问父类加载器无法访问的类。

在JDBC驱动加载的例子中,JDBC驱动会通过线程上下文类加载器加载具体的驱动实现。

四、 类加载过程:砖块变成大楼的步骤

类加载过程并非一蹴而就,而是包含多个阶段:

  1. 加载 (Loading):查找并加载类的二进制数据(.class文件),创建Class对象。
  2. 验证 (Verification):确保Class文件的字节流包含的信息符合JVM规范,没有安全问题。
  3. 准备 (Preparation):为类的静态变量分配内存,并设置默认初始值(例如,int类型的静态变量默认值为0)。
  4. 解析 (Resolution):将常量池中的符号引用替换为直接引用。
  5. 初始化 (Initialization):执行类的静态初始化器(static {} 代码块)和静态变量赋值语句。

用表格来总结一下:

阶段 描述 涉及操作
加载 查找 .class 文件,并将其内容读取到内存中。创建代表这个类的 java.lang.Class 对象。 通过类的全限定名获取定义此类的二进制字节流。(可以通过多种方式获取,如从 .class 文件读取、从网络获取、动态生成等。)
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
* 在Java堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口。
验证 确保加载的类符合 JVM 规范,并且不会危害 JVM 的安全。 文件格式验证: 验证字节流是否符合 .class 文件格式规范。
元数据验证: 验证类的信息是否符合规范,例如是否有父类,是否实现了接口等。
字节码验证: 验证字节码指令是否合法,例如指令的操作数类型是否正确等。
符号引用验证: 验证符号引用是否能被正确解析。
准备 为类的静态变量分配内存,并设置默认初始值。 为类的静态变量分配内存。
将静态变量设置为默认初始值(例如,int 类型设置为 0boolean 类型设置为 false,对象类型设置为 null)。注意,这里仅仅是设置默认初始值,而不是程序员在代码中指定的初始值。 例如,static int value = 123; 在准备阶段,value 的值会被设置为 0,而不是 123123 的赋值会在初始化阶段进行。
解析 将常量池中的符号引用替换为直接引用。 符号引用: 用一组符号来描述所引用的目标。符号可以是任何形式的字面量,只要能无歧义地定位到目标即可。例如,在编译时,一个类可能引用了另一个类的方法,但此时并不知道这个方法的具体内存地址,只是用一个符号(例如方法名和参数类型)来表示。
直接引用: 指向目标的指针、偏移量或者句柄,可以用来直接访问目标。例如,在解析阶段,会将符号引用替换为方法在内存中的实际地址。
初始化 执行类的静态初始化器(static {} 代码块)和静态变量赋值语句。 执行类构造器 <clinit>() 方法。<clinit>() 方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块 (static {}) 中的语句合并产生的。
如果类有父类,JVM会首先初始化父类。
* JVM会保证一个类的 <clinit>() 方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行 <clinit>() 方法完毕。

五、 代码示例:让我们来点实际的

光说不练假把式,让我们通过一些代码示例来加深理解。

public class MyClass {
    private static int count = 0;

    static {
        System.out.println("MyClass is initializing...");
        count = 10;
    }

    public MyClass() {
        System.out.println("MyClass is constructed...");
    }

    public static void main(String[] args) {
        System.out.println("Main method is running...");
        MyClass myClass = new MyClass();
        System.out.println("Count: " + count);
    }
}

当我们运行这段代码时,会看到以下输出:

Main method is running...
MyClass is initializing...
MyClass is constructed...
Count: 10

这个例子展示了类加载的初始化阶段,静态初始化器会在类被加载时执行,并且只执行一次。

六、 自定义类加载器:打造专属的砖块搬运工

Java允许我们自定义类加载器,这为我们提供了更大的灵活性。自定义类加载器可以用于:

  • 从特殊位置加载类,比如从网络、数据库或加密文件中加载类。
  • 对加载的类进行特殊处理,比如对类进行解密或修改。
  • 隔离不同的应用程序,避免类冲突。

下面是一个简单的自定义类加载器的例子:

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class MyClassLoader extends ClassLoader {

    private String classPath;

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

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String fileName = name.replace('.', '/') + ".class";
        Path classFilePath = Paths.get(classPath, fileName);

        try {
            byte[] classBytes = Files.readAllBytes(classFilePath);
            if (classBytes == null || classBytes.length == 0) {
                throw new ClassNotFoundException(name);
            }

            return defineClass(name, classBytes, 0, classBytes.length);
        } catch (IOException e) {
            throw new ClassNotFoundException(name, e);
        }
    }

    public static void main(String[] args) throws Exception {
        String classPath = "/path/to/your/classes"; // 修改成你的class文件存放路径
        MyClassLoader classLoader = new MyClassLoader(classPath);
        Class<?> myClass = classLoader.loadClass("com.example.MyClass"); // 假设你的类在com.example包下
        Object instance = myClass.newInstance();
        System.out.println("Class loaded successfully: " + myClass.getName());
    }
}

代码解释:

  1. MyClassLoader 类: 继承自 ClassLoader,需要重写 findClass() 方法。
  2. classPath 字段: 存储 .class 文件所在的目录。
  3. findClass(String name) 方法: 这个方法是自定义类加载器的核心。
    • 它接收类的全限定名作为参数。
    • 它根据全限定名构建 .class 文件的路径。
    • 它尝试从指定路径读取 .class 文件的内容。
    • 如果读取成功,它调用 defineClass() 方法将字节数组转换为 Class 对象。
    • 如果读取失败,它抛出 ClassNotFoundException 异常。
  4. defineClass(String name, byte[] b, int off, int len) 方法: 这个方法是 ClassLoader 类的protected方法,用于将字节数组转换为 Class 对象。
  5. main 方法: 演示如何使用自定义类加载器加载类并创建实例。

使用自定义类加载器的注意事项:

  • 需要重写findClass()方法,而不是loadClass()方法。loadClass()方法会首先尝试使用父类加载器加载类,如果父类加载器无法加载,才会调用findClass()方法。
  • 需要注意类的命名空间隔离,避免类冲突。
  • 需要处理类加载过程中可能出现的异常。

七、 总结:成为类加载大师

通过今天的讲座,我们深入了解了Java的类加载机制,包括:

  • 类加载机制的作用和意义。
  • 三种核心类加载器:启动类加载器、扩展类加载器和应用程序类加载器。
  • 双亲委派模型及其优点和缺点。
  • 打破双亲委派模型的方法。
  • 类加载过程的五个阶段:加载、验证、准备、解析和初始化。
  • 自定义类加载器的使用方法和注意事项。

希望今天的讲座能够帮助大家更好地理解Java的类加载机制,成为真正的Java大师!下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注