前情回顧:抖音 BoostMultiDex 優化實踐:Android 低版本上 APP 首次啓動時間減小 80%(一)android
抖音自研的 BoostMultiDex 方案,能夠大幅改善 Android 低版本(4.4 及其如下)手機更新或安裝後首次冷啓動時間。而且,不一樣於目前業界全部優化方案,咱們是從 Android Dalvik 虛擬機底層機制入手,從根本上解決了安裝後首次執行 MultiDex 耗時過長問題。面試
咱們上一篇文章中已經介紹了 BoostMultiDex 的核心優化思路,即如何避免 ODEX,直接加載原始 DEX 完成啓動。然而用這個方法加載 DEX 文件,相比於 ODEX 優化後的方式,其 Java 代碼執行性能上仍是有所損失的。咱們也能夠從前面方法的註釋裏面看出,虛擬機對於直接加載原始 DEX 的狀況只是作了些基本優化:c#
The system will only perform "essential" optimizations on the given file.
複製代碼
因此,雖然第一次啓動咱們是加載了原始 DEX 來執行的,但從長遠的角度考慮,後續的啓動,仍是應該儘可能採用 ODEX 的方式來執行。所以,咱們還須要在第一次啓動完成後,在後臺適當的時候作好 ODEX 優化。數組
一開始咱們是作法也比較簡單,在順利加載 DEX 字節數組,完成啓動以後,在後臺開闢單獨的線程執行DexFile.loadDex
就能夠了。這樣當後臺作完 ODEX 後,APP 第二次啓動時,就能夠直接加載以前作好的 ODEX,獲得較好的執行性能。這種作法在線下測試的時候也很正常,然而在上線以後,咱們遇到了這樣一個問題……bash
線上報上來一個 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 dump
複製代碼
SIGSTKFLT 是 Dalvik 虛擬機特有的一個信號。當虛擬機發生了 ANR 或者須要作 GC 的時候,就須要掛起全部 RUNNING 狀態的線程,若是此時 Dalvik 虛擬機等待了足夠長時間,線程仍舊沒法被掛起,就會調用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,長時間沒法響應虛擬機的掛起請求時,就會觸發這個問題。this
通常來講,虛擬機在執行 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 (*)(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);
複製代碼
這樣,咱們本身寫一個 JNI 調用,在 Native 狀態下執行上述代碼,就能達到完成 ODEX 的目的,從而根本上杜絕這個異常了。
另外,咱們把 dexopt 操做放到了單獨進程執行,由此能夠避免 ODEX 操做對主進程形成其餘性能影響。此外,因爲設備狀況多種多樣,運行環境十分複雜,還可能會有一些廠商魔改,致使的 dlsym 找不到_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb
符號,雖然這種狀況極爲罕見,但理論上仍有可能發生。單獨進程裏面因爲環境比較純粹,基本不多發生 ANR 和 GC 事件,掛起的狀況就不多,也能最大程度規避這個問題。
咱們發現,相比於官方 MultiDex 加載 ZIP 形態的 DEX 文件,非 ZIP 方式的 DEX(也就是直接對 DEX 文件作 ODEX,而不用先把 DEX 壓縮進 ZIP 裏面)對於總體時間也有必定程度的優化,由於這種非 ZIP 方式避免了原先的兩個耗時:
非 ZIP 的方式相比於 ZIP 方式,總體耗時會減小 40%左右,可是 DEX 文件磁盤佔用空間比原先 ZIP 文件的方式增長一倍多。所以咱們能夠只在磁盤空間充裕的時候,優先使用非 ZIP 方式加載。
而咱們openDexFile_bytearray
加載 DEX 的方式,須要的只是原始 DEX 文件的字節數組(byte[])。這個字節數組咱們在首次冷啓動的時候是直接從 APK 裏面解壓提取獲得的。咱們能夠在此次啓動提取完成後,先把這些字節數組落地爲 DEX 文件。這樣若是再次啓動 APP 的時候,ODEX 沒作完,就能夠直接使用前面保存的 DEX 文件來獲得字節數組了,從而避免了從 APK 解壓的時間。
整體來看,咱們整套方案中一共存在四種形態的 DEX:
生成各個產物的時序圖以下所示:
咱們依次說明每一步:
正常狀況下,咱們會依次按 A -> B -> C 的時序依次產生各個文件,若是中間有中斷的狀況,咱們下次啓動後會繼續按照當前已有產物作對應操做。咱們僅在磁盤空間不夠,且所在系統不支持直接加載字節數組的狀況下才會走 ZIP&ODEX 方式的 D 路徑。這裏不支持的狀況主要是一些特殊機型,好比 4.4 卻採用了 ART 虛擬機的機型、阿里 Yun OS 機型等。
接下來咱們繼續看下加載流程圖:
當 APP 首次啓動的時候,若是會從 APK 裏面解壓 DEX 數組,所以會按照 a -> b 的路徑執行;
當 APP 發現只有 DEX 文件,沒有 ODEX 文件時,會把從 DEX 文件中取得 DEX 數組,按照 c -> b 路徑執行;
當 APP 發現 DEX 文件和 ODEX 文件都存在的時候,會按照 ODEX 方式加載,按照 d 路徑執行;
當 APP 發現有 ZIP 文件以及它所對應的 ODEX 的時候,會按照 e 路徑執行。
這麼一來,APP 就能夠根據當前狀況,選擇最合適的方式執行加載 DEX 了。從而保證了任意時刻的最優性能。
前面提到,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 進程開始執行,會獲取到互斥鎖,而後作 DEX 處理。OPT 進程在處理完第一個 DEX 文件後,因爲沒有其餘進程持有準備鎖,所以 OPT 進程獲取準備鎖成功,而後釋放準備鎖,繼續作下一個 DEX 優化。
這時候,主進程(或其餘非 OPT 進程)啓動,先成功地獲取準備鎖。而後繼續阻塞地獲取互斥鎖,此時因爲 OPT 進程已經在前一步獲取到了互斥鎖,所以只能等待其釋放。
OPT 進程在處理完第二個 DEX 後,檢測到準備鎖已經被其餘進程持有了,所以獲取失敗,從而中止繼續作 OPT,釋放互斥鎖並退出。
此時主進程就能夠成功地獲取到互斥鎖,而且當即釋放準備鎖,以便其餘進程能夠獲取。接着,在完成 DEX 加載後,釋放互斥鎖,繼續執行後續業務流程。最後再喚起 OPT 進程接着作完原先的 DEX 處理。
整體看來,在這種模式下,OPT 進程能夠主動發現有其餘進程須要加載 DEX,從而中斷 DEX 處理,並釋放互斥鎖。主進程便不須要等待整個 DEX 處理完成,只須要等 OPT 進程完成最近一個 DEX 文件的處理就能夠繼續執行了。
咱們本地選取了幾臺 4.4 及如下的設備,對它們首次啓動的 DEX 加載時間進行了對比:
Android版本 | 廠商 | 機型 | 原始MultiDex耗時(s) | BoostMultiDex耗時(s) |
---|---|---|---|---|
4.4.2 | LG | LGMS323 | 33.545 | 5.014 |
4.4.4 | MOTO | G | 45.691 | 6.719 |
4.3 | Samsung | GT-N7100 | 24.186 | 3.660 |
4.3.0 | Samsung | SGH-T999 | 30.331 | 3.791 |
4.2.2 | HUAWEI | Hol-T00 | 崩潰 | 3.724 |
4.2.1 | HUAWEI | G610-U00 | 36.465 | 4.981 |
4.1.2 | Samsung | I9100 | 30.962 | 5.345 |
以上是在抖音上測得的實際數據,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 問題;對於國內偏遠地區,尤爲對於海外許多發展中國家,Android 低版本機型仍然佔比較高。目前 BoostMultiDex 方案在抖音和 TikTok 已經全量上線,這會使得這部分低版本 Android 用戶直接受益,極大優化升級和安裝啓動體驗。
咱們後續將開源 BoostMultiDex 方案,以協助其餘 APP 在低版本 Android 手機上改進性能體驗。
從此,各家對下沉市場有須要的 APP,都能直接使用 BoostMultiDex 方案,當即得到飛通常的升級安裝體驗!這也是咱們爲改善 Android 生態貢獻的一小份力,後續很快就會發布開源地址,敬請期待!
最後的最後,仍然再提一句,抖音/TikTok Android 基礎技術團隊正在北上深杭四地尋求優秀 Android 開發人才,目前疫情期間咱們也支持徹底遠程無接觸面試。只要你的技術功力深厚或者潛力巨大,均可以經過 字節跳動招聘官網查詢抖音 Android 相關職位 或者聯繫 xiaolin.gan@bytedance.com 來投遞簡歷,咱們十分期待你的加入!
歡迎關注字節跳動技術團隊