五、ClassLoader详解

概述

一个完整的java程序是由多个class文件构成的,程序运行时,JVM需要将这些class加载到内存中才能使用到它们。负责加载这些class文件的是 ClassLoader。

ClassLoader加载class,并不是一次性加载所有class。而是动态按需加载,它有自己的一套加载逻辑。

加载class的时机

通常,以下两种情况下,class会被classLoader加载到JVM中。

  • 调用类的构造方法

    • 这个很容易理解,直接调用类的构造器创建出它的class对象
  • 调用类中的静态变量或者方法

    • 因为一个类的变量和方法都依赖于这个类的class对象,所以必须先构建出class对象

Java类加载器的分类

系统类加载器 AppClassLoader

它是从 当前环境变量中先读取出 java.class.path 的值(也就是classPath的值),然后从路径中找到所有的class文件进行加载。

它是面向用户的classLoader,我们自己的java代码,或者第三方的jar包内的java代码,都会通过它来加载到JVM。

扩展类加载器 ExtClassLoader

它则是加载的java.ext.dirs 下的所有class文件。与 AppClassLoader相比,只是取class的路径不同。

注意:在不同的jdk版本中,名称可能不同,比如在jdk1.8上,名称为:PlatformClassLoader

启动类加载器 BootstrapClassLoader

与上面两种都不同, 这个BootstrapClassLoader 并不是java代码编写的,而是c/c++写的,它本身就是JVM的一部分,如果在java中尝试获取它的引用,会返回null。

它所检索的路径 为:

System.getProperty("sun.boot.class.path");

这些路径主要指向了 JRE(java Runtime Environment java运行环境)中的jar包。

双亲委派机制

Class加载流程

在java中,已经存在了这三种ClassLoader,那么当程序运行起来时,JVM如何决定用那种类加载器去加载需要的class文件呢?毕竟他们3个所检索的 文件路径都不同。

JVM内部存在一个名叫 双亲委派模式 的机制。

当类加载器收到加载class的请求时,通常都是优先委托给父类加载器去加载。只有当父类加载器找不到指定资源或者类的时候,才会执行自身的加载过程。

每个JDK版本的 ClassLoader源代码都有可能不同,以下是 jdk18.0.1(也就是jdk1.8):

有一些细节需要注意:

  • 它自带缓存机制,已经加载过的class会被保存起来,保存的位置在 native层(而不是在java层),大概可以推测 缓存的数据结构是Map类型,Key是 String类型,应该是 class的全类名字符串,value则是 Class对象
  • 每次加载时,优先从缓存中获取,如果获取到了,直接返回。

  • 缓存中没获取到,那就将 加载动作委托给 parent,

  • 如果parent为空,则调用启动类加载器(BootstrapClassLoader)的加载过程

  • 如果parent不为空,则 调用自身的findClass方法

关于Parent ClassLoader

ClassLoader的构造函数要求传入parent:

java中的3个classLoader,经过源码阅读可以发现:

  • AppClassLoader的parent是 ExtClassLoader

  • ExtClassLoader的parent是 null

示例代码如下

自定义一个Test类

java 复制代码
public class Test {

}

写一个main函数,用于打印classLoader

java 复制代码
public class Main {

    public static void main(String[] args) {

        ClassLoader c1 = Test.class.getClassLoader();
        System.out.println("c1 is "+ c1);

        ClassLoader c2 = c1.getParent();
        System.out.println("c2 is "+ c2);

        ClassLoader c3 = c2.getParent();
        System.out.println("c3 is "+ c3);

    }
}

打印结果为:

java 复制代码
c1 is jdk.internal.loader.ClassLoaders$AppClassLoader@70dea4e
c2 is jdk.internal.loader.ClassLoaders$PlatformClassLoader@4517d9a3
c3 is null

第一个为系统类加载器AppClassLoader没错,第二个,为 扩展类加载器 $PlatformClassLoade,第三个为null

模拟加载场景

java 复制代码
Test t = new Test();

比如这个场景,我们触发了Test类的构造函数,

  • AppClassLoader会将它的加载动作委托给它的 parent也就是,ExtClassLoader

  • ExtClassLoader 又会去找它的parent,发现是null

  • 于是ExtClassLoader将加载动作交给了 BootstrapClassLoader

  • BootstrapClassLoader 在 jre目录中 找了一圈,发现并没有找到test

  • AppClassLoader 只能自己去加载test

自定义ClassLoader示例

JVM中的ClassLoader只能加载特定路径下的class文件,如果想加载其他位置的class文件,则需要自定义我们自己的ClassLoader。

比如我们想要自定义一个ClassLoader来加载我们硬盘上的class,步骤如下:

创建一个DistClassLoader继承 ClassLoader

java 复制代码
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class CustomClassLoader extends ClassLoader {
    private String classpaths;

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

    @Override
    protected Class<?> findClass(String className) throws ClassNotFoundException {
        byte[] classData;
        String[] paths = classpaths.split(";");
        for (String path : paths) {
            try {
                classData = loadClassData(path, className);
                return defineClass(className, classData, 0, classData.length);
            } catch (IOException e) {
                // 如果在当前目录路径中没有找到类,则继续尝试下一个目录路径
            }
        }
        throw new ClassNotFoundException("Class not found: " + className);
    }

    private byte[] loadClassData(String classpath, String className) throws IOException {
        String fileName = className.replace('.', '/') + ".class";
        Path path = Paths.get(classpath, fileName);
        return Files.readAllBytes(path);
    }
}

在这里注意一些细节:

  • 传入classPath的值就是我们需要去搜索的目录的绝对的路径,形如:C:/Users/zwx1245985/Desktop/myClassPath/, 如果有多个路径,那么用 英文分号;分隔

  • 自定义classLoader有两种方式,一是 仅需要重写 findClass方法去 自定义路径下去查找我们的class文件,运行时仍然会遵循 双亲委托机制,像是 上面的写法,没有指定parent的情况下,它的parent就是 AppClassLoader(加载classpath路径)

  • 如果我们不想用 双亲委托机制,那么可以重写loadClass方法(本文不做案例)

  • 传入多个路径的情况下,查找的顺序是 从前往后搜索,如果找到了一个,那就直接返回了。(是不是有点像 安卓mutidex热修复的?)

  • 将 指定路径下的class文件转化为byte数组时,使用的是就是 nio 非阻塞文件流

创建一个普通java类

比如:

java 复制代码
public class Secret {

   public void printSecret() {
      System.out.println("这是个秘密!");
   }
}

注意 不要放在任何package内。

然后,用javac去编译它,得到一个 Secret.class,

Secret.class放到你的电脑某个路径下, 比如:C:\Users\zwx1245985\Desktop\myClassPath

开始测试

编写如下代码:

java 复制代码
import java.lang.reflect.Method;

public class Main {

    public static void main(String[] args) {
        testClassLoader();
    }

    private static void testClassLoader() {
        CustomClassLoader distClassLoader = new CustomClassLoader(
                "C:/Users/zwx1245985/Desktop/myClassPath;C:/Users/zwx1245985/Desktop/myClassPath2;");

        System.out.println("它的parent是:" + distClassLoader.getParent().);

        try {
            Class<?> c = distClassLoader.loadClass("Secret");
            if (c != null) {
                Object o = c.getDeclaredConstructor().newInstance();
                Method method = c.getDeclaredMethod("printSecret", null);
                method.invoke(o, null);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
  • 这里我传入了多个路径,用英文分号分隔

  • 使用我们自定义的classLoader尝试加载Secret.class

  • 得到class之后,创建它的实例,并执行它的 printSecret方法

运行结果:

java 复制代码
它的parent是:jdk.internal.loader.ClassLoaders$AppClassLoader@70dea4e
这是个秘密

它的parent是 AppClassLoader,得到验证。并且成功 打印出了 这是个秘密!,printSecret方法被成功调用。

Andorid的ClassLoader

Andorid同样需要通过classLoader将类加载到内存,类加载器之间也是双亲委派机制。

只不过Andorid会将所有的 class转换成dex,Andorid中 加载dex的逻辑全部在BaseDexClassLoader中。

我们一般会接触它的两个子类:PathClassLoader 和 DexClassLoader

PathClassLoader

它通常用来装载 系统apk中的dex,或者 已经安装到 手机上的 app私有路径下 base.apk内的dex。

构造函数如下:

dexPath为 dex文件的路径

librarySearchPath为 c/c++类库的搜索地址,在安卓linux环境下通常指的是 .so动态库或者.a静态库文件

验证

java 复制代码
public class MainActivity extends AppCompatActivity {

    @SuppressLint("MissingInflatedId")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ClassLoader classLoader = MainActivity.class.getClassLoader();
        Log.d("ClassLoaderTag", "" + classLoader);
        setContentView(R.layout.activity_main);
    }

}

打印结果为:

java 复制代码
dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/~~91P9Q_h7V9_YBN6SX6ubug==/com.zhou.gracefulpermissionframework-vwOpzcIGTQr4GnmCM8GSHQ==/base.apk"],nativeLibraryDirectories=[/data/app/~~91P9Q_h7V9_YBN6SX6ubug==/com.zhou.gracefulpermissionframework-vwOpzcIGTQr4GnmCM8GSHQ==/lib/arm64, /system/lib64, /hw_product/lib64, /system/lib64/module/multimedia, /system/product/lib64]]]

DexClassLoader

对比PathClassLoader只能加载已安装的apk内的dex,DexClassLoader可以加载SD卡内的jar包或者apk文件,这也是插件化或者热修复的基础。

构造函数

java 复制代码
public class DexClassLoader extends BaseDexClassLoader {
    public DexClassLoader(
     String dexPath, // 传入自定义dex路径,多个路径默认用冒号:分隔 
     String optimizedDirectory, // 传入dex优化路径,不能为空
     String librarySearchPath, // c/c++库搜索地址
     ClassLoader parent // 父classLoader
   ) 
    {
        super((String)null, (File)null, (String)null, (ClassLoader)null);
        throw new RuntimeException("Stub!");
    }
}
  • dexPath 包含class.dex的 apk jar路径,多个路径用英文冒号:分隔

  • optimizedDirectory 用来缓存优化的dex路径,从apk或者jar包中提取出的dex文件会经过opt优化之后才能被classLoader加载class。 此参数必传,并且路径必须是 app的私有目录。

安卓ClassLoader热修复基本原理

DexClassLoader 是 谷歌留给开发者的一个窗口,允许我们设置自己的 类检索路径,来查找我们自定义路径下的class。

假如有这么一个接口:

java 复制代码
public interface ISay {

    String saySth();

}

线上app上,有这么一个 实现类,存在bug:

java 复制代码
public class Say implements ISay {
   @Override
    public String saySth() {
        return "有bug!";
    }   
}

我们创建了一个补丁类,也叫 Say:

java 复制代码
public class Say implements ISay{
    @Override
    public String saySth() {
        return "now is Ok!";
    }
}

我们将 正确的补丁类打包成 补丁包 hotfix.jar。将它放置到 手机内存卡中。我们可以创建一个DexClassLoader来加载它,然后执行它内部的方法。

java 复制代码
public class MainActivity extends AppCompatActivity {


    ISay iSay;

    @SuppressLint("MissingInflatedId")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        

        findViewById(R.id.btn_jump).setOnClickListener(v -> {

            String hotfixJarName = "hotfix.jar";

            // 获取补丁jar的File对象
            final File finalFile = new File(Environment.getExternalStorageDirectory().getPath()
                    + File.separator + "" + hotfixJarName);
            // 如果补丁不存在,就执行原逻辑
            if (!finalFile.exists()) {
                iSay = new Say(); 
                Toast.makeText(this, "" + iSay.saySth(), Toast.LENGTH_SHORT).show();
            } else {
                // 如果补丁存在,那就 直接用 DexClassLoader加载它
                DexClassLoader dexClassLoader = new DexClassLoader(
                        finalFile.getAbsolutePath(),// 检索路径
                        getExternalCacheDir().getAbsolutePath(), // 优化路径
                        null, // c/c++库检索位置
                        null,  // 设置父classLoader为空
                );

                try {
                    // 反射创建对象,然后执行它的saySth方法
                    Class<?> aClass = dexClassLoader.loadClass("com.xxx.isay.Say");
                    ISay sayOk = (ISay) aClass.getConstructor(null).newInstance();
                    Toast.makeText(this, "" + sayOk.saySth(), Toast.LENGTH_SHORT).show();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }

        });
    }

}

注意以上细节:

  • 如果补丁包不存在,就走原逻辑

  • 如果补丁包存在,那就创建 DexClassLoader对象,注意,此处,parent必须给null,因为 补丁的class与有bug的class是完全同名的,所以一旦我们设置 getClassLoader(它实际上是PathClassLoader,那么根据双亲委托机制,它会优先找到 原有bug的class)

  • 找到 补丁class之后,反射创建对象,强转为 ISay,并执行 saySth方法

总结

  • ClassLoader是用来加载class的,无论是 dex中的还是jar中的

  • java中的三种 classLoader,分别对应了不同路径下的class,各自负责不同的区域,但是彼此之间存在父子关系

  • 自定义ClassLoader时,建议重写 findClass,不建议 重写 loadClass,因为后者会破坏双亲委托机制,最好别破坏

  • Android中的两种 ClassLoader的区别,也是各自负责的区域不同,一个是写死的app私有路径,一个是支持自定义外部路径。

相关推荐
Eliauk__8 分钟前
深入浅出聊聊跨域:它到底是个啥,怎么破?
前端·javascript·面试
行星飞行10 分钟前
Review-Gate MCP,让你的 cursor request 次数翻 5 倍
前端
小磊哥er12 分钟前
【前端AI实践】DeepSeek:开源大模型的使用让开发过程不再抓头发
前端·vue.js·ai编程
Vesper6321 分钟前
【Vue】Vue2/3全局属性配置全攻略
前端·javascript·vue.js
伍哥的传说23 分钟前
React Toast组件Sonner使用详解、倒计时扩展
前端·javascript·react.js·前端框架·ecmascript
徐志伟啊26 分钟前
ElTree组件可以带线了?
前端·vue.js·前端框架
&白帝&26 分钟前
Vue 3 常用响应式数据类型详解:ref、reactive、toRef 和 toRefs
前端·javascript·vue.js
Hanbox27 分钟前
从零开始:React+ECharts动态地理数据可视化
前端
邹荣乐28 分钟前
Vue3中Sass的安装与使用指南:轻松上手CSS预处理器
前端·javascript·vue.js
电商API_1800790524728 分钟前
淘宝天猫商品数据爬取方案:官方API与非官方接口对比
linux·服务器·开发语言·前端·爬虫·python·数据挖掘