Java 中的类何时被加载器加载

在 Java 程序启动的时候,并不会一次性加载程序中所有的 .class 文件,而是在程序的运行过程中,动态地加载相应的类到内存中。通常情况下,Java 程序中的 .class 文件会在以下 2 种情况下被 ClassLoader 主动加载到内存中:

  1. 调用类构造器
  2. 调用类中的静态(static)变量或者静态方法

java 中的classloader

Alt text

主要包括

  1. 启动类加载器 BootstrapClassLoader
  2. 扩展类加载器 ExtClassLoader (JDK 1.9 之后,改名为 PlatformClassLoader)
  3. 系统加载器 APPClassLoader

APPClassLoader

主要加载系统属性“java.class.path”配置下类文件,也就是环境变量 CLASS_PATH 配置的路径。因此 AppClassLoader 是面向用户的类加载器,我们自己编写的代码以及使用的第三方 jar 包通常都是由它来加载的。

ExtClassLoader

加载系统属性“java.ext.dirs”配置下类文件

BootstrapClassLoader

不是使用 Java 代码实现的,而是由 C/C++ 语言编写的,它本身属于虚拟机的一部分。因此我们无法在 Java 代码中直接获取它的引用。如果尝试在 Java 层获取 BootstrapClassLoader 的引用,系统会返回 null。

加载系统属性“sun.boot.class.path”配置下类文件。

类加载流程 Alt text

Android 中的classloader

主要包括

  • BootClassLoader
  • URLClassLoader
  • BaseDexClassLoader
  • PathClassLoader
  • DexClassLoader
  • InMemoryDexClassLoader
  • DelegateLastClassLoader

这些最终都继承自 java.lang.ClasssLoader

关系图如下: Android 平台的ClassLoader

app 中至少需要 BootClassLoader 和 PathClassLoader 才能运行

BootClassLoader

  • 和 JVM 不同的是, BootClassLoader 是 ClassLoader 的内部类,由 Java 代码实现而不是C++
  • 是 Android 平台所有 ClassLoader 的最终 Parent 。
  • 这个类是包内可见,所以我们无法使用。
  • 加载 Android FW 层的字节码文件

URLClassLoader

只能用于 JAR 文件,但是由于 Dalvik 不能识别 JAR ,所以在 Android 平台上无法使用这个加载器

BaseDexClassLoader

DexClassLoader 和 PathClassLoader 都继承 BaseDexClassLoader ,其中主要逻辑都是在 BaseDexClassLoader 完成的。是一个基础的加载器,

先看 BaseDexClassLoader 的构造函数

/**
 * @param dexPath         目标类所在的 APK 或 JAR 文件路径,
 * @param optimizedDirectory   优化目录
 * @param libraryPath       目标所使用的c/c++库存放的路径
 * @param parent           该加载器的父加载器,
 */
public BaseDexClassLoader(String dexPath, File optimizedDirectory
              , String libraryPath , ClassLoadeparent) {
    super(parent);
    this.pathList = new DexPathList(this, dexPath , libraryPath , optimizedDirectory);
}

四个参数的解释:

  • dexPath 类加载器将从该路径中寻找指定的目标类,
    • 该类必须是 APK 或者 JAR 的全路径如果要包括多个路径,
    • 路径之间必须使用指定的分隔符,可以使用System.getProperty(“path.separtor”),
    • DexClassLoader 中所谓的支持加载 APK , JAR , dex ,也可以从 SD 卡中加载,指的就是这个路径,
    • 最终是将 dexPath 路径上的文件 ODEX 优化到 optimizedDirectoyr ,然后进行加载
  • optimizedDirectory
    • 由于 dex 文件被包含在 APK 或者 JAR 中,因此在装载目标类之前需要从 APK 或者 JAR 文件中解压 dex 文件,该参数就是定制解压出来的 dex 文件存放的路径的,这也是对 apk 的 dex 根据平台进行 ODE 优化的过程,
    • 其实 APK 是一个压缩包,里面包括 dex 文件, ODEX 文件的优化就是把包里面的执行程序提取出来,就变成 ODEX 文件,因为你提取出来了,系统第一次启动的时候就不用去解压程序压缩包里面的程序,少了一个解压过程,这样系统启动就加快,
    • 为什么说是第一次呢?因为 DEX 版本只会有在第一次会解压执行程序到 /data/dalvik-cache(针对PathClassLoder)或者 optimizedDirectory (针对DexClassLoader)目录,之后也就直接读取 dex 文件,所以第二次启动就和正常启动差不多了,当然这只是简单理解,实际生成 ODEX 还有一定的优化作用,
    • ClassLoader只能加载内部存储路径中的 dex 文件,所以这个路径必须是内部路径
  • libraryPath 指的是目标所使用的c/c++库存放的路径
  • parent 一般为当前执行类的加载器,例如 Android 中以context.getClassLoader()作为父加载器

PathClassLoader

用来加载已经安装到系统中的 APk 中 class 文件

继承 BaseDexClassLoader ,构造函数如下

public class PathClassLoader extends BaseDexClassLoader {
    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null , null , parent);
    }
  public PathClassLoader(String dexPath, String libraryPath , ClassLoader parent) {
        super(dexPath, null , libraryPath , parent);
    }
}
  1. PathClassLoader把 optimizedDirectory 设置为 null ,也就是没有设置优化后存放的路径,其实 optimizedDirectory 为 null 的默认路径就是/data/dalivk-cache目录
  2. 在 Dalivk 虚拟机上只能加载已安装的 APK 的 dex 。在 ART 虚拟机上,可以加载未安装的 APK 的dex
  3. 主要用于系统和 app 的类加载器
  4. 在应用启动的时候创建,从data/app/安装目录下加载 apk 文件
  5. 在 Android 中, app 安装到手机中, apk 的 dex 文件中的 class 文件都是通过 PathClassLoader 来加载的

DexClassLoader

继承 BaseDexClassLoader ,构造函数如下

public class DexClassLoader extends BaseDexClassLoader {
  public DexClassLoader(String dexPath, String optimizedDirectory ,
 String libraryPath , ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), libraryPath , parent);
  }
}
  1. 支持加载 APK , JAR ,和 DEX ,也可以从 SD 卡上进行加载
  2. 虽然说 dalivk 不能直接识别 JAR ,但却说 DexClassLoader 能加载 JAR 文件,其实这个不矛盾,因为在 BaseDexClassLoader 里面对“.jar”,”.apk”,”.zip”,”.dex”后缀的文件最后都会生成对应的 dex 文件,所以最终处理的还是 dex 文件,而 URLClassLoader 却没有做这样的处理,所以我们 一般都是用 DexClassLoader 来作为动态加载的加载器
  3. 可以从包含classes.dex的 JAR 或者 APK 中加载类的加载器,用于执行动态加载,但必须是 app 私有可写目录来缓存 ODEX 文件,能够加载系统没按照的 apk 或者 JAR 文件,因此很多插件化方案都是用DexClassLoader

InMemoryDexClassLoader

在API 26 (Android 8.0 )时新增的一个类加载器

public final class InMemoryDexClassLoader extends BaseDexClassLoader {
    public InMemoryDexClassLoader(ByteBuffer[] dexBuffers, ClassLoader parent) {
        super(dexBuffers, parent);
    }
    public InMemoryDexClassLoader(ByteBuffer dexBuffer, ClassLoader parent) {
        this(new ByteBuffer[] { dexBuffer }, parent);
    }
}

由代码可知:

  1. 继承 BaseDexClassLoader 。
  2. 构造函数调用父类的构造函数, ByteBuffer 数组构造一个 DesPathList ,可用于内存的 DEX 文件

DelegateLastClassLoader

在API 27 (Android 8.1)新增的类加载器,继承 PathClassLoder.

public final class DelegateLastClassLoader extends PathClassLoader {
    public DelegateLastClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, parent);
    }
    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
      Class<?> cl = findLoadedClass(name);
      if (cl != null) {
          return cl;
      }
      try {
          return Object.class.getClassLoader().loadClass(name);
      } catch (ClassNotFoundException ignored) {
      }
        ClassNotFoundException fromSuper = null;
        try {
            return findClass(name);
        } catch (ClassNotFoundException ex) {
            fromSuper = ex;
        }
        try {
            return getParent().loadClass(name);
        } catch (ClassNotFoundException cnfe) {
            throw fromSuper;
        }
    }
}

实行的是最后查找策略。使用 DelegateLastClassLoader 来加载每个类或者资源。使用一下查找顺序

  1. 首先判断是否已经加载过该类
  2. 然后搜索此类的类加载器是否加载过这个类
  3. 最后,搜索与此类加载器的 dexPath 关联的 dex 文件列表,委托给指定的父对象加载

双亲委托模型

我们知道,每一个加载器都有一个父加载器,这个双亲委托模型就和这个父加载器有关:如果一个加载器收到了加载类的请求,他首先不会自己尝试去加载这个类,而是把这个请求交给他的父类加载器完成,父类加载器也有父类加载器的,每个加载器都是如此,只有当父加载器在自己搜索范围内找不到指定的类时( ClassNotFoundException),子加载器才会尝试自己去加载。 具体加载过程如下:

  1. 源 ClassLoader 判断是否加载过该 Class ,如果加载过,则直接返回该 Class ,如果没有则委托父类加载。
  2. 父类加载器判断是否加载过该 Class ,如果加载过,则直接返回该 Class ,如果没有则委托父类的父类,也就是爷爷类加载器
  3. 以此类推,直到始祖类加载器(即BootstrapClassLoader)
  4. BootstrapClassLoader 判断是否加载过该 Class ,如果加载过,则直接返回该 Class ,如果没有,则尝试从其对应的类的路径下寻找 class 字节码并载入,如果载入成功,则直接返回 Class ,如果载入失败,则委托给始祖类的子类加下载器
  5. 始祖类的子类加载器尝试从其对应的类的路径下寻找 class 字节码并载入,如果载入成功,则直接返回该 Class ,如果载入失败,则委托给始祖类子类的子类,即孙子类加载器
  6. 以此类推,直到源ClassLoader
  7. 源 ClassLoader 尝试从其对应的类的路径下寻找 class 字节码并载入,如果载入成功,则直接返回该 class ,如果载入失败,源 ClassLoader 不会在委托其他子类加载器,而是抛出异常。

委托机制的意义:

防止内存中出现多份同样的字节码

例如如果 A 类和 B 类都需要加载一个 System ,如果不用委托机制,而是自己加载自己,那么 A 类会加载一份 System 字节码.B 类又会加载一份 System 字节码,这样内存中就会出现两份 System 字节码。

试想一个场景:黑客自定义一个java.lang.String类,该 String 类具有系统的 String 类一样的功能,只是在某个函数稍作修改。比如 equals 函数,这个函数经常使用,如果在这这个函数中,黑客加入一些“病毒代码”。并且通过自定义类加载器加入到 JVM 中。此时,如果没有双亲委派模型,那么 JVM 就可能误以为黑客自定义的java.lang.String类是系统的 String 类,导致“病毒代码”被执行。

而有了双亲委派模型,黑客自定义的java.lang.String类永远都不会被加载进内存。因为首先是最顶端的类加载器加载系统的java.lang.String类,最终自定义的类加载器无法加载java.lang.String类。

注意:
“双亲委托”机制只是 Java 推荐的机制,并不是强制的机制。
我们可以继承 java.lang.ClassLoader 类,实现自己的类加载器。
如果想保持双亲委派模型,就应该重写 findClass(name) 方法;
如果想破坏双亲委派模型,可以重写 loadClass(name) 方法。

双亲委托模型在热修复领域的应用

在用 BaseDexClassLoader 或者 DexClassLoader 去加载某个 dex 或者某个 apk 中的 class 时候,无法调用 findClass() 方法,因为该方法是包访问权限的,需要调用 loadClass() ,该方法其实是 BaseDexClassLoader 的父类 ClassLoader 实现的, findclass() 方法就是双亲委托模型的实际体现。源码如下

public Class<?> loadClass(String className) throws ClassNotFoundException {
    return loadClass(className, false);
}
protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
    Class<?> clazz = findLoadedClass(className);//查找是否加载过该 Class 文件

    if (clazz == null) {//没有加载过,
        try {
            //调用父类的 loadClass() 方法去加载该文件
            clazz = parent.loadClass(className, false);
        } catch (ClassNotFoundException e) {
            // Don't want to see this.
        }

        if (clazz == null) {//父类没有加载过或者加载失败,
            clazz = findClass(className);//自己去加载
        }
    }
    //说明加载过,直接返回
    return clazz;
}

流程图如下:

Alt text

如图,当前 classloader 是否加载过当前类,如果加载过,就直接返回,如果没有加载过,则看父类有没有加载过,如果加载过,则直接返回,如果都没加载过,则子 classloader 调用 findClass() 真正加载类

findClass() 在 ClassLoader 中是一个空实现,所以需要到具体的子类中去处理,我们知道, PathClassLoader 和 DexClassLoader 都是继承 BaseDexClassloader ,所以可以先在 BaseDexClassloader 中去查找

protected Class<?> findClass(String name) throws ClassNotFoundException {
     List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
     Class c = pathList.findClass(name, suppressedExceptions);
     if (c == null) {
         ClassNotFoundException cnfe = new ClassNotFoundException(
                 "Didn't find class \"" + name + "\" on path: " + pathList);
         for (Throwable t : suppressedExceptions) {
             cnfe.addSuppressed(t);
         }
         throw cnfe;
     }
     return c;
  }

这里面关键的就是执行 pathList.findClass() ,所以自定义加载器,主要就是实现 findClass()

ClassLoader 加载 DEX 的逻辑

DEX 就是能够被 DVM 识别,并且加载执行的文件格式

一个 ClassLoader 可以有多个 DEX 文件,每个 Dex 文件是一个 Element ,多个 Dex 文件排成一个有序的 dexElements 。
当加载类的时候,会按照顺序遍历 Dex 文件,然后从当前的 Dex 文件中寻找该类。
由于双亲委托模型机制,只要找到就会停止并且返回,如果找不到就从下一个 Dex 文件中继续寻找。
只要先加载修复好的 DEX 文件,那么就不会加载有 bug 的 Dex 文件了。 这就是热修复的核心

另外,假设 APP 引用的类 A ,再其内部引用的类 B ,如果类 A 和类 B 在同一个 DEX 文件中,那么类 A 上就会被打上 CLASS_ISPREVERIFIED 标记,被标记的这个类不能引用其他 dex 文件中的类,否则会报错。 A 类如果还引用了一个 C 类,而 C 类在其他 dex 中,那么 A 类并不会被打上标记。换句话说,只要在 static 方法,构造方法, private 方法, override 方法中直接引用了其他 dex 中的类,那么这个类就不会被打上 CLASS_ISPREVERIFIED 标记。

解决方案就是 让所有类都引用其他 dex 中的某个类就可以了。


搬运地址:

Android动态加载之 ClassLoader 详解

Android类加载器ClassLoader

热修复入门:Android 中的 ClassLoader

Android热补丁动态修复技术(二):实战! CLASS_ISPREVERIFIED 问题!