Android自定义ClassLoader耗时问题追查

最近在优化西瓜视频客户端冷启动速度时,发现在关闭插件 ClassLoader 注入的情况下,启动速度提升了300ms左右,但是西瓜在启动阶段并没有使用到插件,那么这么大的耗时是怎么来的呢?

猜原因

首先看下西瓜目前使用的插件 ClassLoader 是怎么注入的,大致代码如下:

image

代码大致意思是在 PathClassLoader 和 BootClassLoader 之间插入了一个 DelegateClassLoader,而在 DelegateClassLoader 的 findClass 方法中去执行插件 Class 的加载。

为了方便验证,写一个简单的测试Demo,测试加载一个类的耗时:

image

以小米Max2,Android7.1.1机型为例,测试不注入和注入 DelegateClassLoader 加载一个类的耗时:

不注入:60μs

注入后:472μs

差不多慢了8倍,测试了几款手机基本数据都差不多,但是4.x手机上这两种情况下耗时差别却很小。

DelegateClassLoader.findClass耗时?

因为双亲委托机制,所以宿主中所有类的加载都会走到 DelegateClassLoader.findClass 中,但是 DelegateClassLoader 中因为不存在宿主类,所以必然找不到,因此一个宿主类的加载会多调用了一次无用的 findClass 方法,一次findClass的调用会带来如此大的耗时?于是将 DelegateClassLoader 代码精简成下面这样的:

image

这样,DelegateClassLoader 中没有做任何插件类加载的逻辑,只是做了一个中转到父 ClassLoader 的 loadClass 的操作。

结果依然是8倍左右的耗时差距。

java方法调用耗时?

上面方案里只是比不注入自定义 ClassLoader 多了一次 DelegateClassLoader.loadClass 方法的调用,理论上不可能存在这么大的耗时。如果说多调用一次 java 方法 DelegateClassLoader.loadClass 会有8倍的耗时差异的话,那么多调用两次是不是就是16倍的差异?

于是尝试注入两个 DelegateClassLoader,类似这样:

image

但是结果还是8倍左右的耗时差异,并非16倍,这么说不是方法调用带来的性能损耗。

自定义ClassLoader耗时?

所以猜测可能是系统对 PathClassLoader 有什么优化?然后直接构造一个空的 PathClassLoader 注入到 PathClassLoader 和 BootClassLoader 中间,类似这样:

image

神奇的8倍耗时差异没了!所以真的是系统对 PathClassLoader 有优化?

带着这个疑问我们来看下 ClassLoader 的源码,以 Android 7.1.1 源码为例。

ClassLoader#loadClass

首先来看下源头,ClassLoader 的 loadClass 源码,核心代码如下:

image

大致流程是先调用 findLoadedClass 尝试从已加载的 class 中查找,然后再调用父 ClassLoader 的 loadClass 查找,如果依然没有找到的话,最后再调用自己的 findClass 加载。

在 JVM 中,类第一次加载时,肯定之前是没有加载过的,因此 findLoadedClass 应该是返回 null 的,而 BootClassLoader 中只有系统类,因此宿主类的加载应该是调用了 PathClassLoader#findClass 加载的。

PathClassLoader#findClass

那么我们再来看看 PathClassLoader#findClass 的源码,调用链大致如下:

image

如果说系统对 ClassLoader 有某些优化,那么应该只要重点关注在调用链中有用到 ClassLoader 的地方即可。

整个 findClass 流程中使用到 ClassLoader 的地方并不多,只有 ClassLinker::RegisterDexFile 和 ClassLinker::SetupClass 中使用到了。

  • ClassLinker::RegisterDexFile 中是对 ClassLoader 取 class_table 的简单操作;

  • ClassLinker::SetupClass 中是给加载好的 class 设置 ClassLoader,两个方法对 ClassLoader 的操作看上去是不存在任何优化的,理论上不会导致性能损耗,这里不再贴代码。

如果不是 findClass 里有优化,难道在 ClassLoader#findLoadedClass 里?

ClassLoader#findLoadedClass

再来看看 ClassLoader#findLoadedClass 的源码,调用链大致如下:

image

首先来看下c层调用的第一个方法 VMClassLoader_findLoadedClass :

image

这里主要有两个分支,第一个分支,第12行调用 ClassLinker#LookupClass :

image

这里大致意思是从 ClassLoader 中找到 ClassTable ,然后调用 ClassTable#Lookup 而这个 ClassTable 里面就保存了已经加载过的类以及启动时从 app image 中加载的类(app image的作用是记录已经编译好的“热代码”,并且在启动时一次性把它们加载到缓存,参考Tinker博客)。如果一个类是首次加载且不在 app image 中,那么这里会返回 null。

这样就会走到第二个分支(第25行) ClassLinker::FindClassInPathClassLoader 中 image

这里主要分为两个部分:

  • 第一部分:从37行开始,反射从 Java 层的 PathClassLoader 取得 DexPathList,然后再反射从 DexPathList 中取得 dexElements,然后再遍历 dexElements,从每个 Element 中取得 dexFile,然后再从 DexFile 中取得 mCookie,然后通过 mCookie 得到 c 层的 DexFile,最后调用 c 层 DexFile#FindClassDef 来真正的执行类的加载,整个流程其实就是在 c 层把 Java 层的 PathClassLoader#findClass 逻辑走了一遍;

  • 第二部分:采用递归的方式,从 BootClassLoader 开始依次到 PathClassLoader 逐个调用 FindClassInPathClassLoader,直到找到 class 为止,相当于把 Java 层 ClassLoader 的双亲委托加载 class 的机制在 c 层做了一遍,这个其实是 ART 上对 class 加载做的一个优化,但是在 Dalvik 中是没有这段逻辑的,可以参考/dalvik/native/javalangVMClassLoader.cpp。

重点来了!因为上面使用到了反射机制取 PathClassLoader 中的字段,为了保证这套机制不出问题,这里面加了个校验: image

如果 ClassLoader 链中存在不认识的 ClassLoader,也就是说 ClassLoader 的类不是 BootClassLoader 和 PathClassLoader,那么就认为加载类失败。当然这里加载失败的话,并不会影响最终类加载结果,因为在 Java 层 findLoadedClass 失败后,会走到 findClass 中的。

结论

在 Android ART 中默认的 ClassLoader 机制,在 ClassLoader#findLoadedClass 时就把 JVM 中的 findLoadedClass 和 findClass 两件事情都做了。但是如果在 class loader 链中存在自定义 ClassLoader,那么这个机制就会失效,会回退到 JVM 默认的 ClassLoader 机制。

回到上面的问题,由于我们自定义了 ClassLoader,导致 Art 的 ClassLoader 机制回退到了 JVM 的默认类加载机制,而 JVM 默认的类加载机制存在多次 JNI 调用,JNI 调用本身性能是比直接方法调用耗时高几倍的,这里不再详细展开,因此也就能解释前面所说的几倍的耗时差异了。

参考

  • Android N混合编译与对热补丁影响解析
更新日期:
本文总阅读量