支付寶客戶端架構解析:Android 客戶端啟動速度優化之「垃圾回收」

螞蟻金服移動開發平臺mPaaS發表於2018-11-06

前言

《支付寶客戶端架構解析》系列將從支付寶客戶端的架構設計方案入手,細分拆解客戶端在“容器化框架設計”、“網路優化”、“效能啟動優化”、“自動化日誌收集”、“RPC 元件設計”、“移動應用監控、診斷、定位”等具體實現,帶領大家進一步瞭解支付寶在客戶端架構上的迭代與優化歷程。

本節將介紹支付寶 Android 客戶端啟動速度優化下的「垃圾回收」具體思路。

應用啟動時間是移動 App 一個重要的使用者體驗環節,相對於普通的移動 App,支付寶過於龐大,必然會影響啟動速度,一些常規的優化手段在支付寶中已經做得比較完善了,本篇文章嘗試從 GC 的層面來進一步優化支付寶的啟動速度。

背景

相對於 C 語言來說,Java 語言有一些特性,例如開發人員不用考慮記憶體的分配和回收,然而,程式記憶體管理又是必不可少的環節,妥協的結果是 Java 語言的設計者們把物件分配和回收放到了 Java虛擬機器,這裡希望明確一個概念:GC 是有代價的,這個代價包括:阻塞 Java 程式的執行,佔用 CPU 資源,佔用額外記憶體等,谷歌的工程師意識到了 GC 對應用的影響,所以把 GC 的日誌預設輸出到了 Logcat,我們經常能夠看到 Logcat 裡輸出以下幾種 GC 日誌:

  1. GC_EXPLICIT:Dalivk 給開發人員提供的主動觸發 GC 的 API,讀者可以參看 Google Maps 的設計來體會這個 API 的用法
  2. GC_FOR _ALLOCK:是分配物件失敗時觸發的 GC,這個 GC 會將應用所有的 Java 執行緒暫停執行,直到 GC 結束。
  3. GC_CONCURRENT:是 Java 虛擬機器根據堆的當前狀態觸發的 GC,這個 GC 在 Dalvik 單獨 GC 執行緒裡執行,在部分時間裡不影響應用 Java 執行緒的執行。

支付寶啟動是一個典型的關鍵路徑場景,我們希望看到儘可能少的 GC_ CONCURRENT(如果可能,GC_ FOR_ ALLOCK 也應該縮減到最少),然而,通過 Logcat 我們會看到非常糟糕的 GC 行為—大量的 GC_ FOR_ ALLOCK 以及觸目驚心的 Java 執行緒被 WAIT_ FOR_ CONCURRENT_ GC 阻塞,如下圖所示,通過簡單統計這些GC消耗的時間,我們能夠得出GC嚴重影響應用啟動時間的結論。

gc_log

設計思路

支付寶是 Android 系統的一個應用程式,如何能夠通過影響 Dalvik 的 GC 行為來縮短啟動時間呢?這個問題可以分解為兩步:

  • 支付寶是否能影響自身 Dalvik 的行為
  • 如何改進 Dalvik,縮短啟動時間

第一個問題答案是肯定的,Android 系統的設計思路是每個 Android 應用程式都有獨立的 Dalvik 例項,應用啟動後可以修改自己的程式空間裡的程式碼和資料,因此支付寶通過修改記憶體中的 Dalvik 庫檔案 libdvm.so 影響 Dalvik 的行為。

第二個問題的難點在於投入產出比:修改程式空間的程式碼和資料是面向二進位制,難度遠遠大於原始碼,也就是說稍微複雜的 Dalvik 改進工作是不可能的。

基於以上兩點,提出了一種設想:啟動時 GC 抑制,允許堆一直增長,直到開發人員主動停止 GC 抑制或者 OOM 停止 GC 抑制,這是一種"空間換時間"策略,用更多的記憶體消耗來換取啟動時間的縮短,這種策略可行有兩個前提:一是裝置廠商沒有加密記憶體中的 Dalvik 庫檔案,二是裝置廠商沒有改動 Google 的 Dalvik 原始碼(或者少量的改動),理論上通過白名單的方式可以覆蓋所有裝置,但是實現和維護成本都非常高。

GC 抑制的實現

GC 抑制的前提是 Dalvik 比較熟悉,知道如何改變 GC 的行為,解決方案大致如下:首先在原始碼級別找到抑制GC的修改方法,例如改變跳轉分支,其次,在二進位制程式碼裡找到 A 分支條件跳轉的"指令指紋",以及用於改變分支的二進位制程式碼,假設為 override_A,應用啟動後掃描記憶體中的 libdvm.so,根據"指令指紋"定位到修改位置,然後用 override_A 覆蓋,這裡需要注意的是,"指令指紋"的定義需要有一些編譯器和 arm 指令集知識,實現 GC 抑制主要實現了以下 4 個部分:

  • 取消 softlimit 檢測
  • 取消 GC 執行緒的喚醒
  • 取消 GC 例程函式
  • OOM 停止 GC 抑制的實現

1. 取消 softlimit 檢測:

取消 softlimit 檢測的目的是最大限度的分配物件,下圖為 softlimit 檢查對應的 arm 指令片段,位於 dvmHeapSourceAlloc 函式中,OXE057 對應於"return NULL"的分支,如果我們想永遠不進入"return NULL"分支,可以改變 cmp 指令的結果,在具體實現裡我們把"0X42"作為"指令指紋"來識別而且修改為 "cmp r0, r0",這樣就可以實現取消 softlimit 檢查。

   7616c: 42a1 cmp r1, r4
   7616e: d901 bls.n 76174 <_Z18dvmHeapSourceAllocj+0x20>
   76170: 2400 movs r4, #0
   76172: e057 b.n 76224 <_Z18dvmHeapSourceAllocj+0xd0>
   76174: f8df 90bc ldr.w r9, [pc, #188] ; 76234    <_Z18dvmHeapSourceAllocj+0xe0>
   76178: 6a28 ldr r0, [r5, #32]
   7617a: f853 3009 ldr.w r3, [r3, r9]
   7617e: 7d1a ldrb r2, [r3, #20]
void* dvmHeapSourceAlloc(size_t n)
{
...
if (heap->bytesAllocated + n > hs->softLimit) {
/*
* This allocation would push us over the soft limit; act as
* if the heap is full.
/
return NULL;
複製程式碼

2. 取消GC執行緒的喚醒

取消 GC 執行緒喚醒的目的是防止 GC 執行緒頻繁喚醒導致的執行緒抖動。下圖是對應的 C++ 程式碼和 arm 指令片段,這段程式碼同樣位於 dvmHeapSourceAlloc 函式中。在具體實現裡我們會依次掃描 libdvm.so 的 dynstr、dynsym、rel.plt 和 plt 區域獲取 pthreadcondsignal@plt 的地址,然後遍歷 dvmHeapSourceAlloc 中的所有分支跳轉,計算跳轉目的地址。

如果發現 pthreadcondsignal@plt 和當前分支跳轉目的地址配置,擦除這條指令即可。

   if (heap->bytesAllocated > heap->concurrentStartBytes) {
/
* We have exceeded the allocation threshold. Wake up the
* garbage collector.
*/
dvmSignalCond(&gHs->gcThreadCond);
}
7621c: 6800 ldr r0, [r0, #0]
7621e: 30b4 adds r0, #180 ; 0xb4
76220: f7a9 ed0e blx 1fc40 
76224: 4620 mov r0, r4
76226: e8bd 83f8 ldmia.w sp!, {r3, r4, r5, r6, r7, r8, r9, pc}
複製程式碼

3. 取消GC例程函式

取消 GC 例程函式採用鉤子技術來實現,我們將 GC 抑制封裝成了兩個 native 介面 doStartSuppressGCdoStopSuppressGC;並且進一步封裝為 JNI 介面,便於開發者在 Java 裡呼叫。一般的應用方式是,開發者通過日誌看到支付寶在某個場景會觸發大量的 GC 且這個 GC 影響使用者體驗(響應時間慢或者動畫卡頓),然後在這個場景前後插入 doStartSuppressGCdoStopSuppressGC

以支付寶冷啟動場景為例,我們在容器 Quinox 的 attachBaseContext 函式裡插入 doStartSuppressGC,在首頁載入結束時插入 doStopSuppressGC

4. OOM 停止GC抑制的實現

如果僅僅考慮在支付寶啟動過程中抑制 GC,不需要考慮 OOM 停止 GC 抑制的實現,因為支付寶啟動不足以觸發 OOM。但是我們希望 GC 抑制成為一個基礎模組,能夠應用到更多場景中。如果程式在呼叫 doStopSuppressGC 前觸發了 OOM,則需要在 OOM 發生前停止 GC 抑制。和前面簡單的改變分支跳轉方向不同,需要在 OOM 發生前注入一個新的的分支跳轉,這個新分支的程式碼由我們來實現。新分支主要功能是,呼叫 doStopSuppressGC,然後去掉注入的新分支,最後跳回 Dalvik 執行 OOM。

gc_oom

實現同樣採用傳統的鉤子技術。在鉤子函式 dvmCollectGarbageInternal 裡:

  • 當條件不滿足時直接返回,達到取消 GC 的目的;
  • 條件滿足時,取消鉤子且執行原來的 dvmCollectGarbageInternal

實現中使用了開源的二進位制注入框架:github.com/crmulliner/…

這裡需要注意的是,在熱點函式裡使用這個框架提供的 pre_hookpost_hook 的效能開銷非常大。

本文裡的設計只會用到一次 pre_hook,所以不存在效能問題。 看到的這裡讀者可能會問,這種通過“指令指紋”的方式靠譜麼?我的答案是,漏判不影響正確性,誤判理論上存在但概率極小(誤判指“指令指紋”定位到錯誤程式碼位置)。即使誤判發生了,我們還有最後一層保障——基礎架構組同學實現的容災機制。當誤判導致程式異常無法完成正常啟動時,重啟支付寶而且在後續的啟動中直接放棄 GC 抑制。

效果

effect

上圖的啟動時間的資料是在內部的 Android 4.x 測試裝置上獲得的(沒有標註 release 表示 debug 版本)。從圖表上來看,支付寶客戶端的啟動時間縮短了 15%~30%。

小結

通過本節內容,我們初步瞭解了支付寶在 Android 客戶端啟動效能優化下的「垃圾回收」機制和具體實踐,由於篇幅限制,很多技術要點我們無法一一展開。而相應的技術核心,我們同樣應用在了 mPaaS 並對外輸出,歡迎大家上手體驗:

tech.antfin.com/docs/2/4954…

關於 Android 端啟動效能優化的設計思路和具體實踐,同樣期待你們的反饋,歡迎一起探討交流。

往期閱讀

《支付寶客戶端架構解析:iOS 容器化框架初探》

《支付寶客戶端架構解析:Android容器化框架初探》

《開篇 | 模組化與解耦式開發在螞蟻金服 mPaaS 深度實踐探討》

《口碑 App 各 Bundle 之間的依賴分析指南》

《原始碼剖析 | 螞蟻金服 mPaaS 框架下的 RPC 呼叫歷程》

《支付寶移動端動態化方案實踐》

關注我們公眾號,獲得第一手 mPaaS 技術實踐乾貨

QRCode

相關文章