自動識別Android不合理的記憶體分配

騰訊技術工程發表於2020-04-06

寫在前面

Android開發中我們常常會遇到不合理的記憶體分配導致的問題,或是頻繁GC,或是OOM。按照常規的套路我們需要開啟Android Studio錄製記憶體分配或者dump記憶體,然後人工分析,逐個排查問題所在。這些方法是官方提供的能力,可以幫助我們排查問題,但難免有些繁瑣,效率比較低。

如果可以自動識別出不合理的Java(含Kotlin)物件分配,這樣繁瑣的工作將會變得簡單。

本文介紹了一種在Art虛擬機器上實時記錄物件分配的實現方案,基於此方案就可以實現不合理物件分配的自動化的識別。


常規方案對比分析


方案

優勢

不足

Dump記憶體

可以自動化

無法反映出記憶體分配的過程

錄製物件分配

可以看到每次記憶體分配的情況

需要手動啟動,無法自動化

位元組碼插樁

可以自動化

無法記錄不在業務程式碼內的記憶體分配

Dump記憶體和位元組碼插樁的方案都無法覆蓋執行過程中記憶體分配的過程,無法滿足自動識別的訴求。而錄製的方案目前主要的問題是,不能自動化,如果能實現錄製記憶體分配的自動化,就可以完成我們想要做的事情。


讓錄製物件分配自動化

1. 模仿

Android Studio是開源的,因此我們很容易在它的原始碼裡找到一些功能的實現。錄製記憶體分配的程式碼在ToggleAllocationTrackingAction這個類裡。精簡後的流程如下:


自動識別Android不合理的記憶體分配


建立ADB連線、構造請求這些都是IDE做的事情,我們需要模擬IDE做這些事情嗎?不需要。我們只需要關注DdmVmInternal是怎麼做的即可,很幸運,Android系統原始碼的一段測試程式碼直接告訴了我們如何反射呼叫DdmVmInternal提供的能力,原始碼位置在<android src>/art/test/098-ddmc/src/Main.java,這裡程式碼就不貼了。


2. 轉折

呼叫DdmVmInternal的方法,成功的在App裡開啟了記憶體分配的錄製,也成功的拿到了每次記憶體分配的資料。但如果以為事情就這樣OK了,還早了一些。萬萬沒想到,這介面雖然易用,但用得並不爽,有三點:


  1. 最多隻能65535條記錄(size的型別是雙位元組無符號數)。

  2. 錄製時對效能影響很小,但每次獲取錄製記錄時特別慢(開發機實測JDWP封包5秒以上,解包處理10秒以上)。

  3. 每次獲取到的記錄可能有重複,要使用這個資料需要額外做合併去重的操作。


這些不爽的點似乎都很冗餘,能不能直接一點呢?

3. 突破


DdmVmInternal的實現是放在native層的,順藤摸瓜,我們找到了虛擬機器裡實現記憶體分配錄製的原始碼,此處是Android5.1的原始碼,其他版本有差異,後面會講到。


自動識別Android不合理的記憶體分配

這裡的關鍵函式是RecordAllocation,所有物件的記憶體分配都會經過這個函式,因此我們可以Hook這個函式來捕捉到記憶體分配的事件。

怎麼hook


方案

優勢

不足

PLT Hook

修改PLT表的跳轉地址,風險低,易操作

使用場景有限,只能Hook一些被外部呼叫的函式

Inline  Hook

彙編指令級別修改,幾乎能修改所有邏輯

修改彙編指令涉及繁瑣的指令修復工作,有一定門檻


顯然,PLT Hook並不適合我們的場景,好在目前Inline Hook技術也已經比較成熟,看雪有不少大佬都分享了自己的框架,我們要使用Inline Hook無需再處理那些繁瑣的指令修復
(關於hook技術的細節在最後的參考文章裡有列舉,有興趣的同學可以翻閱)。
至此,我們已經可以捕獲到所有的物件分配事件了,但這只是我們邁出的一小步。
讓物件分配可被跟蹤
為了讓物件分配可被跟蹤,我們至少需要三個資訊:這是什麼物件分配了多大記憶體它是怎麼分配的這幾個點看似清楚明瞭,但怎麼做,還需要小費一番周折。

1. 分配了多大記憶體

這個資訊最容易獲取,如果你還記得RecordAllocation函式的定義,你會發現byte_count已經作為引數傳進來了。沒錯,就是這麼簡單。

2. 這是什麼物件


你也許已經發現RecordAllocation還有一個引數是art::mirror::Class*,這是Java裡Class在虛擬機器裡的映象,我們知道Java裡拿到Class,就能直接呼叫getName方法知道這個類是什麼。然鵝,在虛擬機器的原始碼裡,GetName函式有是有,但是是行內函數,我們沒有辦法拿到這個函式的地址。


自動識別Android不合理的記憶體分配

這個咋整?不要方,我們繼續看原始碼,就在不遠處,有一個叫個GetDescriptor的函式。


自動識別Android不合理的記憶體分配
可以說是業界良心了,我們透過dlsym就可以拿到這個函式的地址,然後呼叫它,傳入我們已經拿到的art::mirror::Class*和一個std::string,就可以拿到類名(實際上是類的描述)。

3. 它是怎麼分配的


要知道一個物件是怎麼分配的,我們需要拿到它的呼叫棧,Ok,我們來看看虛擬機器裡面怎麼做的。


自動識別Android不合理的記憶體分配

這個能模仿實現嗎?多番查探,發現每個關鍵節點的實現都是行內函數。咋辦呢?


古人說“山重水複疑無路,柳暗花明又一村”。既然原始碼層面不能給我們更多的啟示了,那回頭想想平時會怎麼做。是的,我們在寫Java程式碼的時候,如果要獲得當前的呼叫棧,一般就直接Thread.currentThread().getStackTrace()。既然這麼容易,那我們直接在native層透過jni呼叫java的方法不就可以拿到呼叫棧了嗎?事實也正是如此。於是,整個流程順下來就是這樣的。


自動識別Android不合理的記憶體分配


4. 發現不合理的物件分配

找到了合適的時機,又收集到了需要的資料,跟蹤發現不合理的物件分配就很容易了。我們可以發現某一次分配的大物件,也可以按照類名或者分類統計物件分配的頻率等等,還可以做更多定製化的監控~

全版本支援
前面提到的方案已在Android5.x版本上驗證OK,指定機型跑自動化是可以的,但目前主流的開發裝置是Android7.x甚至更高的版本,如果要在開發階段就能自動發現記憶體分配的問題,顯然不夠的。


是否可以把前面的方案直接應用在Android 6.x-9.x呢?答案是沒那麼容易。我們先來看下後續版本虛擬機器裡的一些改動。

系統版本
差異點
新增挑戰點
6.x
RecordAllocation函式新增一個引數Thread*
7.x
1. so許可權收緊
2. RecordAllocation傳入的mirro::Class*變成了mirror::Object**
1. 應用無法透過dlsym查詢函式地址
2. mirror::Object無法與mirror::Class對應
8.x-9.x
RecordAllocation傳入的mirror::Object**變成了ObjPtr<Object*>*
無法直接訪問到Object*


對於我們的方案來講,主要的挑戰集中在Android7.x及以上版本,我們來看看這些問題如何各個擊破。

1. 繞過so訪問許可權問題

Android7.0開始,要想動態連結非NDK公開的so需要System或者Root許可權,普通的app是做不到的。如果嘗試連結或者透過dlopen去開啟,要麼看到Permission Denied的錯誤提示,要麼直接Crash。
既然直接的方案不行,那就想辦法繞過去。


1.1 獲得so基址

我們知道,Android是基於Linux的作業系統,Linux作業系統每個程式都有一個maps檔案記錄了所有模組在記憶體裡起始地址,路徑是/proc/<pid>/maps,這裡pid就是程式的pid,訪問自己程式用別名/proc/self/maps也可以。這個檔案很關鍵,我們看看它裡面是什麼。


自動識別Android不合理的記憶體分配

libart.so是虛擬機器的so,可以看到這裡它的起始地址是0xeaf18000。
函式的地址就是基址+偏移,現在基址已有,就差偏移了,偏移怎麼拿?因為每個ROM的so多少都有差別,這個偏移肯定不能是hardcode的,我們要想辦法查到函式的偏移。一般來說有兩種辦法,第一種是無腦搜函式特徵。


1.2 搜尋函式地址 之 函式特徵


自動識別Android不合理的記憶體分配
這圖IDA開啟一個Android7.1的libart.so查到的RecordAllocation函式的二進位制。這個二進位制的前8個或16個位元組就可以用來作為這個函式的特徵,我們在libart.so的記憶體區域內匹配這個特徵就可以定位到這個函式了。
這個方法有個明顯的缺點,因為ROM廠家很有可能會修改虛擬機器的程式碼,或者修改編譯引數,這種透過函式特徵去定位函式的辦法最多隻能作為特殊機型的相容邏輯。我們應該用一種更通用的方法,那就是直接解析ELF


1.3 搜尋函式地址 之 解析ELF


so是一種ELF格式的檔案,在Android系統裡由linker載入到記憶體。關於ELF的格式,網上很容易找到,各種結構貼出來很長,這裡不贅述。 雖然Android限制了我們dlopen開啟NDK非公開的so,但本質上,這些so對我們的程式來說是有可讀許可權的,所以解析ELF格式來查詢函式的偏移是可行的,按照ELF的格式去解析就可以了,程式碼沒有特別值得拎出來說的,但在實現的時候仍然有一些細節。
如果只是參考ELF的結構,我們能想到的直觀的辦法就是:遍歷字串表,找到目標函式名的偏移;然後遍歷符號表,找到目標函式的偏移地址。這樣的做法沒毛病,但效率不夠高,因為是遍歷,所以複雜度為O(n)。
事實上,如果看過linker的原始碼,我們會發現,還有一個更高效的O(1)的查詢辦法。so裡有一個section名字是.hash(有的是.gnu_hash,只是hash函式不同,但基本邏輯是一樣的),它裡面儲存的其實是函式符號的索引。我們參考linker的實現,把函式名(符號名)做一個hash,就可以在這個hash setion裡面找到目標函式在符號表的索引,進而拿到函式的偏移地址。
解析ELF這種方案更通用,也是我最終採用的主要的方案

2. 突如其來的SIGILL

解決了獲取函式地址的問題,執行時發現Hook了搜尋出來的函式就Crash了,系統拋了一個SIGILL的訊號結束了我的程式。SIGILL表示Illegal Instruction,這很有可能是我們的函式地址有問題。
不過基址是系統載入so時記錄的,這個應該不會有錯;搜尋出來的函式偏移和用IDA檢視的函式偏移也是一致的。問題到底在哪?
此時,我想到雖然NDK限制了對非公開so的許可權,但我自己的so,就可以用dlsym來查詢函式地址。於是寫了一個demo,發現一個“不可思議”的事實:dlsym查到的函式地址 比 我搜尋出來的函式地址 剛好大了1。
剛好大1,這絕非巧合。
這有點觸及到知識盲區了,翻閱了不少講解ARM彙編的文章,終於找到了答案。原來ARM彙編編譯時有ARM指令和THUMB指令兩種,ARM指令為4位元組,支援按條件執行;而THUMB指令為2位元組,不支援按條件執行。由於大部分場景都無需按條件執行,所以編譯成THUMB指令,so更加緊湊。由於4位元組和2位元組都是偶數,地址的最低位實際上是用不上的,ARM設計時就巧妙的將地址的最低位置1來表示要按照THUMB指令來解析了。


這就是剛好大1的原因。我們看到IDA反編譯出來的RecordAllocation函式也可以清楚的看到,確實一條指令是2個位元組,所以我們在實現的時候,要把搜尋出來的地址做加1的修正。


自動識別Android不合理的記憶體分配


3. 透過art::mirror::Object獲取類名

關於mirror::Object無法獲取類名的問題,主要是因為它裡面所有跟mirror::Class相關的函式全部是行內函數,我們在實現的時候很難突破。還是那句話,既然往裡走不行,那就試著走出來。我們可以拿到呼叫棧,那是否可以透過解析呼叫棧來獲取當前分配的是什麼物件呢?
答案是否定的。一方面是因為解析呼叫棧涉及字串匹配操作,頻繁的字串匹配操作,對效能的損耗是不太能接受的;另一方面是因為解析堆疊無法覆蓋所有的物件分配(並非所有的物件分配都會經過<init>方法,例如 byte[])。


mirror::Object是Java裡Object在虛擬機器的映象,那我們是否有辦法透過mirror::Object拿到Java的Object的引用呢?透過搜尋以mirror::Object作為引數的函式,我找到了突破口。


自動識別Android不合理的記憶體分配

這是JNI的一個函式,可以把mirror::Object轉成jobject,而jobject就是Java裡Object在JNI層的表示。到了這一步,要獲取類名就非常簡單了,obj.getClass().getName()即可。
關於Android8.x及以上系統,把mirror::Object**改成ObjPtr<Object*>*的處理,就比較簡單了,ObjPtr類定義比較簡單,我們照著原始碼裡的ObjPtr實現一個結構一樣的class,就可以訪問到裡面包裹的mirror::Object*了。
業務實踐


我們的業務已經開始嘗試用NewMonkey做自動化測試,檢測到不合理的分配記憶體的場景,就記錄並上報。


自動識別Android不合理的記憶體分配



參考文章
ART執行時為新建立物件分配記憶體的過程分析
如何獲取Android系統中申請物件的資訊
AllocationTracker實踐篇
Android Arm Inline Hook
Android Native Hook工具實踐
ARM架構下函式呼叫過程分析
ARM機器碼分析
Arm及Thumb指令集
ELF格式詳解


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31559354/viewspace-2655900/,如需轉載,請註明出處,否則將追究法律責任。

相關文章