ClassLoader

JVM 在运行时,并不会一次性加载所有的类到内存中,而是只在需要的时候加载。而 ClassLoader 就是用来将 .class 文件加载到内存中的类。 JVM在判定两个class是否相同时,不仅要判断两个类名是否相同,而且要判断是否由同一个类加载器实例加载的。只有两者同时满足的情况下,JVM才认为这两个class是相同的。

而 Android 中的虚拟机 Dalvik/ART 无法和 JVM 一样直接加载 class 文件和 jar 文件中的 class,只能通过 dex 文件或包含 dex 的 jar 包或 apk 包来加载。因此 Android 和 Java 的 ClassLoader 略有不同,但他们都遵守 ClassLoader 的基本规则。

全盘负责

全盘负责是指,当一个 ClassLoder 加载一个类时,这个类所依赖和引用的类如果没有显式地指定其他 ClassLoder,那么这些类还是由这个 ClassLoader 来加载。

双亲委托模型(Parent Delegation Model )

双亲委托模型这个翻译有很强的误导性,因为每个 ClassLoader 只有一个父加载器,并不存在什么双亲,实际翻译为父委托模型更为妥当。但这个翻译流传甚广,大家都默认了这种叫法。

每个 ClassLoader 对象中都有一个 parent 引用,在构造器中传入,这个就是他的父加载器。因此这里的父加载器并不是继承的关系,而是引用的关系。

双亲委托模型是指,类加载器在加载时遵循以下规则:

  1. 源 ClassLoader 先判断这个类是否加载过,如果已加载,则直接返回,如果未加载,则委托父加载器进行加载。
  2. 父加载器进行和第一步同样的判断,如果已加载,则直接返回,如果未加载,则委托祖父加载器进行加载。
  3. 这样一直上溯,一直到有个加载器的父加载器为 null,这时系统会指定 BootstrapClassLoader 进行加载,这个就是始祖类加载器。因为这个类使用 C++ 实现,不能被 Java 对象直接引用,所以它的子加载器的 parent 都为 null。
  4. 始祖类加载器判断是否加载过,如果没有,则尝试从指定的路径下寻找 class 文件并加载,找到了则返回,找不到则委托给子加载器加载。
  5. 重复上面的步骤,如果始终找不到,会一直下溯到源 ClassLoader
  6. 源 ClassLoader 加载成功的话就直接返回,加载不成功的话会抛出 ClassNotFoundException。

这个机制和 Android 中的事件分发类似,只不过顺序相反。先上溯,父加载器处理不了再下溯交给子加载器处理。说了这么多,其实代码实现很简单(删除了无关代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 {
//如果父加载器为 null,说明到了最顶层,这时委托祖先bootstrap类加载器加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) {
// 如果此时依然为空,说明父辈加载器都没加载。此时自己调用方法加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

为什么要如此设计

  • 防止类重复加载。遵循双亲委托原则的话,同一个类只会由一个类加载器加载。避免混乱。
  • 保证安全。防止插入 Java 核心类到 Java 类库中。
  • 当然,在遵循双亲委托的前提下上面两点才成立。双亲委托模型是由 ClassLoader 的 loadClass 方法实现的,这是个 protected 的方法,因此子类可以重写,并实现自己的逻辑。这会破坏了双亲委托模型,但为插件化提供了方便。

Java 中的 ClassLoader

BootStrap ClassLoader

启动类加载器。这是 Java 中最顶层的类加载器,属于 JVM 的一部分,负责加载 JDK 中的核心类库。它由 C++ 编写,因此其他类加载器不能直接引用它。

Extension ClassLoader

扩展类加载器。负责加载 Java 的扩展类库。默认加载JAVA_HOME/jre/lib/ext/目下的所有jar。

App ClassLoader

系统类加载器,负责加载应用程序classpath目录下的所有jar和class文件。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它。

类加载器本身由谁加载?

前面说了,BootStrap ClassLoader 是 JVM 的一部分,由 JVM 自身管理,我们无法访问。程序启动时,BootStrap ClassLoader 会先加载 Extension ClassLoader,然后加载 App ClassLoader。其中 Extension ClassLoader 是 App ClassLoader 的父加载器。BootStrap ClassLoader 是 Extension ClassLoader 的父加载器。

默认的父加载器是谁?

父加载器在 ClassLoader 的构造方法中传入。如果不传,会指定一个系统加载器给它,这个系统加载器就是 App ClassLoader。

如何自定义 ClassLoader

  1. 继承 ClassLoader 类,重写 findClass 方法
  2. 在 findClass 方法中根据类名找到对应文件,并将文件转成 byte 数组
  3. 调用 defineClass 方法将 byte 数组转为 Class 对象返回
  4. 没事不要重写 loadClass 方法。会破坏双亲委托模型

Android 中的 ClassLoader

Android 中的 ClassLoader 还是符合双亲委托模型的,其 loadClass 没有什么变化。但是,defineClass 方法会直接抛出异常,提示“can’t load this type of class file”。这是因为Android 中的虚拟机 Dalvik/ART 无法和 JVM 一样直接加载 class 文件和 jar 文件中的 class。

Android 有以下三种类加载器:

  • BootClassLoader

    这是 Android 虚拟机中的祖先类加载器。和 Java 不同的是,它是 ClassLoader 的内部类,由 Java 实现。

  • PathClassLoader

    应用默认的类加载器。父加载器是 BootClassLoader。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class PathClassLoader extends BaseDexClassLoader {
    public PathClassLoader(String dexPath, ClassLoader parent) {
    super(dexPath, null, null, parent);
    }

    public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
    super(dexPath, null, librarySearchPath, parent);
    }
    }
  • DexClassLoader

在看源码时,发现 DexClassLoader 在 8.1.0 版本已经废弃了。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//Android 8.1.0
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}
}

//Android 8.0.0 及以前
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
}
}

可以看见,DexClassLoader 构造方法中的 optimizedDirectory 参数在8.1.0版本被废弃了。这样 DexClassLoader 实际上和 PathClassLoader 没有区别了。

看看Android 8.0的行为变更描述

DexFile API is now deprecated, and you are strongly encouraged to use one of the platform classloaders, including PathClassLoader or BaseDexClassLoader, instead.
DexFile API 现已弃用,强烈建议您改为使用此平台的类加载器之一,包括 PathClassLoader 或 BaseDexClassLoader。

应该是出于防止代码注入的原因,官方已经弃用了 DexFile 的机制。下面我们就根据最新的8.1代码进行分析。在看代码的时候始终记得一点就行,optimizedDirectory参数始终为空!!!

BaseDexClassLoader

BaseDexClassLoader 继承自 ClassLoader,是 PathClassLoader 的父类。代码很简单,将主要功能都交给了 DexPathList 去处理。

  • 在构造方法中新建一个 DexPathList,将 DexPath 传入,optimizedDirectory的参数位传 null。

    1
    2
    3
    4
    5
    6
    7
    8
    public BaseDexClassLoader(String dexPath, File optimizedDirectory,String librarySearchPath, ClassLoader parent) {
    super(parent);
    this.pathList = new DexPathList(this, dexPath, librarySearchPath, null);

    if (reporter != null) {
    reporter.report(this.pathList.getDexPaths());
    }
    }
  • 在 findClass 中委托 DexPathList 去查找类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @Override
    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;
    }

DexPathList

DexPathList 也只干了两件事:

  • 新建 Element 数组。其中所有 dex 文件包装成 DexFile,其他文件或文件夹包装成 File 对象传入 Element
  • 在 findClass 中遍历 Element 数组,调用 Element 的 findClass 方法,找到类立即返回,不再继续寻找。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public Class<?> findClass(String name, List<Throwable> suppressed) {
    for (Element element : dexElements) {
    Class<?> clazz = element.findClass(name, definingContext, suppressed);
    if (clazz != null) {
    return clazz;
    }
    }

    if (dexElementsSuppressedExceptions != null) {
    suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
    }
    return null;
    }

以上这段代码至关重要,它意味着一个类只加载一次,并且优先加载排在前面的 dex 文件中的类。这给热修复提供了机会。

Element

Element 是 DexPathList 的静态内部类。调用 findClass 时,如果 dexFile 不为空,则用dexFile查找类,为空则直接返回 null。

1
2
3
4
public Class<?> findClass(String name, ClassLoader definingContext, List<Throwable> suppressed) {
return dexFile != null ? dexFile.loadClassBinaryName(name, definingContext, suppressed)
: null;
}

DexFile

DexFile 就是实际处理 .dex 文件的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) {
return defineClass(name, loader, mCookie, this, suppressed);
}

private static Class defineClass(String name, ClassLoader loader, Object cookie,DexFile dexFile, List<Throwable> suppressed) {
Class result = null;
try {
result = defineClassNative(name, loader, cookie, dexFile);
} catch (NoClassDefFoundError e) {
if (suppressed != null) {
suppressed.add(e);
}
} catch (ClassNotFoundException e) {
if (suppressed != null) {
suppressed.add(e);
}
}
return result;
}

我们终于找到了熟悉的 defineClass 方法!!!
它最终调用了 defineClassNative 方法,从一个 dex 文件中查找类,加载到内存并返回 class 对象。这是一个 native 方法,具体就不深究了。
官方不建议应用开发者使用这个类,因为会有损性能,并可能造成不正确的字节码操作。后续会被移除出源码。官方建议我们标准的 ClassLoader 类,如 PathClassLoader。

热修复

热修复框架的核心技术主要有三类,分别是代码修复、资源修复和动态链接库修复。

代码修复

代码修复主要有三个方案,分别是底层替换方案、类加载方案和Instant Run方案。

类加载方案

在上面介绍类加载器源码时讲到,DexPathList 内部有一个 Element 数组,一个 Element 对应一个 Dex 文件。查找类时,DexPathList 会遍历 Element,调用他们查找类的方法,找到后立即返回。如果我们将需要修改的类打包成 dex 文件,并放到数组的第一个,那么类加载器就会优先加载我们新的类了。这就是类加载方案的原理。

熟悉类加载机制的朋友知道,在加载一个类前会先看这个类有没有加载过,如果有则直接返回。这意味着类一旦加载,只有重启才能变更。因此这种方案需要重启才能生效。

至于如何实现,不同框架有细微的区别:

QQ空间的超级补丁和 Nuwa

QQ 空间的补丁方案并未开源,但是写了一篇文章介绍其原理。Nuwa 仿造其原理实现并开源。但已经三年没有维护了。
这个框架主要做了两件事:

  • 生成补丁包 patch.dex,加载这个 dex,并通过反射将其插入到 Element 数组的第一个。
  • 防止类被打上CLASS_ISPREVERIFIED标志。这个标志是虚拟机做的优化,一个类直接引用的所有类都在同一个 dex 时会打上这个标志。打上标志后再引用不同 dex 文件中的类就会报错。解决方案是修改字节码,在所有类的构造函数中插入一段代码,这段代码引用了一个类,然后这个类被打包到一个单独的 dex 中。这样所有类都引用了不同 dex 文件中的类。这种做法俗称插桩。这样做能解决问题,但在类加载的最后阶段,虚拟机会对未打上标签的类再次进行校验和优化,如果在同一时间点加载大量类,那么就会出现严重的性能问题,如启动时白屏。

微信的 Tinker

微信自研了 DexDiff/DexMerge 算法。它将新旧 apk 做 diff,生成差异文件 patch.dex。然后通过服务器下发给客户端。客户端收到后,将 patch.dex 与手机中的 classes.dex 做合并,得到新的 dex 文件。然后插入到 dexElement 数组中。由于新的 dex 包含所有的类,所以不用担心CLASS_ISPREVERIFIED标签的问题。不过,Tinker 会启动一个 Service 完成补丁合并的过程,这发生在堆内存上,容易引发 OOM,导致修复失败。

底层替换方案 (Native Hook)

每个方法在 ART 虚拟机中对应一个 ArtMethod 结构体。ArtMethod结构体中包含了Java方法的所有信息,包括执行入口、访问权限、所属类和代码执行地址等等。替换这个结构体或者结构体中的相关信息,就可以达到修改方法的目的。由于是底层直接修改,不涉及到类加载,因此不需要重启。

Dexposed 和 AndFix

这两种都是修改 ArtMethod 结构体中的字段。其中 Dexposed 只支持 dalvik 平台,Andfix 两种都支持。但这两种方案会有兼容问题,因为手机厂商可能会修改ArtMethod结构体,导致方法替换失败。
由于是 hook 方法体,所以不支持新增变量,方法和类。局限性较大。

Sophix

Sophix 结合了两种方案的优点。先采用底层 hook 的方案,当底层 hook 满足不了需求时再使用类加载方案。
前面提到修改ArtMethod字段会造成兼容问题,Sophix 直接替换了整个 ArtMethod 结构体,解决了这个问题。
在类加载的方案中,Art 虚拟机会优先加载名为 classes.dex的文件。Sophix 将旧的 classes.dex 文件重命名,并将新的补丁包命名为 classes.dex。这样就完成了替换。

Instant Run方案

Instant Run 是谷歌官方的增量更新方案。在第一次构建 apk 时,它使用 ASM 在每个方法中注入代码。如果方法有变更,他会生成替换类,并在前面注入的代码中调用这个替换类中对应的方法,实现方法的拦截。

Robust

美团的Robust就是基于这个原理实现的,由原理可知,这种方法不支持资源和 so 文件的替换。但是这种方法的好处是显而易见的,由于在编译时插入了代码,这种方案不需要重启,也没有性能问题。兼容性和稳定性都很不错。

参考资料

Android热修复原理(一)热修复框架对比和代码修复

Android热修复技术原理详解(最新最全版本)