抖音BoostMultiDex优化实践AndroidAPP首次启动时间减少80%
低版本Andriod因CPU性能和系统版本原因,运行在系统上的程序通常需要做适配和降级处理,其中低版本安卓的冷启动耗时时间长十分突出,字节跳动技术团队深入研究起因,实现最优解决方案,实现APP首次启动时间减少80%。
以下文章来源于字节跳动技术团队 ,作者晓霖
字节跳动的技术实践分享
抖音自研的 BoostMultiDex 方案,可以大幅改善 Android 低版本(4.4 及其以下)手机更新或安装后首次冷启动时间。并且,不同于目前业界所有优化方案,我们是从 Android Dalvik 虚拟机底层机制入手,从根本上解决了安装后首次执行 MultiDex 耗时过长问题。我们上一篇文章中已经介绍了 BoostMultiDex 的核心优化思路,即如何避免 ODEX,直接加载原始 DEX 完成启动。然而用这个方法加载 DEX 文件,相比于 ODEX 优化后的方式,其 Java 代码执行性能上还是有所损失的。我们也可以从前面方法的注释里面看出,虚拟机对于直接加载原始 DEX 的情况只是做了些基本优化:前情回顾:抖音BoostMultiDex优化实践:Android低版本上APP首次启动时间减少80%(一)
The system will only perform "essential" optimizations on the given file.所以,虽然第一次启动我们是加载了原始 DEX 来执行的,但从长远的角度考虑,后续的启动,还是应该尽量采用 ODEX 的方式来执行。因此,我们还需要在第一次启动完成后,在后台适当的时候做好 ODEX 优化。一开始我们是做法也比较简单,在顺利加载 DEX 字节数组,完成启动之后,在后台开辟单独的线程执行DexFile.loadDex就可以了。这样当后台做完 ODEX 后,APP 第二次启动时,就可以直接加载之前做好的 ODEX,得到较好的执行性能。这种做法在线下测试的时候也很正常,然而在上线之后,我们遇到了这样一个问题……
SIGSTKFLT 问题
线上报上来一个 Native Crash,它的堆栈如下所示:Signal 16(SIGSTKFLT), Code -6(SI_TKILL) #00 pc 00016db4 /system/lib/libc.so (write+12) [armeabi-v7a] #01 pc 000884a5 /system/lib/libdvm.so (sysWriteFully(int, void const*, unsigned int, char const*)+28) [armeabi-v7a] #02 pc 00088587 /system/lib/libdvm.so (sysCopyFileToFile(int, int, unsigned int)+114) [armeabi-v7a] #03 pc 00050d41 /system/lib/libdvm.so (dvmRawDexFileOpen(char const*, char const*, RawDexFile**, bool)+392) [armeabi-v7a] #04 pc 00064a41 /system/lib/libdvm.so [armeabi-v7a] #05 pc 000276e0 /system/lib/libdvm.so [armeabi-v7a] #06 pc 0002b5c4 /system/lib/libdvm.so (dvmInterpret(Thread*, Method const*, JValue*)+184) [armeabi-v7a] #07 pc 0005fc79 /system/lib/libdvm.so (dvmCallMethodV(Thread*, Method const*, Object*, bool, JValue*, std::__va_list)+272) [armeabi-v7a] #08 pc 0005fca3 /system/lib/libdvm.so (dvmCallMethod(Thread*, Method const*, Object*, JValue*, ...)+20) [armeabi-v7a] #09 pc 0005481f /system/lib/libdvm.so [armeabi-v7a] #10 pc 0000e3e8 /system/lib/libc.so (__thread_entry+72) [armeabi-v7a] #11 pc 0000dad4 /system/lib/libc.so (pthread_create+160) [armeabi-v7a]APP 收到 SIGSTKFLT 信号崩溃了,同时还输出了这样的日志:
06-25 15:10:53.821 7449 7450 E dalvikvm: threadid=2: stuck on threadid=135, giving up 06-25 15:10:53.821 7449 7450 D dalvikvm: threadid=2: sending two SIGSTKFLTs to threadid=135 (tid=8021) to cause debuggerd dumpSIGSTKFLT 是 Dalvik 虚拟机特有的一个信号。当虚拟机发生了 ANR 或者需要做 GC 的时候,就需要挂起所有 RUNNING 状态的线程,如果此时 Dalvik 虚拟机等待了足够长时间,线程仍旧无法被挂起,就会调用dvmNukeThread函数发送 SIGSTKFLT 信号给相应线程,从而杀死 APP。具体代码如下:
static void waitForThreadSuspend(Thread* self, Thread* thread) { constint kMaxRetries = 10; ... ... while (thread->status == THREAD_RUNNING) { ... ... if (retryCount++ == kMaxRetries) { ALOGE("Fatal spin-on-suspend, dumping threads"); dvmDumpAllThreads(false); /* log this after -- long traces will scroll off log */ => ALOGE("threadid=%d: stuck on threadid=%d, giving up", self->threadId, thread->threadId); /* try to get a debuggerd dump from the spinning thread */ => dvmNukeThread(thread); /* abort the VM */ dvmAbort(); ... ... }而从堆栈我们看出,杀死进程的时候,我们正调用DexFile.loadDex,这个方法最后会调用到dvmRawDexFileOpen里面,执行 write 操作。而这个 write 涉及 I/O 操作,是比较耗时的。所以,当线程在做 dexopt,长时间无法响应虚拟机的挂起请求时,就会触发这个问题。一般来说,虚拟机在执行 Java 代码的时候,都会是 RUNNING 状态。而只要调用了 JNI 方法,在执行到 C/C++代码的时候,就会切换为 NATIVE 状态。而虚拟机只会在 RUNNING 状态下会挂起线程,如果是在 NATIVE 状态下,虚拟机是不会要求线程必须挂起的。不过,这里有一个特殊之处。虽然DexFile.loadDex方法最终也走到了 JNI 里面调用dvmRawDexFileOpen函数,但由于DexFile类是虚拟机的内部类,Dalvik 虚拟机不会在内部类执行 JNI 方法的时候将线程切换为 NATIVE 状态,仍然会保持原来的 RUNNING 状态。于是,在 RUNNING 状态下,做 OPT 的线程就会被要求挂起。而此时由于正在执行耗时的 write 操作,无法响应挂起请求,便出现了如上的崩溃。当然,可能有人会想到在 Native 代码中,用CallStaticObjectMethod来触发DexFile.loadDex,不过这种方式是不可行的。因为CallStaticObjectMethod调用 Java 方法DexFile.loadDex时,会使得状态再次切换为 RUNNING。具体来看下 CallStatciXXXMethod 方法的定义处:
static _ctype CallStatic##_jname##Method(JNIEnv* env, jclass jclazz, jmethodID methodID, ...) { UNUSED_PARAMETER(jclazz); ScopedJniThreadState ts(env); JValue result; va_list args; va_start(args, methodID); dvmCallMethodV(ts.self(), (Method*)methodID, NULL, true, &result, args); va_end(args); if (_isref && !dvmCheckException(ts.self())) result.l = (Object*)addLocalReference(ts.self(), result.l); return _retok; }关键在于 ScopedJniThreadState:
explicit ScopedJniThreadState(JNIEnv* env) {
mSelf = ((JNIEnvExt*) env)->self;
... ...
CHECK_STACK_SUM(mSelf);
dvmChangeStatus(mSelf, THREAD_RUNNING);
}
~ScopedJniThreadState() {
dvmChangeStatus(mSelf, THREAD_NATIVE);
COMPUTE_STACK_SUM(mSelf);
}
在使用dvmCallMethodV调用 Java 方法前,会先切换状态为THREAD_RUNNING,执行完毕后,ScopedJniThreadState析构,再切换回THREAD_NATIVE。这样,JNI 执行DexFile.loadDex就和直接执行 Java 代码一样,状态会有问题。不只是CallStaticXXXMethod,所有使用CallXXXMethod函数在 Native 下调用 Java 方法的情况都是如此。好在,我们想到了另一个办法:既然 Dalvik 不会对内部类的 JNI 调用做切换,我们就自己写一个 JNI 调用,使其走到 Native 代码中,这样线程就会变为 Native 状态,然后直接调用虚拟机内部函数做 dexopt 即可。这样在做 dexopt 的时候,始终会处于 NATIVE 的状态,不会切为 RUNNING,也不会被要求挂起,也就能避免这个问题。这个虚拟机内部函数就是dvmRawDexFileOpen,我们先来看下它的代码说明:
/* * Open a raw ".dex" file, optimize it, and load it. * * On success, returns 0 and sets "*ppDexFile" to a newly-allocated DexFile. * On failure, returns a meaningful error code [currently just -1]. */ int dvmRawDexFileOpen(const char* fileName, const char* odexOutputName, RawDexFile** ppDexFile, bool isBootstrap);这个函数可以用来打开原始 DEX 文件,并且对它做优化和加载。对应到 libdvm.so 中的符号是_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb,我们只需要用 dlsym 在 libdvm.so 里面找到它,就可以直接调用了,完整代码如下:
using func = int (*)(constchar* fileName, constchar* odexOutputName, void* ppRawDexFile, bool isBootstrap); void* handler = dlopen("libdvm.so", RTLD_NOW); dvmRawDexFileOpen = (func) dlsym(handler, "_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb"); dvmRawDexFileOpen(file_path, opt_file_path, &arg, false);这样,我们自己写一个 JNI 调用,在 Native 状态下执行上述代码,就能达到完成 ODEX 的目的,从而根本上杜绝这个异常了。另外,我们把 dexopt 操作放到了单独进程执行,由此可以避免 ODEX 操作对主进程造成其他性能影响。此外,由于设备情况多种多样,运行环境十分复杂,还可能会有一些厂商魔改,导致的 dlsym 找不到_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb符号,虽然这种情况极为罕见,但理论上仍有可能发生。单独进程里面由于环境比较纯粹,基本很少发生 ANR 和 GC 事件,挂起的情况就很少,也能最大程度规避这个问题。
多级加载
我们发现,相比于官方 MultiDex 加载 ZIP 形态的 DEX 文件,非 ZIP 方式的 DEX(也就是直接对 DEX 文件做 ODEX,而不用先把 DEX 压缩进 ZIP 里面)对于整体时间也有一定程度的优化,因为这种非 ZIP 方式避免了原先的两个耗时:- 把原始 DEX 压缩为 ZIP 格式的时间;
- ODEX 优化的时候从 ZIP 中解压出原始 DEX 的时间。
- 从 APK 文件里面解压得到的 DEX 字节数组;
- 从落地的 DEX 文件里面得到的 DEX 字节数组;
- 从 DEX 文件优化得到的 ODEX 文件;
- 从 ZIP 文件优化得到的 ODEX 文件。
我们依次说明每一步:
- A. 从 APK 里面直接解压得到 DEX 字节数组;
- B. 将 DEX 数组保存为文件;
- C. 用 DEX 文件生成 ODEX 文件;
- D. 用 DEX 数组生成 ZIP 文件以及它对应的 ODEX 文件。
-
当 APP 首次启动的时候,如果会从 APK 里面解压 DEX 数组,因此会按照 a -> b 的路径执行;
-
当 APP 发现只有 DEX 文件,没有 ODEX 文件时,会把从 DEX 文件中取得 DEX 数组,按照 c -> b 路径执行;
-
当 APP 发现 DEX 文件和 ODEX 文件都存在的时候,会按照 ODEX 方式加载,按照 d 路径执行;
-
当 APP 发现有 ZIP 文件以及它所对应的 ODEX 的时候,会按照 e 路径执行。
进程锁优化
前面提到,OPT 优化是在单独的进程里面执行的。单独进程除了可以减少前面的 SIGSTKFLT 问题,还能在做完 OPT 后及时终止后台进程,避免过多的资源占用。然而,在单独进程处理 OPT 和其他进程执行 install 的时候,都涉及到 DEX 和 ODEX 文件的访问和生成,因此在这些进程之间涉及到文件访问和 OPT 时,都是加文件锁互斥执行的。这样可以避免加载的同时,另一个进程在操作 DEX 和 ODEX 文件导致的文件损坏。在官方的 MultiDex 中也是采用这种文件锁的方式来进行互斥访问的。但这带来了另一个问题,如果 OPT 进程在长时间做 dexopt,而此时主进程(或者其他后台进程)需要再次启动,便会因为 OPT 进程持有互斥文件锁,而导致这些进程被阻塞住无法继续启动。可以看流程图来理解这一过程:正如图中描绘的场景,用户第一次打开了 APP,然后运行一会之后因为一些情况杀死了 APP,这时,后台进程已经启动并正在做 OPT。如果此时用户想要再次打开,就会由于 OPT 进程互斥锁导致阻塞而黑屏。这显然是不可接受的。因此,我们就需要采取更好的策略,使得在主进程能够正常地继续往下执行,而不至于被阻塞住。这个问题的关键在于,主进程需要依赖 OPT 进程的产物,才能继续往下执行,而 OPT 进程此时正在操作 DEX 文件,这个过程中的产物必定无法被主进程直接使用。所以,如果想要主进程不再因 OPT 操作阻塞,我们很容易想到可以无视 OPT 进程,不使用 DEX 文件,只从 APK 里面获取内存形式的 DEX 字节码就可以了。不过这种方式的主要问题在于,如果 OPT 时间非常长,在这段时间内就不得不一直使用内存方式的 DEX 启动 APP,这样性能就会处于比较差的水平。因此我们采用的是另一种方案。在主进程退出而再次启动的时候,先中止 OPT 进程,直接取得现有 DEX 产物进行加载,然后再唤起 OPT 进程。如下图所示:这里关键点在于如何中止进程。当然,我们可以直接在主进程发信号杀死 OPT 进程,不过这种方式过于粗暴,很可能导致 DEX 文件损坏。而且 kill 信号的方式没有回调,我们无法得知是否进程确实地退出了。因此,我们采取的方式是用两个文件锁来做同步,保证进程启动和退出的信息可以在多个进程之间传达。第一个文件锁就是单纯用来作为互斥锁,保证处理 DEX 和加载 DEX 的过程是互斥发生的。第二个文件锁用来表示进程即将获取互斥锁,我们称之为准备锁,它可以用来通知 OPT 进程:此时有其他进程正需要加载 DEX 产物。对于 OPT 进程而言,获取文件锁的步骤如下:- 获取互斥锁;
- 执行 OPT;
- 非阻塞地尝试获取准备锁;
- 如果没有获取到准备锁,表示此时有其他进程已经持有准备锁,则释放互斥锁,并退出 OPT 进程;
- 如果获取到了准备锁,表示此时没有其他进程正常持有准备锁,则再次执行第 2 步,做下个文件的 OPT;
- 完成所有 DEX 文件的 OPT 操作,释放互斥锁,退出。
- 阻塞等待获取准备锁;
- 阻塞等待获取互斥锁;
- 释放准备锁;
- 完成 DEX 加载;
- 释放互斥锁;
- 继续往下执行业务代码。
具体情形见下图:
首先,OPT 进程开始执行,会获取到互斥锁,然后做 DEX 处理。OPT 进程在处理完第一个 DEX 文件后,由于没有其他进程持有准备锁,因此 OPT 进程获取准备锁成功,然后释放准备锁,继续做下一个 DEX 优化。这时候,主进程(或其他非 OPT 进程)启动,先成功地获取准备锁。然后继续阻塞地获取互斥锁,此时由于 OPT 进程已经在前一步获取到了互斥锁,因此只能等待其释放。OPT 进程在处理完第二个 DEX 后,检测到准备锁已经被其他进程持有了,因此获取失败,从而停止继续做 OPT,释放互斥锁并退出。此时主进程就可以成功地获取到互斥锁,并且立即释放准备锁,以便其他进程可以获取。接着,在完成 DEX 加载后,释放互斥锁,继续执行后续业务流程。最后再唤起 OPT 进程接着做完原先的 DEX 处理。总体看来,在这种模式下,OPT 进程可以主动发现有其他进程需要加载 DEX,从而中断 DEX 处理,并释放互斥锁。主进程便不需要等待整个 DEX 处理完成,只需要等 OPT 进程完成最近一个 DEX 文件的处理就可以继续执行了。实测数据
我们本地选取了几台 4.4 及以下的设备,对它们首次启动的 DEX 加载时间进行了对比:以上是在抖音上测得的实际数据,APK 中共有 6 个 Secondary DEX,显而易见,BoostMultiDex 方案相比官方 MultiDex 方案,其耗时有着本质上的优化,基本都只到原先的 11%~17%之间。也就是说 BoostMultiDex 减少了原先过程 80%以上的耗时。 另外我们看到,其中有一个机型,在官方 MultiDex 下是直接崩溃,无法启动的。使用 BoostMultiDex 也将使得这些机型可以焕发新生。另外,我们在线上采取了对半分的方式,也就是 BoostMultiDex 和原始 MultiDex 随机各自选取一半线上设备,对比二者的耗时。我们先以设备维度来看,这里随机选取了 15 分钟的线上数据,图中横轴为每个 Android 版本 4.4 及以下的设备,纵轴为首次启动加载 DEX 的耗时,按耗时升序排列,单位为纳秒。BoostMultiDex 下的设备耗时:MultiDex 下的设备耗时:两张图最大的区别在于纵轴的时间刻度。可以看到,绝大多数设备的 BoostMultiDex 耗时在 5s 左右,最多耗时也不会超过 35s。而反观 MultiDex,大多数都需要耗时 30 多 s,最长的耗时甚至达到了将近 200s。上面的图可能差别不够明显,我们选取一段时间,每半小时取所有设备耗时的中位数,可以得到下面的对比曲线:其中,下方橙色线为 BoostMultiDex,上方蓝色线为原始 MultiDex,可以明显看出,耗时下降的幅度非常巨大。耗时的大幅减少会带来怎样的效果呢?我们统计了 4.4 及以下机型中,两者进入到抖音播放页的设备数占比,时间范围为一周,其中右边橙色为 BoostMultiDex,左边蓝色为原始 MultiDex。由于我们所有设备对于两种方案的选取是对半开的,所以理论上二者的设备数应该接近于 1 比 1,不过从图中我们可以看到,BoostMultiDex 的设备数已经大幅超过 MultiDex 的设备数,两者比例接近于 2 比 1。从中可以看出,MultiDex 耗时的减少对于设备活跃数的提升,效果十分显著!
总结
最后,我们再梳理一下整个方案的实现要点:- 采用openDexFile_bytearray函数,可以直接加载原始 DEX 字节码;
- 提前注入dex_object对象,以解决 4.4 机型上加载原始 DEX 字节码时,getDex的崩溃问题;
- 采用dvmRawDexFileOpen函数做 ODEX,以解决 SIGSTKFLT 问题;
- 多级加载,在 DEX 字节码、DEX 文件、ODEX 文件中选取最合适的产物启动 APP;
- 单独进程做 OPT,并实现合理的中断及恢复机制。
对于国内偏远地区,尤其对于海外许多发展中国家,Android 低版本机型仍然占比较高。目前 BoostMultiDex 方案在抖音和 TikTok 已经全量上线,这会使得这部分低版本 Android 用户直接受益,极大优化升级和安装启动体验。我们后续将开源 BoostMultiDex 方案,以协助其他 APP 在低版本 Android 手机上改进性能体验。今后,各家对下沉市场有需要的 APP,都能直接使用 BoostMultiDex 方案,立即获得飞一般的升级安装体验!这也是我们为改善 Android 生态贡献的一小份力,后续很快就会发布开源地址,敬请期待!
【如有意向认证本平台签约作者 可扫描二维码或添加微信联系:baozhilin76】
【如有侵权,请联系删除】
已有人点赞
已有人收藏