Java基石--Java发动机ClassLoader

前言

上一篇分析了Java Class的构成,我们知道JVM识别的是.class文件,那么一个存储在磁盘上的.class文件最终怎么就被JVM执行了呢?此次主要分析该问题(1. 网络大部分的文章都是基于JDK1.8,JDK9之后和之前的内容有所区别;2. 自定义ClassLoader和内置的ClassLoader加载过程还是有些差异) 通过本篇文章,你将了解到:

  1. new 一个对象经历了什么?
  2. 三大内置的ClassLoader处理流程
  3. 自定义ClassLoader如何实现
  4. ClassLoader的应用场景

new 一个对象经历了什么?

从磁盘到内存

以前总有人打趣道:没有对象没关系,可以new出来。如下,声明一个Animal类,并在启动类里引用它:

java 复制代码
public class Animal {
}

public class Main {
    public static void main(String[] args) {
        Animal animal = new Animal();
    }
}

上面的源码编译后会产生2个.class文件:Animal.class和Main.class。

当使用java命令执行Main.class时,将会执行入口方法main,该方法里执行new Animal动作,而该动作涉及两个步骤:

  1. 类加载
  2. 对象创建

接下来,分别阐述两者具体做了哪些事。

类加载

根据官方文档,很容易就总结出类加载过程:

类加载(Loading):

  1. 通过类的全限定名获取类的二进制字节流,如从磁盘上读取.class文件。
  2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表该类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

连接(Linking): 连接分为三个小步骤:验证(Verification),准备(Preparation),解析(Resolution)。 验证:

  1. 确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
  2. 验证的内容包括:文件格式验证、元数据验证、字节码验证、符号引用验证等。

准备:

  1. 为类的静态变量分配内存,并设置类的静态变量的初始值。
  2. 注意此处的初始值,比如类里定义了:static int age = 10,此时int类型的初始值是0。

解析:

  1. 将类、接口、字段和方法的符号引用转换为直接引用。
  2. 符号引用是一种对类、方法、字段等的逻辑描述,而直接引用是可以直接指向目标的指针、偏移量或句柄。

初始化(Initialization):

  1. 执行类的初始化代码,包括静态变量赋值和静态代码块。
  2. 类的初始化代码由编译器自动收集类中的所有静态变量赋值动作和静态代码块合并产生的 方法。

将上述的Animal.java改造如下:

java 复制代码
public class Animal {
    static int age = 10;
    static String name = "fish";
    static {
        System.out.println("Animal age:" + age);
    }
}

此时,static块里的动作就发生在初始化阶段,类的初始化方法是有锁的,没有多线程安全问题,类加载过程中只会执行一次。

使用javap -v Animal.class 命令就可以看到clinit代码如下:

可以看出,编译器生成的clinit方法里,将静态变量的赋值和static块的代码结合起来,并且是先执行的静态变量赋值,再进行static块的执行。

初始化阶段是真正执行Java代码的开始。

对象创建

第一:分配对象内存

当类加载完成后,就会进行对象的创建。

我们知道,new 出来的对象是存放在Java堆里,因此需要先分配堆内存。

在堆中分配一块内存空间,用于存放对象实例,内存大小在类加载时已知(根据字段数量和类型计算)。

第二:设置成员变量的初始值

此处的初始化和类值的初始化差不多,都是给变量赋初始值,如下代码:

java 复制代码
public class Animal {
    int age = 10;
    String name = "fish";
    public Animal() {
        System.out.println("Animal" + age);
    }
}

age的初始值是:0,name初始值是null

第三:设置对象头

对象的哈希码(HashCode)、GC 分代年龄(Generational Age)、锁状态标志(Lock State)、指向类元信息的指针(指向方法区中的 Animal 类信息)

关于对象头部分可查看:Java 对象头分析与使用(Synchronized相关)

第四:执行构造方法

执行父类构造方法、执行显示初始化、执行构造代码块、执行构造方法体,如下代码:

java 复制代码
public class Animal {
    int age = 10;
    String name = "fish";
    public Animal() {
        System.out.println("Animal" + age);
    }
    {
        System.out.println("Animal" + name);
    }
}

执行 javap -v Animal.class 命令后:

可以看出,构造方法最终编译为init()方法,该方法里包含了如上图的步骤。

clinit是针对类,init()是用于对象,它们都用于初始化,流程都差不多。

第五:返回对象地址

Animal animal = new Animal();

前面的动作都完成后,返回对象在堆里的地址,并赋值给引用(animal即为对象的引用),外部调用者就可以通过引用来操作对象(实例)。

2. 三大内置的ClassLoader处理流程

内置加载器的联系与区别

前面流程涉及到类加载和对象创建,大部分是JVM的逻辑,我们能够干涉的不多,加载.class文件到内存是为数不多能够干涉的点,因此我们重点讲讲这块的逻辑。

ClassLoader顾名思义:类加载器,作用即是上面说的:类加载(Loading)

系统默认的有三个类加载器:

  1. AppClassLoader (应用类加载器) 负责加载应用程序类路径(classpath)上的类。
  2. PlatformClassLoader(平台类加载器) Java 9 引入模块系统(JPMS),负责加载 JDK 中非 Java 核心模块的类,例如 java.sql等模块。Java 1.8(含)之前叫做ExentisonClassLoader(扩展类加载器)
  3. BootClassLoader(启动类加载器) 负责加载Java核心库,如java.base包下的类。BootClassLoader是Java层面加载器,它通过调用底层的ClassLoader实现功能,实际干活的是C++层BootstrapClassLoader,因此BootClassLoader只是个壳,当我们尝试在Java层通过getClassLoader()获取核心库下的类的加载器时,会返回null,因为BootstrapClassLoader是C++实现的,它没有Java层对象。

以如下代码为例,看看Animal如何被加载的。

java 复制代码
public class Main {
    public static void main(String[] args) {
        Animal animal = new Animal();
    }
}

上述代码涉及两个类:Main和Animal,它们都是被同一个ClassLoader加载,它是AppClassLoader。

如何知道某个类是哪个加载器加载的呢?每个类在内存里会存在唯一的Class对象,而实例对象可以有若干个,因此我们可以通过Class对象找到加载该类的加载器。

如下代码:

java 复制代码
public class Main {
    public static void main(String[] args) {
        Animal animal = new Animal();
        ClassLoader classLoader = animal.getClass().getClassLoader();
        System.out.println("Main classloader:" + Main.class.getClassLoader());
        System.out.println("Animal classLoader:" + classLoader);
        System.out.println("Sql classloader:" + SQLException.class.getClassLoader());
        System.out.println("String classloader:" + String.class.getClassLoader());
    }
}

打印结果:

java 复制代码
Main classloader:jdk.internal.loader.ClassLoaders$AppClassLoader@251a69d7
Animal classLoader:jdk.internal.loader.ClassLoaders$AppClassLoader@251a69d7
Sql classloader:jdk.internal.loader.ClassLoaders$PlatformClassLoader@279f2327
String classloader:null

这几个ClassLoader有什么关系呢?

它们都直接继承自BuiltinClassLoader,最终继承自ClassLoader。

AppClassLoader父加载器为PlatformClassLoader,PlatformClassLoader父加载器为BootClassLoader。

一个类是怎么确定被哪个加载器加载?

没有明确指定ClassLoader的类默认先尝试使用AppClassLoader加载,如果AppClassLoader不能加载,那么继续找它的父加载器PlatformClassLoader,PlatformClassLoader也不能的话,找到BootClassLoader,如果BootClassLoader还是不能加载,那么最终难题还是抛给AppClassLoader,它自己最终不能加载的话就会抛出异常:ClassNotFoundException

java 复制代码
    @Override
    protected Class<?> loadClass(String cn, boolean resolve)
        throws ClassNotFoundException
    {
        Class<?> c = loadClassOrNull(cn, resolve);
        if (c == null)
            throw new ClassNotFoundException(cn);
        return c;
    }

如果指定了ClassLoader加载,那么它依然会先询问父加载器。

你可能会问,Animal明明可以直接被AppClassLoader加载,为什么还需要向上寻求帮助加载呢?岂不是降低了效率吗?

先回答第一个问题:为什么向上寻求帮助?

答:这就是大名鼎鼎的双亲委派机制(Parent Delegation Model),主要基于两点考虑:

  1. 确保类的唯一性(避免重复加载)如果没有双亲委派机制,不同的类加载器可能会重复加载相同的类,导致类冲突或内存浪费。
  2. 保障核心类库的安全性,确保这些核心类不会被用户自定义的类加载器覆盖或篡改,防止恶意代码伪装成核心类,比如自定义类加载器加载String类。

再回答第二个问题:降低了效率?

答:可以忽略,因为父加载器会先判断能否加载(通过路径匹配),而不是无脑先读取.class文件进行加载。

此外所谓的双亲是parent这个单词翻译过来的,并不是既向父亲请求帮助,又向母亲请求帮助,实际上是单亲,可以理解为只有father。

每个类加载器都有自己预设的管辖范围:

BootClassLoader 加载Java核心类,如下:

红框部分就是类所处的模块,比如java.base模块下就包含了Java核心类:

PlatformClassLoader 加载其它核心类,如下:

可以看出,不论是PlatformClassLoader还是BootClassLoader,它们是预制好的可以加载哪些模块里的类,然后通过jrt协议去访问,而这些类不像是java 1.8那样存储在rt.jar,而是存储在不同的.jmod文件里(也是压缩包格式)。

AppClassLoader 加载应用的类,也就是当前应用里自己编写的类或是引用的其它第三方类(非Java核心),如下:

test_a、test_b、test_c 是工程里的不同的module,mysql-connector-java-8.0.29.jar 是引入的三方包。

AppClassLoader 尝试加载类时,会先去父loader寻找,没成功则自己找,就是从上图的path里列举的路径找。classes目录是源码编译后的.class存放的地方,.jar是依赖的jar包。

双亲委派机制的实现

这分两部分分析,内置的加载器(三大加载器)和自定义加载器在不同的方法实现,网上很多文章是基于jdk 1.8分析,而jdk 9(含)之后这部分的逻辑有变化。

内置加载器

内置加载器实现,先看源码:

java 复制代码
AppClassLoader.loadClass(String name)->BuiltinClassLoader.loadClass(String cn, boolean resolve)->BuiltinClassLoader.loadClassOrNull(String cn, boolean resolve)

核心点在于BuiltinClassLoader.loadClassOrNull,完整源码如下,注释在代码里。

java 复制代码
    protected Class<?> loadClassOrNull(String cn, boolean resolve) {
        synchronized (getClassLoadingLock(cn)) {
            // 先找到该类是否已加载过
            Class<?> c = findLoadedClass(cn);

            if (c == null) {
                //没有加载过
                //查找已加载的模块,实际上是从Map里查找,Map的key就是类名所在的包名
                BuiltinClassLoader.LoadedModule loadedModule = findLoadedModule(cn);
                if (loadedModule != null) {
                    //能走到这里的,说明该类能被PlatformClassLoader和BootClassLoader加载
                    //存在则说明该类(还没加载)已经关联到对应的ClassLoader,取出classloader
                    BuiltinClassLoader loader = loadedModule.loader();
                    if (loader == this) {
                        //如果该类关联的ClassLoader就是自己,则直接加载该类
                        if (VM.isModuleSystemInited()) {
                            c = findClassInModuleOrNull(loadedModule, cn);
                        }
                    } else {
                        //如果该类关联的ClassLoader不是自己,则使用关联的loader尝试加载
                        c = loader.loadClassOrNull(cn);
                    }
                } else {
                    //走到这,说明不能被PlatformClassLoader和BootClassLoader加载
                    if (parent != null) {
                        //尝试使用父加载器加载
                        c = parent.loadClassOrNull(cn);
                    }
                    //父加载器还是不能加载,只能靠自己,hasClassPath表示是否有classpath
                    //也就是AppClassLoader指定要加载的路径是否存在
                    if (c == null && hasClassPath() && VM.isModuleSystemInited()) {
                        //直接加载该类
                        c = findClassOnClassPathOrNull(cn);
                    }
                }
            }

            //是否需要初始化
            if (resolve && c != null)
                resolveClass(c);

            //返回加载的Class,有可能为null
            return c;
        }
    }

上述代码的核心在于递归调用:loadClassOrNull()。

AppClassLoader和PlatformClassLoader都没有重写该方法,调用的BuiltinClassLoader的loadClassOrNull()。

我们知道,递归是有条件的,要不然就会无限递归,此处的条件是BootClassLoader重写了loadClassOrNull()方法,当loader==BootClassLoader或是parent==BootClassLoader时,执行BootClassLoader的loadClassOrNull方法:

java 复制代码
    private static class BootClassLoader extends BuiltinClassLoader {
        BootClassLoader(URLClassPath bcp) {
            super(null, null, bcp);
        }

        @Override
        protected Class<?> loadClassOrNull(String cn, boolean resolve) {
            return JLA.findBootstrapClassOrNull(cn);
        }
    };

JLA.findBootstrapClassOrNull(cn) 最终调用C++层的BootstrapClassLoader,能加载则返回对应的Class,不能加载则返回null。

此外需要注意的是:

  1. 如果是加载Java的核心类,比如Float,那么此时调用路径是AppClassLoader->BootClassLoader,跳过了中间的PlatformClassLoader。
  2. PlatformClassLoader的parent是BootClassLoader,和Class.getClassLoader不一样。

看看流程图:

我们做个类比:小张是一线程序员(AppClassLoader),它的上级是小组长(PlatformClassLoader),小组长上级是技术经理(BootClassLoader)。

他们有个特征:都是程序员类比继承自ClassLoader。

  1. 公司对代码管控比较严格,有一天小张想要开发一个功能A。 他先向小组长询问,我可以做这个功能A吗?小组长也没法确定,就问技术经理,技术经理认为这个功能A属于核心功能,只能自己来亲自把控,于是告诉小张,这个功能A我来实现,你只管用就好了。技术经理也不能开发所有的功能,因此他将一部分的功能给小组长开发,小组长负责开发功能B。

  2. 某天,小张又要开发一个功能B,先问问小组长,小组长再向上问技术经理,技术经理已经不负责做这个功能了,于是小组长当仁不让说自己来开发,小张只管用。

  3. 后来,小张就开发一个测试功能,但他还是要按照流程询问小组长,小组长再问技术经理,他们都不管这个边角东西,于是小张就自己开发自己用了。

3. 自定义ClassLoader如何实现

自定义ClassLoader实现

实现一个自定义加载器需要两个步骤:

  1. 继承自ClassLoader
  2. 重写findClass
java 复制代码
public class MyCustomClassLoader extends ClassLoader {

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException("无法找到类: " + name);
        }
        return defineClass(name, classData, 0, classData.length);
    }

    private byte[] loadClassData(String className) {
        String filePath = (System.getProperty("user.dir")) +"/src/main/java/com/example/util/MyClass.class";
        File file = new File(filePath);

        if (!file.exists()) {
            return null;
        }

        try (FileInputStream fis = new FileInputStream(file)) {
            byte[] buffer = new byte[(int) file.length()];
            fis.read(buffer);
            return buffer;
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }
}

findClass()里先将.class文件读取到byte[]字节数组里,再调用系统的defineClass转换为Class结构。

使用自定义ClassLoader:

java 复制代码
        MyCustomClassLoader loader = new MyCustomClassLoader();
        // 加载类
        Class<?> myClass = loader.loadClass("com.example.util.MyClass");

如此一来,通过自定义加载器,我们就可以加载位于其它位置的.class文件,甚至是网络文件等。

自定义ClassLoader 双亲委托机制实现

再来看自定义ClassLoader加载过程。

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) {
                }

                if (c == null) {
                    //还是找不到,只能自己处理
                    c = findClass(name);
                }
            }
            if (resolve) {
                //解析类
                resolveClass(c);
            }
            return c;
        }
    }
}

我们自定义的.class文件不在AppClassLoader的扫描路径内,不是PlatformClassLoader、BootClassLoader的预置类,因此最后只能自己处理,调用findClass(),我们需要在该重写方法内实现加载.class的逻辑(主要是将文件读入内存)。

通常来说,我们自定义加载器的父加载器是AppClassLoader,因此交给AppClassLoader后就走内置加载器加载流程。自定义ClassLoader加载流程图如下:

4. ClassLoader的应用场景

JVM识别类的唯一性是通过:ClassLoader+全限定类名。

常见的触发类加载的时机有:

  1. new 类名
  2. 访问类的静态变量或方法
  3. Class.forName()
  4. 子类加载后加载父类
  5. 其它场景

掌握了ClassLoader的原理,可以用在如下场景:

1. 插件化架构

动态加载插件:应用程序可以在运行时加载和卸载插件模块

隔离插件环境:不同插件可以使用独立的 ClassLoader,避免类冲突

热插拔功能:无需重启应用即可添加或移除功能模块

2.开发工具和框架

IDE 插件系统:Eclipse 等 IDE 使用插件架构

测试框架:JUnit 等框架动态加载测试类

依赖注入框架:Spring 等框架管理 Bean 的加载和生命周期

3.热更新和热修复

在线修复:不重启服务的情况下修复 bug

功能灰度发布:逐步推送新功能给部分用户

A/B 测试:同时运行不同版本的功能模块

除了上述几点,还有其它领域等待大家探索。

下篇趁热打铁分析Java的强力功能:反射,敬请期待~。

相关推荐
超浪的晨16 分钟前
Java 实现 B/S 架构详解:从基础到实战,彻底掌握浏览器/服务器编程
java·开发语言·后端·学习·个人开发
Littlewith27 分钟前
Java进阶3:Java集合框架、ArrayList、LinkedList、HashSet、HashMap和他们的迭代器
java·开发语言·spring boot·spring·java-ee·eclipse·tomcat
没有bug.的程序员1 小时前
《 Spring Boot启动流程图解:自动配置的真相》
前端·spring boot·自动配置·流程图
追逐时光者1 小时前
一款超级经典复古的 Windows 9x 主题风格 Avalonia UI 控件库,满满的回忆杀!
后端·.net
进击的码码码码N2 小时前
HttpServletRequestWrapper存储Request
java·开发语言·spring
weixin_lynhgworld2 小时前
旧物回收小程序系统开发——开启绿色生活新篇章
java·小程序·生活
Python涛哥2 小时前
go语言基础教程:【1】基础语法:变量
开发语言·后端·golang
野蛮人6号2 小时前
黑马点评系列问题之p44实战篇商户查询缓存 jmeter如何整
java·redis·jmeter·黑马点评
我命由我123452 小时前
PostgreSQL 保留关键字冲突问题:语法错误 在 “user“ 或附近的 LINE 1: CREATE TABLE user
数据库·后端·sql·mysql·postgresql·问题·数据库系统
书唐瑞3 小时前
Percona pt-archiver 出现长事务
java·服务器·数据库