阅读本文你能收获到
- 了解 Java/Android中类加载器及其工作流程
- Android 开发过程中类加载器的应用场景
类加载器
类加载器是虚拟机运行时动态加载字节码文件的入口。注意这里的虚拟机并不特指 Java虚拟机 或 Android虚拟机。通常来说开发者写的代码经过“前端编译器”编译成字节码文件集(Java-jar, Android-dex等)之后交给虚拟机,而虚拟机加载这些字节码文件集就是通过类加载器完成。
应用程序包的字节码文件集是经过“前段编译器”精挑细选得到的,程序运行所需要的任意代码及资源都被打进程序包中。但实际上用户在使用程序时往往难以走遍程序所有逻辑,因此虚拟机只需按需加载当前用户场景所涉及到的代码。这便决定了类加载器的加载场景 “按需加载”。
应用程序包实际运行是依赖“运行环境”的。比如 Java 程序需要依赖 JRE 进行运行,而 Android 程序则依赖 Application Framework。当然这里讨论的仅仅是下面第一层,更下层的还有Kernel等不在讨论范围内。而这些环境运行时提供了运行程序库依赖的支持。如果应用程序包中使用了运行环境中的类,那运行时这部分类该如何加载? 同时,应用程序内的类又是如何加载的呢? 事实上,虚拟机对要加载的类进行“域”划分,不同“域”的加载委托给不同的类加载器进行加载,这种加载模式称为 “委托加载”。
委托加载
除了系统提供的类加载,开发者也可以自定义 ClassLoader,在整个应用程序加载流程中所有类加载器有严格的加载逻辑, 按照 ”按需加载,委托加载“ 的思想组合起来一起完成类加载,这种模型称为 ”双亲委派模型“。在了解 Java 和 Android 平台的类加载器之前,我们先看下委托加载的流程。整个 “委托加载” 的思想都体现在 Java.lang.ClassLoader, 两个平台的加载流程方向上是一致的。
ClassLoader
是一个抽象类, 没有抽象方法, 加载流程统一集中在 ClassLoader#loadClass.
1 | protected Class<?> loadClass(String name, boolean resolve) |
1处
通过 native (VMClassLoader实现)手段检测当前 ClassLoader 是否已经加载过该类, 如果加载过则直接返回。2处
当且仅当 ClassLoader 没有加载过类且父加载器存在时, 尝试调用 parent#loadClass 获取3处
当且仅当 ClassLoader 没有加载过类且父加载器不存在时, 尝试通过 native 手段从引导类加载器获取- 如果上述途径都没有获取到, 则 findClass 获取, 默认方法内抛出异常, 子类需要实现覆盖实现自己的逻辑。
活动图如下
值得注意的是, loadClass 一个 protected 权限级别的方法, 这意味着 ”双亲委派模型“ 并不是唯一的加载模式而是系统建议我们使用的模式。
再来看看 Java 和 Android 平台提供哪些类加载器, 以 Android 加载器的内容重点展开。
Java 流派
Java系统主要提供了 3 种类加载器
Bootstrap Classloader
,启动类加载器,负责加载 \lib 目录下或者被 -Xbootclasspath参数所指定的路径种的,能被虚拟机识别的类库 (即所有 java.* 开头的类)。Extension Classloader
,扩展类加载器,负责加载 \lib\ext 目录下或者被 java.ext.dirs 系统变量指定路径的类库(例如所有 javax.* 开头的类和存放在 JRE 的 ext 目录下的类)。Application Classloader
,应用程序类加载器,负责加载用户类路径指定的类库,开发者可以直接使用这个类加载器(即我们自己写的 Java程序时新创建的类都是通过它来接在的)。
Android 流派
Android系统也提供了多种类加载器, 这些类加载器都由 java.lang.ClassLoader
继承而来。
ps: 本文所涉及到的源码均以 Andriod P 版本作为展开分析
以 Android 类加载为例子,这里画了类图方便直观预览。
上述类图除了橙色的 DexPathList
类之外, 其他都是 Android 提供的类加载器。按照 Java 系统类加载器的划分, Android 类加载器大致也可以划分 3 种类加载器。
- 加载 Framework 层类,
BootClassLoader
为该类型类加载器, 在 Android 系统启动的时候创建该实例, 当应用程序系统也需要用到 Framework 类是会传递该类加载器实例给应用层。 - 加载已经安装的 apk 中的类,
PathClassLoader
为该类型类加载器。 - 加载jar/apk/dex,未安装过的 apk 中的类,
DexClassLoader
或PathClassLoader
为该类型类加载器。
这里可能和你平时在网上看到的结论是不一样的, PathClassLoader 也可以加载外部 dex 了? 肯定的啊。 看下面分析。
BaseDexClassLoader
Android 应用层的类加载逻辑基本围绕
BaseDexClassLoader
及子类开展的, 也是应用层开发热修复, 插件化技术中重要的技术基础。下面重点分析这部分内容, 在掌握这部分知识之前, 希望我们能达成一致的共识 “带着问题在源码中找答案”。
待解决的问题 :
- BaseDexClassLoader的设计初衷是什么?
- 如何加载 Dex 文件 ?
- 子类的应用场景是什么 ?
- 实际开发过程我们可以如何使用 ?
设计初衷
由于移动端架构及性能原因, Android 针对 class 文件进一步优化形成 dex 文件。 为了适配加载 dex 文件, BaseDexClassLoader
应运而生。 在BaseDexClassLoader.DexPathList 源码中可知, BaseDexClassLoader
加载 dex 实际上是委托 DexPathList
对象进行的。
如何加载 Dex 文件
在 BaseDexClassLoader
中有一个重要的成员变量 pathList
, 是一个 DexPathList
类型对象。结合 BaseDexClassLoader.DexPathList源码及类图可以理解, DexPathList
托管处理了 BaseDexClassLoader
查找资源的过程。
1 | //from BaseDexClassLoader.java 部分源码做过删减 |
在创建 BaseDexClassLoader
对象的同时也创建了 DexPathList
对象。 sharedLibraryLoaders 是共享的 Loader 数组, 在查找 Resource 和 Class 是优先从 sharedLibraryLoaders 中获取, 这些 Loader 也是调用各自的查找方法, 最终会依赖 DexPathList 对象进行查找。
在熟悉 DexPathList 之前看下其构造器。
1 | private Element[] dexElements; |
- definingContext 为关联的 ClassLoader
- dexFiles 内存中已经存在的 dex 缓存
- librarySearchPath native 库的路径
- optimizedDirectory 存放优化过的 dex 文件
dexElements 是存放加载过的 dex 或 resource 资源。
下面重点看下 findClass 逻辑
1 | public Class<?> findClass(String name, List<Throwable> suppressed) { |
Element
是一个静态类, 封装了一个 Dex 单元. 而 findClass 从 dexElements 数组中获取, 所以追踪 dexElements 初始化。
- 构造器-从ByteBuffer[]中读取(调用 makeDexElements)
- 构造器-从dexPath中读取(调用 makeInMemoryDexElements)
- addDexPath-从新的dexPath中读取 (直接 new Element[])
makeInMemoryDexElements 方法直接读取 dexFiles(ByteBuffer[]), 类似于写文件。makeDexElements 则是从特定 path 下读取 dex 。
1 | private static Element[] makeDexElements(List<File> files, File optimizedDirectory, |
从 dexPath 中读取文件数组之后, 无论是否以 .dex 后缀结尾的文件, 都通过 loadDexFile 方法进行读取。唯一不同的地方在于, 当 optimizedDirectory 不为 null 时则通过 DexFile.loadDex 加载, 否则直接 new 一个 DexFile
对象。
optimizedPathFor 是用于转换文件后缀名的。 如果文件没有带.标识符则默认拼接 .dex
后缀, 否则则改写后缀为 .dex
。 这样做得意义在于虚拟机能通过后缀名识别包含 dex 的文件提高读取效率。
DexFile.java 是如何加载 dex 的呢?
1 | private DexFile(String sourceName, String outputName, int flags, ClassLoader loader, |
DexPathList#loadDexFile
最终都是调用的是 DexFile 构造器。optimizedDirectory 不为 null 的场景下:outputName 为 null 且 flags 为 0。 openDexFileNative 是一个 native 方法。
nativa 方法如何追踪呢, 下载 aosp源代码之后, 使用 vs 进行预览。 DexFile 的包名为 “dalvik.system”, 则 native 文件名为 “dalvik_sytem_DexFile” 搜索一下就定位到了。 其中 DexFile_openDexFileNative 方法就是对应的 native 方法。
native 代码在虚拟机内部。dalvik_system_DexFile.cc 为官方源码。有兴趣可以 dowm 下来看看。
下面为整个 dex 加载流程的时序图.
子类应用场景
BaseDexClassLoader的众多子类并没有覆盖其任何方法, 唯一不同的地方在于每个子类的构造器调用 super 参数不一致。
1 | //from PathClassLoader.java |
可以确定的是 InMemoryDexClassLoader 不从 dexPath 路径中加载 dex 文件而是从 dex 缓存内容中读取。 但是 DexClassLoader
和 PathClassLoader
存在一样的构造器啊。翻开 BaseDexClassLoader 文档一开
1 | optimizedDirectory File: this parameter is deprecated and has no effect since API level 26. |
optimizedDirectory 在 8.0 版本废弃会有什么影响呢 ? optimizedDirectory 是存放被优化过的 dex。在 dex 文件首次被加载的时候, 虚拟机会执行 dexopt
操作, 而 optimizedDirectory 就是优化后的odex文件的存放目录。在 native
加载 dex过程中, 如果 outputName 参数为 null 时则默认把优化后的的dex保存在为 `/data/dalvik-cache/xxx@classes.dex。 而
PathClassLoader默认 *outputName* 参数为 null, 则 app 启动加载dex生成的优化文件正是存放在
/data/dalvik-cache/xxx@classes.dex。8.0 之前 DexClassLoader 可以指定生成 odex 文件存放的目录,而 8.0 之后则不可以了, 默认都存在
/data/dalvik-cache/xxx@classes.dex`。 从官网最新的文档上可以看到
1 | //from PathClassLoader.java |
就是为了保证新版本DexClassLoader
加载生成的 odex 文件不被随便存放, 其目的是为了避免加载的内容会对应用造成攻击。
这里放一下 app 默认的 PathClassLoader 加载流程:
- ActivityThread#handleBindApplication
- ContextImpl#getClassLoader
- LoadedApk#makePaths(计算dexPath) -> LoadedApk#getClassLoader
- ApplicationLoaders#getClassLoader
- PathClassLoaderFactory#createClassLoader
- PathClassLoader#构造器
有兴趣的朋友可以翻阅源码查看。
在 api 27 (Android 8.1) 中新增 DelegateLastClassLoader
, 该类继承 PathClassLoader
。在 委托加载 章节中最后提到, “双亲委派模型”的加载并不是唯一的方式, 而 DelegateLastClassLoader
就是最好的证明。
1 | //from DelagateLastClassLoader.java |
1处
通过 native (VMClassLoader实现)手段检测当前 ClassLoader 是否已经加载过该类, 如果加载过则直接返回。2处
尝试通过 native 手段从引导类加载器获取3处
则 findClass 获取, 默认方法内抛出异常, 子类需要实现覆盖实现自己的逻辑4处
则尝试调用 parent#loadClass 获取
实际开发的应用
插桩实现
广为人知的 Qzone 补丁方案就是通过插桩实现热修复, 下面为伪代码(8.0版本之前), 略去反射实现
1 | // 1 |
1处
加载补丁 dex2处
反射获取当前 dex 数组3处
把补丁 dex 查到 当前 dex 数组前面
这样一来, 在 DexPathList#findClass
过程中, 新插入的 element 元素就会优先被遍历到使用。
但是, 如果 currentElements 中某个 dex 中的类 A 持有 newElements 中某个 dex 中的类 B 的引用, 运行时会出现 Class ref in pre-verified clas resolved to unexpected implementation
。了解原因前得清楚虚拟机在 native 加载类的时候大致会做哪些工作。
dexopt 会进行类校验 。比如校验 A 中 “static 方法,private 方法,构造函数,虚函数第一层引用 (c++)” 是否存在对某些类的引用且他们和 A 是在同个dex。 如果是则 A 会打上 “CLASS_ISPREVERIFIED” 。
虚拟机加载类的顺序为: dvmResolveClass -> dvmLinkClass -> dvmInitClasss 。 在 dvmResolveClass 会校验如果类被打上 CLASS_ISPREVERIFIED,则需要检验其引用类是否是同一个 dex ,不在同个 dex 则抛出 Exception。
既然如此, 那么就在 A 的 “static 方法,private 方法,构造函数,虚函数第一层引用 (c++)” 中任意一处引用另外一个 dex 的某个类来防止 A 被打上 “CLASS_ISPREVERIFIED”。 可以借助 gradle 入侵 dex 打包流程, 利用字节码技术对所有类的构造器插入对该 dex 中某个类的引用进而解决问题。
tinker Android N 类加载
Android_N混合编译与对热补丁影响解析 中一文已经明确指出 “无论是使用插入pathlist还是parent classloader的方式,若补丁修改的class已经存在与app image,它们都是无法通过热补丁更新的。它们在启动app时已经加入到PathClassLoader的ClassTable中,系统在查找类时会直接使用base.apk中的class。”
为了解决这种问题, tinker 团队选择在 Android N 及以上版本采用 “运行时替换PathClassLoader方案” 以达到废除 cache 效果。
SystemClassLoaderAdder#installDexes 中针对版本构建不同的 ClassLoader 对象。
1 | public static void installDexes(Application application, PathClassLoader loader, File dexOptDir, List<File> files) |
在 Android N 上选择 AndroidNClassLoader 。
1 | private static AndroidNClassLoader createAndroidNClassLoader(PathClassLoader originalClassLoader, Application application) throws Exception { |
1处
创建 androidNClassLoader 对象2处
反射获取原来的 originPathList 对象3处
通过反射取出 originPathList 对象的各个属性信息并反射构建一个新的DexPathList
对象4处
DexPathList
对象关联 androidNClassLoader5处
androidNClassLoader 持有DexPathList
对象
而在 findClass 过程中针对 application, tinker 库内的 loader 及一些特殊库文件, 默认使用 PathClassLoader 加载, 其他的优先使用 androidNClassLoader 查找。
1 | public Class<?> findClass(String name) throws ClassNotFoundException { |
鉴于自己能力有限,如果本文中有遗漏或者错误的地方,请在评论区指出或者通过 yummyl.lau@gmail.com 邮件联系我,感谢。