揭开 JVM 的神秘面纱:深入解析 JVM 的类加载机制

119 阅读14分钟

一、引言

1. 虚拟机背后的魔法

Java 虚拟机(JVM)就像是舞台上的魔法师,默默地扮演着关键的角色,让我们的代码在各种平台上如临大观。背后的魔法是一系列精密的步骤,其中类加载机制是整个过程的引导者,它负责将我们编写的 Java 代码变成可在虚拟机上运行的字节码。

2. 类加载机制的关键作用

类加载机制负责将 Java 类从编写的源代码转变为可执行的指令流,这个过程包含了加载、连接和初始化。此外,类加载机制还赋予了 Java 语言独特的特性,即在程序运行时动态加载新的类。这为实现热部署和灵活的扩展提供了可能性,使得我们可以在不停止程序的情况下引入新的功能或模块。

二、类加载的基本过程

一个类型从被加载到虚拟机中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。接下来让我们来了解下 JVM 中类加载的全过程,即加载、验证、准备、解析和初始化这五个阶段所执行的动作。

1. 加载 (Loading)

在加载阶段,类加载器负责查找并加载类的字节码文件到内存中,这通常是从文件系统、网络或其他地方获取字节码的过程。此过程是在运行时动态进行的,只有在使用到类的时候才会触发加载操作,例如调用类的 main() 方法、创建类的新对象等。在此阶段,JVM 会在内存中生成一个代表该类的 java.lang.Class 对象,此对象包含了该类的结构信息,如字段、方法、构造函数等,充当了访问方法区中该类各种数据的入口。

通过这个加载过程,Java虚拟机实现了类的按需加载,避免了不必要的资源浪费。每个类的加载都会生成对应的 java.lang.Class 对象,以方便在运行时获取该类的各种信息。

2. 验证(Verification)

检验字节码文件的正确性,确保被加载的类满足 Java 虚拟机规范,不会危害虚拟机自身安全。

3. 准备 (Preparation)

为类的静态变量分配内存空间,并设置默认初始值。这里并不包括实例变量,因为它们在对象实例化时才被分配内存。

4. 解析 (Resolution)

解析阶段是将类、接口、字段和方法的符号引用解析为直接引用的过程。在Java中,符号引用是一种符号化的引用,而直接引用是可以直接使用的内存地址或偏移量。解析阶段的主要目的是将代码中的符号引用转换为直接引用,以便在运行时更容易访问类、字段和方法。

5. 初始化 (Initialization)

在初始化阶段,Java虚拟机执行类的初始化代码,包括执行类的静态初始化器和为静态变量赋值。

三、类加载器详解

上面我们已经简单了解了类加载的一个过程,那么我们用什么工具来加载我们的类?答案就是我们接下来要讲的类加载器。在Java中,类加载器(Class Loader)是 Java 运行时环境的一部分,负责将类的字节码文件加载到 Java 虚拟机中,并将其转换为运行时的 Class 对象。Java 的类加载器体系是一个重要的特性,它支持动态加载和卸载类,提供了灵活性和可扩展性。

1. 类加载器的分类及其作用

1.1. 启动类加载器 (Bootstrap Class Loader)

启动类加载器又名引导类加载器,负责加载支撑 JVM 运行的位于 JRE 的 lib 目录下的核心类库,比如rt.jar、charsets.jar等。

1.2. 扩展类加载器(Extension Class Loader)

负责加载支撑 JVM 运行的位于 JRE 的 lib 目录下的 ext 扩展目录中的 JAR 类包

1.3. 应用程序类加载器(Application Class Loader)

负责加载 ClassPath 路径下的类包,主要就是加载你自己写的那些类,它是通过 ClassLoader 类的 getSystemClassLoader() 方法获取的

1.4. 自定义类加载器

负责加载用户自定义路径下的类包,可以通过继承 ClassLoader ****类创建自定义类加载器,实现特定的类加载行为,如从数据库加载类、网络加载类等

2. 类加载器的初始化过程

在Java 应用程序启动时,会实例化一个很重要的类:sun.misc.Launcher(启动器)(具体它是如何实例化出来的不是我们本文的重点,详细的细节需要去看HotSpot 的底层源码才行),它在构造函数中进行了一系列的初始化操作,包括创建扩展类加载器(ExtClassLoader)和应用程序类加载器(AppClassLoader),然后将应用程序类加载器设置为当前线程的上下文类加载器,所以 JVM 默认使用 Launcher 的 getClassLoader() 方法返回的类加载器 AppClassLoader 的实例来加载我们的应用程序。

public class Launcher {
	// 创建了一个静态的 Launcher 对象,用于获取对启动器的实例的引用
    private static Launcher launcher = new Launcher();
    // 定义了一个类加载器对象,用于加载 Java 类
	private ClassLoader loader;

    public static Launcher getLauncher() {
        return launcher;
    }

    public Launcher() {
        Launcher.ExtClassLoader var1;
        try {
			// 创建扩展类加载器,并将其父加载器设置为 null
            var1 = Launcher.ExtClassLoader.getExtClassLoader();
        } catch (IOException var10) {
            throw new InternalError("Could not create extension class loader", var10);
        }

        try {
			// 创建应用类加载器,并将其父加载器设置为 ExtClassLoader,
             // 设置 Launcher 的 loader 为 AppClassLoader,我们一般都是用这个类加载器来加载我们自己写的应用程序
			this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
        } catch (IOException var9) {
            throw new InternalError("Could not create application class loader", var9);
        }

		public ClassLoader getClassLoader() {
        	return this.loader;
    	}
        ······
    }

四、双亲委派机制详解

1. 什么是双亲委派机制?

双亲委派机制是一种类加载的委派模型(如图1),就是一种类加载机制的实现方式,其核心思想是当一个类加载器(ClassLoader)收到类加载请求时,它首先不会自己尝试加载这个类,而是将加载请求委派给父类加载器去完成。如果父类加载器能够加载该类,就成功返回类的Class对象;如果父类加载器无法加载该类,子类加载器才会尝试加载。

图1.双亲委派机制

2. 为什么要有双亲委派机制?

双亲委派机制的核心是为了保证类的一致性的安全性。

  • 保证类的一致性

其自下而上的委派方式和自上而下的加载方式保证了类的一致性,如加载类 java.lang.Object,不管由哪个类加载器来加载,通过双亲委派机制我们可以保证最终都是委派给启动类加载器来加载,这样每次拿到的都是同一个类。

  • 保证类的安全性

如何保证安全性?我们还是拿核心类 java.lang.Object 来举例,我们现在自己编写一个包名和类名都跟原来的 Object 类保存一致的类,但是里面是我们自己写的一些代码,如果没有双亲委派机制,那么我们可能加载的就是我们自己写的 Object 类,这样就会大大增加引入安全漏洞和恶意代码执行的风险。有了双亲委派机制,优先加载系统已存在的类,自己编写的核心类不会被加载,这样便可以防止核心API库被随意篡改。

3. 双亲委派机制源码解读

接下来带大家简单看一下 ClassLoader 的 loadClass 方法,里面实现了双亲委派机制

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 1. 检查是否已加载该类,已加载直接返回,防止类重复加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
			// 2. 判断是否存在父加载器,存在则委托其加载,否则调用引导类加载器来加载
            try {
                // 存在父类加载器,委托父类加载器加载
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 不存在父类加载器,委托引导类加载器加载该类
					// 通过上面的图1我们可以知道引导类加载器为顶层类加载器,
					// 其本身不存在父类加载器,所以此处直接由它来本身来加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 如果父类加载器不为null且无法加载类,则抛出ClassNotFoundException
            }

            if (c == null) {
                long t1 = System.nanoTime();
				// 3. 父加载器及引导类加载器都没有找到指定的类,则调用当前类加载器的 findClass 方法来完成类加载
				// 最终都会调用 URLClassLoader 的 findClass 方法在加载器的类路径里查找并加载该类
                c = findClass(name);

                // 记录一些统计信息,不用关注
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }

        // 不会执行
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

五、打破双亲委派机制

1. 为什么要打破双亲委派机制?

一般来说我们不推荐去打破双亲委派机制,但是有些时候双亲委派机制可能与我们的实际场景有所冲突,所以我们不得不去打破它,下面是打破双亲委派机制的一些案例(感兴趣的可自行去深入了解):

  • JNDI: JNDI服务引入了线程上下文类加载器,用它去加载所需要的 SPI 代码。
  • Tomcat: 引入 Web应用程序类加载器(WebappClassLoader),每个 Web 应用程序(每个 Context)都有一个独立的类加载器,此 Web 应用程序类加载器在加载类时首先查找自身的类路径,如果找不到,则委托给父类加载器(通常是公共类加载器CommonClassLoader)。这种机制与传统的双亲委派模型有相似之处,但是 Tomcat 的类加载机制允许每个 Web 应用程序拥有独立的类加载器,这样就可以隔离不同的 Web 应用程序,防止它们之间的类冲突。
  • OSGi(Open Service Gateway Initiative): 一个用于构建模块化、可扩展和动态更新的 Java 应用程序的规范。在 OSGi 环境下,实现模块化热部署往往需要更灵活的类加载机制,为了实现模块热替换,通常会涉及到为每个模块自定义类加载器,使得每个模块都拥有独立的类加载空间。当需要更换模块时,不仅仅是模块本身的代码需要被替换,还需要替换相应模块使用的类加载器。这一机制没有完全遵循自下而上的委托,存在平级的类加载器加载行为,打破了双亲委派机制。
  • JDK9 模块系统: 在JDK9中,整个JDK都基于模块化进行构建,以前的 rt.jar, tool.jar 被拆分成数十个模块,编译的时候只编译实际用到的模块,引入了平台类加载器,加载类前优先找当前模块的类加载器,找不到才委派给父级去加载。

2. 怎么打破双亲委派机制?

2.1. 自定义类加载器重写 loadClass 方法

自定义类加载器只需要继承 java.lang.ClassLoader 类,该类有两个核心方法,一个是 loadClass(String, boolean) ,其实现了 双亲委派机制,所以我们重写 loadClass 方法,在加载类时不先委派给父类加载器,而是直接尝试加载,那么就破坏了双亲委派机制;还有一个方法是 findClass,默认实现是空方法,所以如果我们希望自定义的类加载器也实现双亲委派机制的话,那么只需要重写 findClass 方法即可。

下面是一个简单的示例代码,先演示如何使用自定义类加载器来加载我们想要的类:

public class User {
    public void print(String classLoadName) {
        System.out.println("通过类加载器:" + classLoadName + "加载 User 类");
    }
}

public class CustomClassLoaderTest {
    static class CustomClassLoader extends ClassLoader {
        private String classPath;

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

        private byte[] loadByte(String name) throws Exception {
            name = name.replaceAll("\.", "/");
            FileInputStream fis = new FileInputStream(classPath + "/" + name
                                                      + ".class");
            int len = fis.available();
            byte[] data = new byte[len];
            fis.read(data);
            fis.close();
            return data;
        }

        protected Class<?> findClass(String name) throws ClassNotFoundException {
            try {
                byte[] data = loadByte(name);
                // defineClass将一个字节数组转为 Class 对象,这个字节数组是 class 文件读取后最终的字节数组
                return defineClass(name, data, 0, data.length);
            } catch (Exception e) {
                e.printStackTrace();
                throw new ClassNotFoundException();
            }
        }
    }

    public static void main(String args[]) throws Exception {
       // 初始化自定义类加载器,会先初始化父类 ClassLoader,其中会把自定义类加载器的父加载器设置为应用程序类加载器 AppClassLoader
        CustomClassLoader classLoader = new CustomClassLoader("D:\test");
        // 首先在我们类路径下的 com.example.demo 目录下有一个 User 类,
        // 然后在D盘创建 test/com/example/demo 几级目录,将 target 目录下的 User.class 文件复制此目录下
        Class clazz = classLoader.loadClass("com.example.demo.User");
        Object obj = clazz.newInstance();
        Method method = clazz.getDeclaredMethod("print", String.class);
        method.invoke(obj, clazz.getClassLoader().getClass().getName());
        // 运行结果:通过类加载器:sun.misc.Launcher$AppClassLoader加载 User 类
        // 很明显此时用的是 AppClassLoader 来加载,这就是双亲委派机制在起作用了。
        
        // 我们把类路径下的 User 类删除并重新编译运行一下,结果如下
        // 运行结果:通过类加载器:com.example.demo.ClassLoaderTest$CustomClassLoader加载 User 类
        // 此时类路径下找不到 User 类,就由我们自定义的类加载器去 D盘的 test 目录加载了
    }
}

接下来在上面的代码基础上演示如何打破双亲委派机制(重点关注 loadClass 方法):

public class User {
    public void print(String classLoadName) {
        System.out.println("通过类加载器:" + classLoadName + "加载 User 类");
    }
}

public class CustomClassLoaderTest {
    static class CustomClassLoader extends ClassLoader {
        private String classPath;

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

        private byte[] loadByte(String name) throws Exception {
            name = name.replaceAll("\.", "/");
            FileInputStream fis = new FileInputStream(classPath + "/" + name
                                                      + ".class");
            int len = fis.available();
            byte[] data = new byte[len];
            fis.read(data);
            fis.close();
            return data;
        }

        protected Class<?> findClass(String name) throws ClassNotFoundException {
            try {
                byte[] data = loadByte(name);
                // defineClass将一个字节数组转为 Class 对象,这个字节数组是 class 文件读取后最终的字节数组
                return defineClass(name, data, 0, data.length);
            } catch (Exception e) {
                e.printStackTrace();
                throw new ClassNotFoundException();
            }
        }
   		
        @Override
        protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
            synchronized (getClassLoadingLock(name)) {
                // First, check if the class has already been loaded
                Class<?> c = findLoadedClass(name);
                if (c == null) {
                    long t0 = System.nanoTime();
                    // 此处不再去委托父加载器,直接用当前类加载器来加载试试
                    c = findClass(name);

                    if (c == null) {
                        // If still not found, then invoke findClass in order
                        // to find the class.
                        long t1 = System.nanoTime();
                        c = findClass(name);

                        // this is the defining class loader; record the stats
                        sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                        sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                        sun.misc.PerfCounter.getFindClasses().increment();
                    }
                }
                if (resolve) {
                    resolveClass(c);
                }
                return c;
            }
        }
    }

    public static void main(String args[]) throws Exception {
       // 初始化自定义类加载器,会先初始化父类 ClassLoader,其中会把自定义类加载器的父加载器设置为应用程序类加载器 AppClassLoader
        CustomClassLoader classLoader = new CustomClassLoader("D:\test");
        // 首先在我们类路径下的 com.example.demo 目录下有一个 User 类,
        // 然后在D盘创建 test/com/example/demo 几级目录,将 target 目录下的 User.class 文件复制此目录下
        Class clazz = classLoader.loadClass("com.example.demo.User");
        Object obj = clazz.newInstance();
        Method method = clazz.getDeclaredMethod("print", String.class);
        method.invoke(obj, clazz.getClassLoader().getClass().getName());
    }
}

报错如下:

很明显,直接加载是行不通的,因为一些核心类是不可能由我们自定义的加载器来加载的,就算我们自己创建一个核心类如:java.lang.Object 也是不行的

其实稍微调整下 loadClass 方法就可以了,代码如下:

@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            // 非自定义的类还是走双亲委派加载
            if (!name.startsWith("com.example.demo")){
                c = this.getParent().loadClass(name);
            }else{
                c = findClass(name);
            }

            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                long t1 = System.nanoTime();
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

2.2. 设置线程上下文类加载器

可以通过在运行时设置线程的上下文类加载器(Context Class Loader)来打破双亲委派机制。在Java中,每个线程都有一个上下文类加载器,用于在运行时加载类和资源。这个上下文类加载器可以在运行时动态地更改,从而绕过双亲委派机制。

Thread.currentThread().setContextClassLoader(customClassLoader);

六、最后

这篇文章拖了很长时间,很早就写得差不多了,差一点点一直拖到现在。。下次写完要立即发布才行。这块内容我也是刚刚入门,可能有些地方写的不太准确,有发现问题的帅哥美女们可以指出来,大家一起讨论讨论。