79

Android自定义ClassLoader耗时问题追查

 5 years ago
source link: https://techblog.toutiao.com/2018/06/01/untitled-35/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

猜原因

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

umeamu6.jpg!web

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

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

MnUrUvy.png!web

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

不注入:60μs

注入后:472μs

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

DelegateClassLoader.findClass耗时?

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

NvMjEzq.jpg!web

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

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

java方法调用耗时?

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

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

nUnUFjZ.jpg!web

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

自定义ClassLoader耗时?

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

RRzANra.png!web

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

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

ClassLoader#loadClass

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

7BnmqqI.jpg!web

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

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

PathClassLoader#findClass

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

63UvmaQ.jpg!web

如果说系统对 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 的源码,调用链大致如下:

BrERV3M.jpg!web

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

vyUfmuQ.jpg!web

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

mMZJvmr.jpg!web

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

这样就会走到第二个分支(第25行) ClassLinker::FindClassInPathClassLoader 中 N36vUf3.jpg!web

这里主要分为两个部分:

  • 第一部分:从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/java lang VMClassLoader.cpp。

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

如果 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混合编译与对热补丁影响解析

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK