iOS之深入解析如何检测“循环引用”
生活随笔
收集整理的這篇文章主要介紹了
iOS之深入解析如何检测“循环引用”
小編覺得挺不錯的,現(xiàn)在分享給大家,幫大家做個參考.
一、前言
- Objective-C 使用引用計數(shù)作為 iPhone 應(yīng)用的內(nèi)存管理方案,引用計數(shù)相比 GC 更適用于內(nèi)存不太充裕的場景,只需要收集與對象關(guān)聯(lián)的局部信息來決定是否回收對象,而 GC 為了明確可達(dá)性,需要全局的對象信息。引用計數(shù)固然有其優(yōu)越性,但也正是因為缺乏對全局對象信息的把控,導(dǎo)致 Objective-C 無法自動銷毀陷入循環(huán)引用的對象。雖然 Objective-C 通過引入弱引用技術(shù),讓開發(fā)者可以盡可能地規(guī)避這個問題,但在引用層級過深,引用路徑不那么直觀的情況下,即使是經(jīng)驗豐富的工程師,也無法百分百保證產(chǎn)出的代碼不存在循環(huán)引用。
- 這時候就需要有一種檢測方案,可以實時檢測對象之間是否發(fā)生了循環(huán)引用,來輔助開發(fā)者及時地修正代碼中存在的內(nèi)存泄漏問題。要想檢測出循環(huán)引用,最直觀的方式是遞歸地獲取對象強(qiáng)引用的其他對象,并判斷檢測對象是否被其路徑上的對象強(qiáng)引用了,也就是在有向圖中去找環(huán)。明確檢測方式之后,接下來需要解決的是如何獲取強(qiáng)引用鏈,也就是獲取對象的強(qiáng)引用,尤其是最容易造成循環(huán)引用的 block。
二、Block 捕獲實體引用
① 捕獲區(qū)域布局
- 根據(jù) block 的定義結(jié)構(gòu),可以簡單地將其視為:
- 可以看到 block 捕獲的變量都會存儲在 sr_block_layout 結(jié)構(gòu)體 descriptor 字段之后的內(nèi)存空間中,通過 clang -rewrite-objc 重寫如下代碼語句:
- 可以得到 :
- __main_block_impl_0 結(jié)構(gòu)中新增了捕獲的 i 字段,即 sr_block_layout 結(jié)構(gòu)體的 imported variables 部分,這種操作可以看作在 sr_block_layout 尾部定義了一個 0 長數(shù)組,可以根據(jù)實際捕獲變量的大小,給捕獲區(qū)域申請對應(yīng)的內(nèi)存空間,只不過這一操作由編譯器完成:
- 既然已經(jīng)知道捕獲變量 i 的存放地址,那么就可以通過 *(int *)layout->captured 在運行時獲取 i 的值,得到捕獲區(qū)域的起始地址之后,再來看捕獲區(qū)域的布局問題,考慮以下代碼塊:
- 捕獲區(qū)域的布局分兩部分看:順序和大小,先使用老方法重寫代碼塊:
- 按照目前 clang 針對 64 位機(jī)的默認(rèn)對齊方式(下文的字節(jié)對齊計算都基于此前提條件),可以計算出這個結(jié)構(gòu)體占用的內(nèi)存空間大小為 24 + 8 + 8 + 8 = 48字節(jié),并且按照上方代碼塊先 i 后 o 的捕獲排序方式,如果要訪問捕獲的 o 對象指針變量,只需要在捕獲區(qū)域起始地址上偏移 8 字節(jié)即可,可以借助 lldb 的 memory read (x) 命令查看這部分內(nèi)存空間:
- 和使用 clang -rewrite-objc 重寫時的猜想不一樣,可以從以上終端日志中看出以下兩點:
-
- 捕獲變量 i、o 在捕獲區(qū)域的排序方式為 o、i,o 變量地址與捕獲起始地址一致,i 變量地址為捕獲起始地址加上 8 字節(jié);
-
- 捕獲整形變量 i 在內(nèi)存中實際占用空間大小為 4 字節(jié);
- 那么 block 到底是怎么對捕獲變量進(jìn)行排序,并且為其分配內(nèi)存空間的呢?這就需要看 clang 是如何處理 block 捕獲的外部變量。
② 捕獲區(qū)域布局分析
- 首先解決捕獲變量排序的問題,根據(jù) clang 針對這部分的排序代碼,可以知道,在對齊字節(jié)數(shù) (alignment) 不相等時,捕獲的實體按照 alignment 降序排序 (C 結(jié)構(gòu)體比較特殊,即使整體占用空間比指針變量大,也排在對象指針后面),否則按照以下類型進(jìn)行排序:
-
- __strong 修飾對象指針變量;
-
- __block 修飾對象指針變量;
-
- __weak 修飾對象指針變量;
-
- 其他變量;
- 再結(jié)合 clang 對捕獲變量對齊子節(jié)數(shù)計算方式 ,可以知道,block 捕獲區(qū)域變量的對齊結(jié)果趨向于被 attribute ((packed)) 修飾的結(jié)構(gòu)體,舉個例子:
- 創(chuàng)建 foo 結(jié)構(gòu)體需要分配的空間大小為 8 + 4 + 4 = 16,關(guān)于結(jié)構(gòu)體的內(nèi)存對齊方式,編譯器會按照成員列表的順序一個接一個地給每個成員分配內(nèi)存,只有當(dāng)存儲成員需要滿足正確的邊界對齊要求時,成員之間才可能出現(xiàn)用于填充的額外內(nèi)存空間,以提升計算機(jī)的訪問速度(對齊標(biāo)準(zhǔn)一般和尋址長度一致),在聲明結(jié)構(gòu)體時,讓那些對齊邊界要求最嚴(yán)格的成員最先出現(xiàn),對邊界要求最弱的成員最后出現(xiàn),可以最大限度地減少因邊界對齊而帶來的空間損失。再看以下代碼塊:
- attribute ((packed)) 編譯屬性會告訴編譯器,按照字段的實際占用子節(jié)數(shù)進(jìn)行對齊,所以創(chuàng)建 foo 結(jié)構(gòu)體需要分配的空間大小為 8 + 4 + 1 = 13。
- 結(jié)合以上兩點,可以嘗試分析以下 block 捕獲區(qū)域的變量布局情況:
- 按照 aligment 排序,可以得到排序順序為 [o1 o2 o3] j i c,再根據(jù) __strong、__block、__weak 修飾符對 o1 o2 o3 進(jìn)行排序,可得到最終結(jié)果 o1[8] o3[8] o2[8] j[8] i[4] c[1]。同樣的,我們使用 lldb 的 x 命令驗證分析結(jié)果是否正確:
- 可以看到,小端模式下,捕獲的 o1 和 o2 指針變量值為 0x10200f6a0,對應(yīng)內(nèi)存地址為 0x10200d960 和 0x10200d970,而 o3 因為被 __block 修飾,編譯器為 o3 捕獲變量包裝了一層 byref 結(jié)構(gòu),所以其值為 byref 結(jié)構(gòu)的地址 0x102000d990,而不是 0x10200f6a0,捕獲的 j 變量地址為 0x10200d978,i 變量地址為 0x10200d980,c 字符變量緊隨其后。
③ Descriptor 的 Layout 信息
- 經(jīng)過上述的一系列分析,捕獲區(qū)域變量的布局方式已經(jīng)大致清楚,接下來回過頭看下 sr_block_descriptor 結(jié)構(gòu)的 layout 字段是用來干什么的?從字面上理解,這個字段很可能保存了 block 某一部分的內(nèi)存布局信息,比如捕獲區(qū)域的布局信息,依然使用上文的最后一個例子,看看 layout 的值:
- 可以看到 layout 值為空字符串,并沒有展示出任何直觀的布局信息,看來要想知道 layout 是怎么運作的,可以閱讀 block 代碼 和 clang 代碼,繼續(xù)一步步地分析這兩段代碼里面隱藏的信息,這里貼出其中的部分代碼和注釋:
- 首先要解釋的是 inline 這個詞,Objective-C 中有一種叫做 Tagged Pointer 的技術(shù),它讓指針保存實際值,而不是保存實際值的地址,這里的 inline 也是相同的效果,即讓 layout 指針保存實際的編碼信息。在 inline 狀態(tài)下,使用十六進(jìn)制中的一位表示捕獲變量的數(shù)量,所以每種類型的變量最多只能有 15 個,此時的 layout 的值以 0xXYZ 形式呈現(xiàn),其中 X、Y、Z 分別表示捕獲 __strong、__block、__weak 修飾指針變量的個數(shù),如果其中某個類型的數(shù)量超過 15 或者捕獲變量的修飾類型不為這三種任何一個時,比如捕獲的變量由 __unsafe_unretained 修飾,則采用另一種編碼方式,這種方式下,layout 會指向一個字符串,這個字符串的每個字節(jié)以 0xPN 的形式呈現(xiàn),并以 0x00 結(jié)束,P 表示變量類型,N 表示變量個數(shù),需要注意的是,N 為 0 表示 P 類型有一個,而不是 0 個,也就是說實際的變量個數(shù)比 N 大 1。
- 需要注意的是,捕獲 int 等基礎(chǔ)類型,不影響 layout 的呈現(xiàn)方式,layout 編碼中也不會有關(guān)于基礎(chǔ)類型的信息,除非需要基礎(chǔ)類型的編碼來輔助定位對象指針類型的位置,比如捕獲含有對象指針字段的結(jié)構(gòu)體。
- 如下所示:代碼塊沒有捕獲任何對象指針,所以實際的 descriptor 不包含 copy 和 dispose 字段:
- 去除這兩個字段后,再輸出實際的布局信息,結(jié)果為空(0x00 表示結(jié)束),說明捕獲一般基礎(chǔ)類型變量不會計入實際的 layout 編碼:
- 接著嘗試第一種 layout 方式:
- 以上代碼塊對應(yīng)的 layout 值為 0x111,表示三種類型變量每種一個:
- 再嘗試第二種 layout 編碼方式:
- 以上代碼塊對應(yīng)的 layout 值是一個地址 0x0000000100002f44 ,這個地址為編碼字符串的起始地址,轉(zhuǎn)換成十六進(jìn)制后為 0x3f 0x30 0x40 0x50 0x00,其中 P 為 3 表示 __strong 修飾的變量,數(shù)量為 15(f) + 1 + 0 + 1 = 17 個,P 為 4 表示 __block 修飾的變量,數(shù)量為 0 + 1 = 1 個, P 為 5 表示 __weak 修飾的變量,數(shù)量為 0 + 1 = 1 個:
④ 結(jié)構(gòu)體對捕獲布局的影響
- 由于結(jié)構(gòu)體字段的布局順序在聲明時就已經(jīng)確定,無法像 block 構(gòu)造捕獲區(qū)域一樣,按照變量類型、修飾符進(jìn)行調(diào)整,所以如果結(jié)構(gòu)體中有類型為對象指針的字段,就需要一些額外信息來計算這些對象指針字段的偏移量,需要注意的是,被捕獲結(jié)構(gòu)體的內(nèi)存對齊信息和未捕獲時一致,以尋址長度作為對齊基準(zhǔn),捕獲操作并不會變更對齊信息。
- 同樣地,先嘗試捕獲只有基本類型字段的結(jié)構(gòu)體:
- 然后調(diào)整 descriptor 結(jié)構(gòu),輸出 layout :
- 可以看到,只有含有基本類型的結(jié)構(gòu)體,同樣不會影響 block 的 layout 編碼信息。給結(jié)構(gòu)體新增 __strong 和 __weak 修飾的對象指針字段:
- 同樣分析輸出 layout :
- layout 編碼為0x20 0x30 0x20 0x50 0x00,其中 P 為 2 表示 word 字類型(非對象),由于字大小一般和指針一致,所以表示占用 8 * (N + 1) 個字節(jié),第一個 0x20 表示非對象指針類型占用了 8 個字節(jié),也就是 char 類型和 int 類型字段對齊之后所占用的空間,接著 0x30 表示有一個 __strong 修飾的對象指針字段,第二個 0x20 表示非對象指針 long 類型占用 8 個字節(jié),最后的 0x50 表示有一個 __weak 修飾的對象指針字段。由于編碼中包含每個字段的排序和大小,就可以通過解析 layout 編碼后的偏移量,拿到想要的對象指針值。 P 還有個 byte 類型,值為 1,和 word 類型有相似的功能,只是表示的空間大小不同。
⑤ Byref 結(jié)構(gòu)的布局
- 由 __block 修飾的捕獲變量,會先轉(zhuǎn)換成 byref 結(jié)構(gòu),再由這個結(jié)構(gòu)去持有實際的捕獲變量,block 只負(fù)責(zé)管理 byref 結(jié)構(gòu):
- 以上代碼塊就是 byref 對應(yīng)的結(jié)構(gòu)體,第一眼看上去,比較困惑為什么還要有 layout 字段,雖然 block 源碼注釋說明 byref 和 block 結(jié)構(gòu)一樣,都具備兩種不同的布局編碼方式,但是 byref 不是只針對一個變量嗎,難道和 block 捕獲區(qū)域一樣也可以攜帶多個捕獲變量?帶著這個困惑,先看下以下表達(dá)式 :
- 使用 clang 重寫之后:
- 和 block 捕獲變量一樣,byref 攜帶的變量也是保存在結(jié)構(gòu)體尾部的內(nèi)存空間里,當(dāng)前上下文中,可以直接通過 sr_block_byref 的 layout 字段獲取 o1 對象指針值。可以看到,在包裝如對象指針這類常規(guī)變量時,layout 字段并沒有起到實質(zhì)性的作用,那什么條件下的 layout 才表示布局編碼信息呢?如果使用 layout 字段表示編碼信息,那么攜帶的變量又是何處安放的呢?
- 針對第一個問題,先看以下代碼塊 :
- 使用 clang 重寫之后:
- 和常規(guī)類型一樣,foo 結(jié)構(gòu)體保存在結(jié)構(gòu)體尾部,也就是原本 layout 所在的字段,重寫的代碼中依然看不到 layout 的蹤影,接著輸出 foo :
- 看來事情并沒有看上去的那么簡單,首先重寫代碼中 foo 字段所在內(nèi)存保存的并不是結(jié)構(gòu)體,而是 0x0000000000000100,這個 100 是不是看著有點眼熟?沒錯,這就是 byref 的 layout 信息,根據(jù) 0xXYZ 編碼規(guī)則,這個值表示有 1 個 __strong 修飾的對象指針。
- 接著針對第二個問題,攜帶的對象指針變量存在哪?往下移動 8 個字節(jié),這不就是 foo.o1 對象指針的值么?總結(jié)下,在存在 layout 的情況下,byref 使用 8 個字節(jié)保存 layout 編碼信息,并緊跟著在 layout 字段后存儲捕獲的變量。
- 以上是 byref 的第一種 layout 編碼方式,再嘗試第二種:
- 使用 clang 重寫代碼之后 :
- 上面代碼并不是粘貼錯誤,貌似 Rewriter 并不能很好地處理這種情況,看來又需要直接去看對應(yīng)內(nèi)存地址中的值:
- 地址 0x100755168 中保存 layout 編碼字符串的地址 0x0000000100003e8d ,將此字符串轉(zhuǎn)換成十六進(jìn)制后為 0x20 0x30 0x50 0x00。
⑥ 強(qiáng)引用對象的獲取
- 已經(jīng)知道 block / byref 如何布局捕獲區(qū)域內(nèi)存,以及如何獲取關(guān)鍵的布局信息,接下來就可以嘗試獲取 block 強(qiáng)引用的對象,強(qiáng)引用的對象可以分成兩部分:
-
- 被 block 強(qiáng)引用;
-
- 被 byref 結(jié)構(gòu)強(qiáng)引用。
- 只要獲取這兩部分強(qiáng)引用的對象就可以了,由于上文已經(jīng)將整個原理脈絡(luò)理清,所以編寫出可用的代碼并不困難。這兩部分都涉及到布局編碼,先根據(jù) layout 的編碼方式,解析出捕獲變量的類型和數(shù)量:
- 然后遍歷 block 的布局編碼信息,根據(jù)變量類型和數(shù)量,計算出對象指針地址偏移,獲取對應(yīng)的對象指針值:
- block 布局區(qū)域中的 byref 結(jié)構(gòu)需要進(jìn)行額外的處理,如果 byref 直接攜帶 __strong 修飾的變量,則不需要關(guān)心 layout 編碼,直接從結(jié)構(gòu)尾部獲取指針變量值即可,否則需要和處理 block 布局區(qū)域一樣,先得到布局信息,然后遍歷這些布局信息,計算偏移量,獲取強(qiáng)引用對象地址:
⑦ 另一種強(qiáng)引用對象獲取方式
- 上文通過將 block 的布局編碼信息轉(zhuǎn)化為對應(yīng)字段的偏移量來獲取強(qiáng)引用對象,還有另外一種比較取巧的方式,也是目前檢測循環(huán)引用工具獲取 block 強(qiáng)引用對象的常用方式,比如 facebook 的 FBRetainCycleDetector。
- 根據(jù) FBRetainCycleDetector 對應(yīng)的源碼,此方式大致原理如下:
-
- 獲取 block 的 dispose 函數(shù) (如果捕獲了強(qiáng)引用對象,需要利用這個函數(shù)解引用);
-
- 構(gòu)造一個 fake 對象,此對象由若干個擴(kuò)展的 byref 結(jié)構(gòu) (對象) 組成,其個數(shù)由 block size 決定,即把 block 劃分為若干個 8 字節(jié)內(nèi)存區(qū)域,就像以下代碼塊一樣 :
-
- 擴(kuò)展的 byref 結(jié)構(gòu)會重寫 release 方法,只在此方法中設(shè)置強(qiáng)引用標(biāo)識位,不執(zhí)行原釋放邏輯;
-
- 將 fake 對象作為參數(shù),調(diào)用 dispose 函數(shù),dispose 函數(shù)會去 release 每個 block 強(qiáng)引用的對象,這些強(qiáng)引用對象被替換成 byref 結(jié)構(gòu),所以可以通過它的強(qiáng)引用標(biāo)識位判斷 block 的哪塊區(qū)域保存了強(qiáng)引用對象地址;
-
- 遍歷 fake 對象,保存所有強(qiáng)引用標(biāo)志位被設(shè)置的 byref 結(jié)構(gòu)對應(yīng)索引,通過這個索引可以去 block 中找強(qiáng)引用指針地址;
-
- 釋放所有的 byref 結(jié)構(gòu);
-
- 根據(jù)上面得到的索引,獲取捕獲變量偏移量,偏移量為索引值 * 8 字節(jié) (指針大小) ,再根據(jù)偏移量去 block 內(nèi)存塊中拿強(qiáng)引用對象地址。
- 關(guān)于這種方案,需要明確:
-
- 首先這種方案也需要在明確 block 內(nèi)存布局的情況下才能夠?qū)嵤?#xff0c;因為 block ,或者說 block 結(jié)構(gòu)體,實際執(zhí)行內(nèi)存對齊時,并沒有按照尋址大小也就是 8 字節(jié)對齊,假設(shè) block 捕獲區(qū)域的對齊方式變成如下的這樣 :
-
- 那么使用 fake 的方案就會失效,因為這種方案的前提是 block 內(nèi)存對齊基準(zhǔn)基于尋址長度,即指針大小。不過 block 對捕獲的變量按照類型和尺寸進(jìn)行了排序,__strong 修飾的對象指針都在前面,本來只需要這種類型的變量,并不關(guān)心其它類型,所以即使后面的對齊方式不滿足 fake 條件也沒關(guān)系,另外捕獲結(jié)構(gòu)體的對齊基準(zhǔn)是基于尋址長度的,即使結(jié)構(gòu)體有其他類型,也滿足 fake 條件 :
-
- 可以看到,通過以上代碼塊的排序,讓 o1 和 o2 都被 FakedByref 結(jié)構(gòu)覆蓋,而 i、c 變量本身就不會在 dispose 函數(shù)中訪問,因此怎么設(shè)置都不會影響到策略的生效;
-
- 第二點是為什么要用擴(kuò)展的 byref 結(jié)構(gòu),而不是隨便整個重寫 release 的類,這是因為當(dāng) block 捕獲了 __block 修飾的指針變量時,會將這個指針變量包裝成 byref 結(jié)構(gòu),而 dispose 函數(shù)會對這個 byref 結(jié)構(gòu)執(zhí)行 _Block_object_dispose 操作,這個函數(shù)有兩個形參,一個是對象指針,一個是 flag,當(dāng) flag 指明對象指針為 byref 類型,而實際傳入的實參不是,就會出現(xiàn)問題,所以必須用擴(kuò)展的 byref 結(jié)構(gòu);
-
- 第三點是這種方式無法處理 __block 修飾對象指針的情況。
- 不過這種方式貴在簡潔,無需考慮內(nèi)部每種變量類型具體的布局方式,就可以滿足大部分需要獲取 block 強(qiáng)引用對象的場景。
三、對象成員變量強(qiáng)引用
- 對象強(qiáng)引用成員變量的獲取相對來說直接些,因為每個對象對應(yīng)的類中都有其成員變量的布局信息,并且 runtime 有現(xiàn)成的接口,只需要分析出編碼格式,然后按順序和成員變量匹配即可。獲取編碼信息的接口有兩個, class_getIvarLayout 函數(shù)返回描述 strong ivar 數(shù)量和索引信的編碼信息,相對的 class_getWeakIvarLayout 函數(shù)返回描述 weak ivar 的編碼信息。
- class_getIvarLayout 返回值是一個 uint8 指針,指向一個字符串,uint8 在 16 進(jìn)制下占用 2 位,所以編碼以 2 位為一組,組內(nèi)首位描述非 strong ivar 個數(shù),次位為 strong ivar 個數(shù),最后一組如果 strong ivar 個數(shù)為 0,則忽略,且 layout 以 0x00 結(jié)尾。
- 如下所示:
- 起始非 strong ivar 個數(shù)為 0,并且接著一個 strong ivar ,得出編碼為 0x01 。
- 起始非 strong ivar 個數(shù)為 0,并且接著一個 strong ivar ,得出編碼為 0x01,接著有個 weak ivar,但是后面沒有 strong ivar,所以忽略。
- 起始非 strong ivar 個數(shù)為 0,并且接著一個 strong ivar ,得出編碼為 0x01,接著有個 weak ivar,并且后面緊接著一個 strong ivar ,得出編碼 0x11 ,合并得到 0x0111。
- 起始非 strong ivar 個數(shù)為 2,并且緊接著一個 strong ivar,得出編碼 0x21,接著有個 weak ivar,后面緊接著一個 strong ivar ,得出編碼 0x11 ,合并得到 0x2111。
- 了解了成員變量的編碼格式,剩下的就是如何解碼并依次和成員變量進(jìn)行匹配, FBRetainCycleDetector 已經(jīng)實現(xiàn)了這部分功能 ,主要原理如下:
-
- 獲取所有的成員變量以及 ivar 編碼;
-
- 解析 ivar 編碼,跳過非 strong ivar ,獲得 strong ivar 所在索引值 (把對象分成若干個 8 字節(jié)內(nèi)存片段);
-
- 利用 ivar_getOffset 函數(shù)獲取 ivar 的偏移量,除以指針大小就是自身的索引值 (對象布局對齊基準(zhǔn)為尋址長度,這里為 8 字節(jié));
-
- 匹配 2、3 步獲得的索引值,得到 strong ivar;
-
- 實現(xiàn)了對結(jié)構(gòu)體的處理。
四、總結(jié)
- “Block 捕獲實體引用”和“對象成員變量強(qiáng)引用”是檢測循環(huán)引用兩個比較關(guān)鍵的點,特別是獲取 block 捕獲的強(qiáng)引用對象環(huán)節(jié),block ABI 中并沒有詳細(xì)說明捕獲區(qū)域布局信息,需要自己結(jié)合 block 源碼以及 clang 生成 block 的 CodeGen 邏輯去推測實際的布局信息。
總結(jié)
以上是生活随笔為你收集整理的iOS之深入解析如何检测“循环引用”的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: iOS之深入解析如何使用Block实现委
- 下一篇: iOS逆向之深入解析如何Hook所有+l