開發

自動識別Android不合理的內存分配

寫在前面

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

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

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

常規方案對比分析

方案優勢不足
Dump內存可以自動化無法反映出內存分配的過程
錄制對象分配可以看到每次內存分配的情況需要手動啟動,無法自動化
字節碼插樁可以自動化無法記錄不在業務代碼內的內存分配

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

讓錄制對象分配自動化

1. 模仿

Android Studio是開源的,因此我們很容易在它的源碼里找到一些功能的實現。錄制內存分配的代碼在ToggleAllocationTrackingAction這個類里。精簡后的流程如下:

建立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的源碼,其他版本有差異,后面會講到。

這里的關鍵函數是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函數有是有,但是是內聯函數,我們沒有辦法拿到這個函數的地址。

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

可以說是業界良心了,我們通過dlsym就可以拿到這個函數的地址,然后調用它,傳入我們已經拿到的art::mirror::Class*和一個std::string,就可以拿到類名(實際上是類的描述)。

3. 它是怎么分配的

要知道一個對象是怎么分配的,我們需要拿到它的調用棧,Ok,我們來看看虛擬機里面怎么做的。

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

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

4. 發現不合理的對象分配

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

全版本支持

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

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

系統版本差異點新增挑戰點
6.xRecordAllocation函數新增一個參數Thread*
7.x1. so權限收緊2. RecordAllocation傳入的mirro::Class*變成了mirror::Object**1. 應用無法通過dlsym查詢函數地址2. mirror::Object無法與mirror::Class對應
8.x-9.xRecordAllocation傳入的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也可以。這個文件很關鍵,我們看看它里面是什么。

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

1.2 搜索函數地址 之 函數特征

這圖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的修正。

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

關于mirror::Object無法獲取類名的問題,主要是因為它里面所有跟mirror::Class相關的函數全部是內聯函數,我們在實現的時候很難突破。還是那句話,既然往里走不行,那就試著走出來。我們可以拿到調用棧,那是否可以通過解析調用棧來獲取當前分配的是什么對象呢?

答案是否定的。一方面是因為解析調用棧涉及字符串匹配操作,頻繁的字符串匹配操作,對性能的損耗是不太能接受的;另一方面是因為解析堆棧無法覆蓋所有的對象分配(并非所有的對象分配都會經過<init>方法,例如 byte[])。

mirror::Object是Java里Object在虛擬機的鏡像,那我們是否有辦法通過mirror::Object拿到Java的Object的引用呢?通過搜索以mirror::Object作為參數的函數,我找到了突破口。

這是JNI的一個函數,可以把mirror::Object轉成jobject,而jobject就是Java里Object在JNI層的表示。到了這一步,要獲取類名就非常簡單了,obj.getClass().getName()即可。

關于Android8.x及以上系統,把mirror::Object**改成ObjPtr<Object*>*的處理,就比較簡單了,ObjPtr類定義比較簡單,我們照著源碼里的ObjPtr實現一個結構一樣的class,就可以訪問到里面包裹的mirror::Object*了。

業務實踐

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

參考文章

ART運行時為新創建對象分配內存的過程分析

如何獲取Android系統中申請對象的信息

AllocationTracker實踐篇

Android Arm Inline Hook

Android Native Hook工具實踐

ARM架構下函數調用過程分析

ARM機器碼分析Arm及Thumb指令集

ELF格式詳解

我還沒有學會寫個人說明!

制定機器學習訓練數據策略的6個技巧

上一篇

5G+AI能否讓中興視頻再創新高?

下一篇

你也可能喜歡

自動識別Android不合理的內存分配

長按儲存圖像,分享給朋友

ITPUB 每周精要將以郵件的形式發放至您的郵箱


微信掃一掃

微信掃一掃
30岁的男人干啥赚钱快赚钱多 澳洲幸运8破解密 捕鱼大师现金版 2017 长春微乐麻将下载 意甲赛程赛果 幸运赛车冠军选号技巧 海南体彩4 1 股市风险评估 在线看股票 捕鱼提现手机版下载 网上游戏棋牌手机版下载