溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊(cè)×
其他方式登錄
點(diǎn)擊 登錄注冊(cè) 即表示同意《億速云用戶服務(wù)條款》

怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配

發(fā)布時(shí)間:2021-08-25 10:07:57 來源:億速云 閱讀:134 作者:chen 欄目:移動(dòng)開發(fā)

這篇文章主要介紹“怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配”,在日常操作中,相信很多人在怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配問題上存在疑惑,小編查閱了各式資料,整理出簡(jiǎn)單好用的操作方法,希望對(duì)大家解答”怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配”的疑惑有所幫助!接下來,請(qǐng)跟著小編一起來學(xué)習(xí)吧!

寫在前面

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

如果可以自動(dòng)識(shí)別出不合理的Java(含Kotlin)對(duì)象分配,這樣繁瑣的工作將會(huì)變得簡(jiǎn)單。

本文介紹了一種在Art虛擬機(jī)上實(shí)時(shí)記錄對(duì)象分配的實(shí)現(xiàn)方案,基于此方案就可以實(shí)現(xiàn)不合理對(duì)象分配的自動(dòng)化的識(shí)別。

常規(guī)方案對(duì)比分析

方案

優(yōu)勢(shì)

不足

Dump內(nèi)存

可以自動(dòng)化

無法反映出內(nèi)存分配的過程

錄制對(duì)象分配

可以看到每次內(nèi)存分配的情況

需要手動(dòng)啟動(dòng),無法自動(dòng)化

字節(jié)碼插樁

可以自動(dòng)化

無法記錄不在業(yè)務(wù)代碼內(nèi)的內(nèi)存分配

Dump內(nèi)存和字節(jié)碼插樁的方案都無法覆蓋運(yùn)行過程中內(nèi)存分配的過程,無法滿足自動(dòng)識(shí)別的訴求。而錄制的方案目前主要的問題是,不能自動(dòng)化,如果能實(shí)現(xiàn)錄制內(nèi)存分配的自動(dòng)化,就可以完成我們想要做的事情。

讓錄制對(duì)象分配自動(dòng)化

1. 模仿

Android Studio是開源的,因此我們很容易在它的源碼里找到一些功能的實(shí)現(xiàn)。錄制內(nèi)存分配的代碼在ToggleAllocationTrackingAction這個(gè)類里。精簡(jiǎn)后的流程如下:

怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配

建立ADB連接、構(gòu)造請(qǐng)求這些都是IDE做的事情,我們需要模擬IDE做這些事情嗎?不需要。我們只需要關(guān)注DdmVmInternal是怎么做的即可,很幸運(yùn),Android系統(tǒng)源碼的一段測(cè)試代碼直接告訴了我們?nèi)绾畏瓷湔{(diào)用DdmVmInternal提供的能力,源碼位置在<android src>/art/test/098-ddmc/src/Main.java,這里代碼就不貼了。

2. 轉(zhuǎn)折

調(diào)用DdmVmInternal的方法,成功的在App里開啟了內(nèi)存分配的錄制,也成功的拿到了每次內(nèi)存分配的數(shù)據(jù)。但如果以為事情就這樣OK了,還早了一些。萬萬沒想到,這接口雖然易用,但用得并不爽,有三點(diǎn):
  1. 最多只能65535條記錄(size的類型是雙字節(jié)無符號(hào)數(shù))。

  2. 錄制時(shí)對(duì)性能影響很小,但每次獲取錄制記錄時(shí)特別慢(開發(fā)機(jī)實(shí)測(cè)JDWP封包5秒以上,解包處理10秒以上)。

  3. 每次獲取到的記錄可能有重復(fù),要使用這個(gè)數(shù)據(jù)需要額外做合并去重的操作。

這些不爽的點(diǎn)似乎都很冗余,能不能直接一點(diǎn)呢?

3. 突破

DdmVmInternal的實(shí)現(xiàn)是放在native層的,順藤摸瓜,我們找到了虛擬機(jī)里實(shí)現(xiàn)內(nèi)存分配錄制的源碼,此處是Android5.1的源碼,其他版本有差異,后面會(huì)講到。

怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配

這里的關(guān)鍵函數(shù)是RecordAllocation,所有對(duì)象的內(nèi)存分配都會(huì)經(jīng)過這個(gè)函數(shù),因此我們可以Hook這個(gè)函數(shù)來捕捉到內(nèi)存分配的事件。

怎么hook?

方案

優(yōu)勢(shì)

不足

PLT Hook

修改PLT表的跳轉(zhuǎn)地址,風(fēng)險(xiǎn)低,易操作

使用場(chǎng)景有限,只能Hook一些被外部調(diào)用的函數(shù)

Inline  Hook

匯編指令級(jí)別修改,幾乎能修改所有邏輯

修改匯編指令涉及繁瑣的指令修復(fù)工作,有一定門檻

顯然,PLT Hook并不適合我們的場(chǎng)景,好在目前Inline Hook技術(shù)也已經(jīng)比較成熟,看雪有不少大佬都分享了自己的框架,我們要使用Inline Hook無需再處理那些繁瑣的指令修復(fù)
(關(guān)于hook技術(shù)的細(xì)節(jié)在最后的參考文章里有列舉,有興趣的同學(xué)可以翻閱)。
至此,我們已經(jīng)可以捕獲到所有的對(duì)象分配事件了,但這只是我們邁出的一小步。
讓對(duì)象分配可被跟蹤
為了讓對(duì)象分配可被跟蹤,我們至少需要三個(gè)信息:這是什么對(duì)象;分配了多大內(nèi)存;它是怎么分配的。這幾個(gè)點(diǎn)看似清楚明了,但怎么做,還需要小費(fèi)一番周折。

1. 分配了多大內(nèi)存

這個(gè)信息最容易獲取,如果你還記得RecordAllocation函數(shù)的定義,你會(huì)發(fā)現(xiàn)byte_count已經(jīng)作為參數(shù)傳進(jìn)來了。沒錯(cuò),就是這么簡(jiǎn)單。

2. 這是什么對(duì)象

你也許已經(jīng)發(fā)現(xiàn)RecordAllocation還有一個(gè)參數(shù)是art::mirror::Class*,這是Java里Class在虛擬機(jī)里的鏡像,我們知道Java里拿到Class,就能直接調(diào)用getName方法知道這個(gè)類是什么。然鵝,在虛擬機(jī)的源碼里,GetName函數(shù)有是有,但是是內(nèi)聯(lián)函數(shù),我們沒有辦法拿到這個(gè)函數(shù)的地址。

怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配

這個(gè)咋整?不要方,我們繼續(xù)看源碼,就在不遠(yuǎn)處,有一個(gè)叫個(gè)GetDescriptor的函數(shù)。

怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配
可以說是業(yè)界良心了,我們通過dlsym就可以拿到這個(gè)函數(shù)的地址,然后調(diào)用它,傳入我們已經(jīng)拿到的art::mirror::Class*和一個(gè)std::string,就可以拿到類名(實(shí)際上是類的描述)。

3. 它是怎么分配的

要知道一個(gè)對(duì)象是怎么分配的,我們需要拿到它的調(diào)用棧,Ok,我們來看看虛擬機(jī)里面怎么做的。

怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配

這個(gè)能模仿實(shí)現(xiàn)嗎?多番查探,發(fā)現(xiàn)每個(gè)關(guān)鍵節(jié)點(diǎn)的實(shí)現(xiàn)都是內(nèi)聯(lián)函數(shù)。咋辦呢?

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

怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配

4. 發(fā)現(xiàn)不合理的對(duì)象分配

找到了合適的時(shí)機(jī),又收集到了需要的數(shù)據(jù),跟蹤發(fā)現(xiàn)不合理的對(duì)象分配就很容易了。我們可以發(fā)現(xiàn)某一次分配的大對(duì)象,也可以按照類名或者分類統(tǒng)計(jì)對(duì)象分配的頻率等等,還可以做更多定制化的監(jiān)控~

全版本支持
前面提到的方案已在Android5.x版本上驗(yàn)證OK,指定機(jī)型跑自動(dòng)化是可以的,但目前主流的開發(fā)設(shè)備是Android7.x甚至更高的版本,如果要在開發(fā)階段就能自動(dòng)發(fā)現(xiàn)內(nèi)存分配的問題,顯然不夠的。

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

系統(tǒng)版本
差異點(diǎn)
新增挑戰(zhàn)點(diǎn)
6.x
RecordAllocation函數(shù)新增一個(gè)參數(shù)Thread*
7.x
1. so權(quán)限收緊
2. RecordAllocation傳入的mirro::Class*變成了mirror::Object**
1. 應(yīng)用無法通過dlsym查詢函數(shù)地址
2. mirror::Object無法與mirror::Class對(duì)應(yīng)
8.x-9.x
RecordAllocation傳入的mirror::Object**變成了ObjPtr<Object*>*
無法直接訪問到Object*
對(duì)于我們的方案來講,主要的挑戰(zhàn)集中在Android7.x及以上版本,我們來看看這些問題如何各個(gè)擊破。

1. 繞過so訪問權(quán)限問題

Android7.0開始,要想動(dòng)態(tài)鏈接非NDK公開的so需要System或者Root權(quán)限,普通的app是做不到的。如果嘗試鏈接或者通過dlopen去打開,要么看到Permission Denied的錯(cuò)誤提示,要么直接Crash。
既然直接的方案不行,那就想辦法繞過去。
1.1 獲得so基址

我們知道,Android是基于Linux的操作系統(tǒng),Linux操作系統(tǒng)每個(gè)進(jìn)程都有一個(gè)maps文件記錄了所有模塊在內(nèi)存里起始地址,路徑是/proc/<pid>/maps,這里pid就是進(jìn)程的pid,訪問自己進(jìn)程用別名/proc/self/maps也可以。這個(gè)文件很關(guān)鍵,我們看看它里面是什么。

怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配

libart.so是虛擬機(jī)的so,可以看到這里它的起始地址是0xeaf18000。
函數(shù)的地址就是基址+偏移,現(xiàn)在基址已有,就差偏移了,偏移怎么拿?因?yàn)槊總€(gè)ROM的so多少都有差別,這個(gè)偏移肯定不能是hardcode的,我們要想辦法查到函數(shù)的偏移。一般來說有兩種辦法,第一種是無腦搜函數(shù)特征。
1.2 搜索函數(shù)地址 之 函數(shù)特征
怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配
這圖IDA打開一個(gè)Android7.1的libart.so查到的RecordAllocation函數(shù)的二進(jìn)制。這個(gè)二進(jìn)制的前8個(gè)或16個(gè)字節(jié)就可以用來作為這個(gè)函數(shù)的特征,我們?cè)趌ibart.so的內(nèi)存區(qū)域內(nèi)匹配這個(gè)特征就可以定位到這個(gè)函數(shù)了。
這個(gè)方法有個(gè)明顯的缺點(diǎn),因?yàn)镽OM廠家很有可能會(huì)修改虛擬機(jī)的代碼,或者修改編譯參數(shù),這種通過函數(shù)特征去定位函數(shù)的辦法最多只能作為特殊機(jī)型的兼容邏輯。我們應(yīng)該用一種更通用的方法,那就是直接解析ELF
1.3 搜索函數(shù)地址 之 解析ELF
so是一種ELF格式的文件,在Android系統(tǒng)里由linker加載到內(nèi)存。關(guān)于ELF的格式,網(wǎng)上很容易找到,各種結(jié)構(gòu)貼出來很長(zhǎng),這里不贅述。 雖然Android限制了我們dlopen打開NDK非公開的so,但本質(zhì)上,這些so對(duì)我們的進(jìn)程來說是有可讀權(quán)限的,所以解析ELF格式來查找函數(shù)的偏移是可行的,按照ELF的格式去解析就可以了,代碼沒有特別值得拎出來說的,但在實(shí)現(xiàn)的時(shí)候仍然有一些細(xì)節(jié)。
如果只是參考ELF的結(jié)構(gòu),我們能想到的直觀的辦法就是:遍歷字符串表,找到目標(biāo)函數(shù)名的偏移;然后遍歷符號(hào)表,找到目標(biāo)函數(shù)的偏移地址。這樣的做法沒毛病,但效率不夠高,因?yàn)槭潜闅v,所以復(fù)雜度為O(n)。
事實(shí)上,如果看過linker的源碼,我們會(huì)發(fā)現(xiàn),還有一個(gè)更高效的O(1)的查詢辦法。so里有一個(gè)section名字是.hash(有的是.gnu_hash,只是hash函數(shù)不同,但基本邏輯是一樣的),它里面存儲(chǔ)的其實(shí)是函數(shù)符號(hào)的索引。我們參考linker的實(shí)現(xiàn),把函數(shù)名(符號(hào)名)做一個(gè)hash,就可以在這個(gè)hash setion里面找到目標(biāo)函數(shù)在符號(hào)表的索引,進(jìn)而拿到函數(shù)的偏移地址。
解析ELF這種方案更通用,也是我最終采用的主要的方案。

2. 突如其來的SIGILL

解決了獲取函數(shù)地址的問題,運(yùn)行時(shí)發(fā)現(xiàn)Hook了搜索出來的函數(shù)就Crash了,系統(tǒng)拋了一個(gè)SIGILL的信號(hào)結(jié)束了我的進(jìn)程。SIGILL表示Illegal Instruction,這很有可能是我們的函數(shù)地址有問題。
不過基址是系統(tǒng)加載so時(shí)記錄的,這個(gè)應(yīng)該不會(huì)有錯(cuò);搜索出來的函數(shù)偏移和用IDA查看的函數(shù)偏移也是一致的。問題到底在哪?
此時(shí),我想到雖然NDK限制了對(duì)非公開so的權(quán)限,但我自己的so,就可以用dlsym來查找函數(shù)地址。于是寫了一個(gè)demo,發(fā)現(xiàn)一個(gè)“不可思議”的事實(shí):dlsym查到的函數(shù)地址 比 我搜索出來的函數(shù)地址 剛好大了1。
剛好大1,這絕非巧合。
這有點(diǎn)觸及到知識(shí)盲區(qū)了,翻閱了不少講解ARM匯編的文章,終于找到了答案。原來ARM匯編編譯時(shí)有ARM指令和THUMB指令兩種,ARM指令為4字節(jié),支持按條件執(zhí)行;而THUMB指令為2字節(jié),不支持按條件執(zhí)行。由于大部分場(chǎng)景都無需按條件執(zhí)行,所以編譯成THUMB指令,so更加緊湊。由于4字節(jié)和2字節(jié)都是偶數(shù),地址的最低位實(shí)際上是用不上的,ARM設(shè)計(jì)時(shí)就巧妙的將地址的最低位置1來表示要按照THUMB指令來解析了。

這就是剛好大1的原因。我們看到IDA反編譯出來的RecordAllocation函數(shù)也可以清楚的看到,確實(shí)一條指令是2個(gè)字節(jié),所以我們?cè)趯?shí)現(xiàn)的時(shí)候,要把搜索出來的地址做加1的修正。

怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配

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

關(guān)于mirror::Object無法獲取類名的問題,主要是因?yàn)樗锩嫠懈鷐irror::Class相關(guān)的函數(shù)全部是內(nèi)聯(lián)函數(shù),我們?cè)趯?shí)現(xiàn)的時(shí)候很難突破。還是那句話,既然往里走不行,那就試著走出來。我們可以拿到調(diào)用棧,那是否可以通過解析調(diào)用棧來獲取當(dāng)前分配的是什么對(duì)象呢?
答案是否定的。一方面是因?yàn)榻馕稣{(diào)用棧涉及字符串匹配操作,頻繁的字符串匹配操作,對(duì)性能的損耗是不太能接受的;另一方面是因?yàn)榻馕龆褩o法覆蓋所有的對(duì)象分配(并非所有的對(duì)象分配都會(huì)經(jīng)過<init>方法,例如 byte[])。

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

怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配

這是JNI的一個(gè)函數(shù),可以把mirror::Object轉(zhuǎn)成jobject,而jobject就是Java里Object在JNI層的表示。到了這一步,要獲取類名就非常簡(jiǎn)單了,obj.getClass().getName()即可。
關(guān)于Android8.x及以上系統(tǒng),把mirror::Object**改成ObjPtr<Object*>*的處理,就比較簡(jiǎn)單了,ObjPtr類定義比較簡(jiǎn)單,我們照著源碼里的ObjPtr實(shí)現(xiàn)一個(gè)結(jié)構(gòu)一樣的class,就可以訪問到里面包裹的mirror::Object*了。

到此,關(guān)于“怎么自動(dòng)識(shí)別Android不合理的內(nèi)存分配”的學(xué)習(xí)就結(jié)束了,希望能夠解決大家的疑惑。理論與實(shí)踐的搭配能更好的幫助大家學(xué)習(xí),快去試試吧!若想繼續(xù)學(xué)習(xí)更多相關(guān)知識(shí),請(qǐng)繼續(xù)關(guān)注億速云網(wǎng)站,小編會(huì)繼續(xù)努力為大家?guī)砀鄬?shí)用的文章!

向AI問一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長(zhǎng)郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI