其他
抖音BoostMultiDex优化实践:Android低版本上APP首次启动时间减少80%(二)
Editor's Note
低版本Andriod因CPU性能和系统版本原因,运行在系统上的程序通常需要做适配和降级处理,其中低版本安卓的冷启动耗时时间长十分突出,字节跳动技术团队深入研究起因,实现最优解决方案,实现APP首次启动时间减少80%。
The following article is from 字节跳动技术团队 Author 晓霖
The system will only perform "essential" optimizations on the given file.
DexFile.loadDex
就可以了。这样当后台做完 ODEX 后,APP 第二次启动时,就可以直接加载之前做好的 ODEX,得到较好的执行性能。这种做法在线下测试的时候也很正常,然而在上线之后,我们遇到了这样一个问题……SIGSTKFLT 问题
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]
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 dump
dvmNukeThread
函数发送 SIGSTKFLT 信号给相应线程,从而杀死 APP。static void waitForThreadSuspend(Thread* self, Thread* thread)
{
const int 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,长时间无法响应虚拟机的挂起请求时,就会触发这个问题。DexFile.loadDex
方法最终也走到了 JNI 里面调用dvmRawDexFileOpen
函数,但由于DexFile
类是虚拟机的内部类,Dalvik 虚拟机不会在内部类执行 JNI 方法的时候将线程切换为 NATIVE 状态,仍然会保持原来的 RUNNING 状态。于是,在 RUNNING 状态下,做 OPT 的线程就会被要求挂起。而此时由于正在执行耗时的 write 操作,无法响应挂起请求,便出现了如上的崩溃。CallStaticObjectMethod
来触发DexFile.loadDex
,不过这种方式是不可行的。因为CallStaticObjectMethod
调用 Java 方法DexFile.loadDex
时,会使得状态再次切换为 RUNNING。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; \
}
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 方法的情况都是如此。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);
_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb
,我们只需要用 dlsym 在 libdvm.so 里面找到它,就可以直接调用了,完整代码如下:using func = int (*)(const char* fileName, const char* 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);
_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb
符号,虽然这种情况极为罕见,但理论上仍有可能发生。单独进程里面由于环境比较纯粹,基本很少发生 ANR 和 GC 事件,挂起的情况就很少,也能最大程度规避这个问题。多级加载
把原始 DEX 压缩为 ZIP 格式的时间; ODEX 优化的时候从 ZIP 中解压出原始 DEX 的时间。
openDexFile_bytearray
加载 DEX 的方式,需要的只是原始 DEX 文件的字节数组(byte[])。这个字节数组我们在首次冷启动的时候是直接从 APK 里面解压提取得到的。我们可以在这次启动提取完成后,先把这些字节数组落地为 DEX 文件。这样如果再次启动 APP 的时候,ODEX 没做完,就可以直接使用前面保存的 DEX 文件来得到字节数组了,从而避免了从 APK 解压的时间。从 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; 非阻塞地尝试获取准备锁; 如果没有获取到准备锁,表示此时有其他进程已经持有准备锁,则释放互斥锁,并退出 OPT 进程; 如果获取到了准备锁,表示此时没有其他进程正常持有准备锁,则再次执行第 2 步,做下个文件的 OPT; 完成所有 DEX 文件的 OPT 操作,释放互斥锁,退出。
阻塞等待获取准备锁; 阻塞等待获取互斥锁; 释放准备锁; 完成 DEX 加载; 释放互斥锁; 继续往下执行业务代码。
具体情形见下图:
实测数据
总结
采用 openDexFile_bytearray
函数,可以直接加载原始 DEX 字节码;提前注入 dex_object
对象,以解决 4.4 机型上加载原始 DEX 字节码时,getDex
的崩溃问题;采用 dvmRawDexFileOpen
函数做 ODEX,以解决 SIGSTKFLT 问题;多级加载,在 DEX 字节码、DEX 文件、ODEX 文件中选取最合适的产物启动 APP; 单独进程做 OPT,并实现合理的中断及恢复机制。
让我知道你在看
7. 抖音BoostMultiDex优化实践:Android低版本上APP首次启动时间减少80%(一)