直到 2013 年左右,蘋果認(rèn)為 ARC 技術(shù)足夠成熟,直接將 macOS(當(dāng)時叫 OS X)上的垃圾回收機(jī)制廢棄,從而使得 ARC 迅速被接受。
2014 年的 WWDC 大會上,蘋果推出了 Swift 語言,而該語言仍然使用 ARC 技術(shù),作為其內(nèi)存管理方式。
雖然 ARC 幫我們解決了引用計(jì)數(shù)的大部分問題,做不好內(nèi)存管理工作。不能理解常見的循環(huán)引用問題,而這些問題會導(dǎo)致內(nèi)存泄漏,最終使得應(yīng)用運(yùn)行緩慢或者被系統(tǒng)終止進(jìn)程。
所以,我們每一個 iOS 開發(fā)者,需要理解引用計(jì)數(shù)這種內(nèi)存管理方式,只有這樣,才能處理好內(nèi)存管理相關(guān)的問題。
什么是引用計(jì)數(shù)
引用計(jì)數(shù)(Reference Count)是一個簡單而有效的管理對象生命周期的方式。當(dāng)我們創(chuàng)建一個新對象的時候,它的引用計(jì)數(shù)為 1,當(dāng)有一個新的指針指向這個對象時,我們將其引用計(jì)數(shù)加 1,當(dāng)某個指針不再指向這個對象是,我們將其引用計(jì)數(shù)減 1,當(dāng)對象的引用計(jì)數(shù)變?yōu)?0 時,說明這個對象不再被任何指針指向了,這個時候我們就可以將對象銷毀,回收內(nèi)存。由于引用計(jì)數(shù)簡單有效,除了 Objective-C 和 Swift 語言外,微軟的 COM(Component Object Model )、C++11(C++11 提供了基于引用計(jì)數(shù)的智能指針 share_prt)等語言也提供了基于引用計(jì)數(shù)的內(nèi)存管理方式。
為了更形象一些,我們再來看一段 Objective-C 的代碼。新建一個工程,因?yàn)楝F(xiàn)在默認(rèn)的工程都開啟了自動的引用計(jì)數(shù) ARC(Automatic Reference Count),我們先修改工程設(shè)置,給 AppDelegate.m 加上 -fno-objc-arc 的編譯參數(shù)(如下圖所示),這個參數(shù)可以啟用手工管理引用計(jì)數(shù)的模式。
然后,我們在中輸入如下代碼,可以通過 Log 看到相應(yīng)的引用計(jì)數(shù)的變化。
|
<pre style="font-family: "Source Code Pro", Consolas, Menlo, Monaco, "Courier New", monospace; overflow: auto; font-size: 0.9rem; margin: 0px; padding: 0px; line-height: 1.3rem; font-weight: 400; color: rgb(171, 178, 191); background: rgb(40, 44, 52); border: none;">- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
NSObject *object = [[NSObject alloc] init];
NSLog(@"Reference Count = %u", [object retainCount]);
NSObject *another = [object retain];
NSLog(@"Reference Count = %u", [object retainCount]);
[another release];
NSLog(@"Reference Count = %u", [object retainCount]);
[object release];
// 到這里時,object 的內(nèi)存被釋放了
return YES;
}
</pre>
|
運(yùn)行結(jié)果:
|
<pre style="font-family: "Source Code Pro", Consolas, Menlo, Monaco, "Courier New", monospace; overflow: auto; font-size: 0.9rem; margin: 0px; padding: 0px; line-height: 1.3rem; font-weight: 400; color: rgb(171, 178, 191); background: rgb(40, 44, 52); border: none;">Reference Count = 1
Reference Count = 2
Reference Count = 1
</pre>
|
對 Linux 文件系統(tǒng)比較了解的同學(xué)可能發(fā)現(xiàn),引用計(jì)數(shù)的這種管理方式類似于文件系統(tǒng)里面的硬鏈接。在 Linux 文件系統(tǒng)中,我們用 ln 命令可以創(chuàng)建一個硬鏈接(相當(dāng)于我們這里的 retain),當(dāng)刪除一個文件時(相當(dāng)于我們這里的 release),系統(tǒng)調(diào)用會檢查文件的 link count 值,如果大于 1,則不會回收文件所占用的磁盤區(qū)域。直到最后一次刪除前,系統(tǒng)發(fā)現(xiàn) link count 值為 1,則系統(tǒng)才會執(zhí)行直正的刪除操作,把文件所占用的磁盤區(qū)域標(biāo)記成未用。
我們?yōu)槭裁葱枰糜?jì)數(shù)
從上面那個簡單的例子中,我們還看不出來引用計(jì)數(shù)真正的用處。因?yàn)樵搶ο蟮纳谥皇窃谝粋€函數(shù)內(nèi),所以在真實(shí)的應(yīng)用場景下,我們在函數(shù)內(nèi)使用一個臨時的對象,通常是不需要修改它的引用計(jì)數(shù)的,只需要在函數(shù)返回前將該對象銷毀即可。
引用計(jì)數(shù)真正派上用場的場景是在面向?qū)ο蟮某绦蛟O(shè)計(jì)架構(gòu)中,用于對象之間傳遞和共享數(shù)據(jù)。我們舉一個具體的例子:
假如對象 A 生成了一個對象 M,需要調(diào)用對象 B 的某一個方法,將對象 M 作為參數(shù)傳遞過去。在沒有引用計(jì)數(shù)的情況下,一般內(nèi)存管理的原則是 “誰申請誰釋放”,那么對象 A 就需要在對象 B 不再需要對象 M 的時候,將對象 M 銷毀。但對象 B 可能只是臨時用一下對象 M,也可能覺得對象 M 很重要,將它設(shè)置成自己的一個成員變量,那這種情況下,什么時候銷毀對象 M 就成了一個難題。
對于這種情況,有一個暴力的做法,就是對象 A 在調(diào)用完對象 B 之后,馬上就銷毀參數(shù)對象 M,然后對象 B 需要將參數(shù)另外復(fù)制一份,生成另一個對象 M2,然后自己管理對象 M2 的生命期。但是這種做法有一個很大的問題,就是它帶來了更多的內(nèi)存申請、復(fù)制、釋放的工作。本來一個可以復(fù)用的對象,因?yàn)椴环奖愎芾硭纳?,就簡單的把它銷毀,又重新構(gòu)造一份一樣的,實(shí)在太影響性能。如下圖所示:
[圖片上傳失敗...(image-c90666-1623424772056)]
我們另外還有一種辦法,就是對象 A 在構(gòu)造完對象 M 之后,始終不銷毀對象 M,由對象 B 來完成對象 M 的銷毀工作。如果對象 B 需要長時間使用對象 M,它就不銷毀它,如果只是臨時用一下,則可以用完后馬上銷毀。這種做法看似很好地解決了對象復(fù)制的問題,但是它強(qiáng)烈依賴于 AB 兩個對象的配合,代碼維護(hù)者需要明確地記住這種編程約定。而且,由于對象 M 的申請是在對象 A 中,釋放在對象 B 中,使得它的內(nèi)存管理代碼分散在不同對象中,管理起來也非常費(fèi)勁。如果這個時候情況再復(fù)雜一些,例如對象 B 需要再向?qū)ο?C 傳遞對象 M,那么這個對象在對象 C 中又不能讓對象 C 管理。所以這種方式帶來的復(fù)雜性更大,更不可取。
所以引用計(jì)數(shù)很好的解決了這個問題,在參數(shù) M 的傳遞過程中,哪些對象需要長時間使用這個對象,就把它的引用計(jì)數(shù)加 1,使用完了之后再把引用計(jì)數(shù)減 1。所有對象都遵守這個規(guī)則的話,對象的生命期管理就可以完全交給引用計(jì)數(shù)了。我們也可以很方便地享受到共享對象帶來的好處。
不要向已經(jīng)釋放的對象發(fā)送消息
有些同學(xué)想測試當(dāng)對象釋放時,其 retainCount 是否變成了 0,他們的試驗(yàn)代碼如下:
|
<pre style="font-family: "Source Code Pro", Consolas, Menlo, Monaco, "Courier New", monospace; overflow: auto; font-size: 0.9rem; margin: 0px; padding: 0px; line-height: 1.3rem; font-weight: 400; color: rgb(171, 178, 191); background: rgb(40, 44, 52); border: none;">- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
NSObject *object = [[NSObject alloc] init];
NSLog(@"Reference Count = %u", [object retainCount]);
[object release];
NSLog(@"Reference Count = %u", [object retainCount]);
return YES;
}
</pre>
|
但是,如果你真的這么實(shí)驗(yàn),你得到的輸出結(jié)果可能是以下這樣:
|
<pre style="font-family: "Source Code Pro", Consolas, Menlo, Monaco, "Courier New", monospace; overflow: auto; font-size: 0.9rem; margin: 0px; padding: 0px; line-height: 1.3rem; font-weight: 400; color: rgb(171, 178, 191); background: rgb(40, 44, 52); border: none;">Reference Count = 1
Reference Count = 1
</pre>
|
我們注意到,最后一次輸出,引用計(jì)數(shù)并沒有變成 0。這是為什么呢?因?yàn)樵搶ο蟮膬?nèi)存已經(jīng)被回收,而我們向一個已經(jīng)被回收的對象發(fā)了一個 retainCount 消息,所以它的輸出結(jié)果應(yīng)該是不確定的,如果該對象所占的內(nèi)存被復(fù)用了,那么就有可能造成程序異常崩潰。
那為什么在這個對象被回收之后,這個不確定的值是 1 而不是 0 呢?這是因?yàn)楫?dāng)最后一次執(zhí)行 release 時,系統(tǒng)知道馬上就要回收內(nèi)存了,就沒有必要再將 retainCount 減 1 了,因?yàn)椴还軠p不減 1,該對象都肯定會被回收,而對象被回收后,它的所有的內(nèi)存區(qū)域,包括 retainCount 值也變得沒有意義。不將這個值從 1 變成 0,可以減少一次內(nèi)存的寫操作,加速對象的回收。
拿我們之前提到的 Linux 文件系統(tǒng)舉列,Linux 文件系統(tǒng)下刪除一個文件,也不是真正的將文件的磁盤區(qū)域進(jìn)行抹除操作,而只是刪除該文件的索引節(jié)點(diǎn)號。這也和引用計(jì)數(shù)的內(nèi)存回收方式類似,即回收時只做標(biāo)記,并不抹除相關(guān)的數(shù)據(jù)。
ARC 下的內(nèi)存管理問題
ARC 能夠解決 iOS 開發(fā)中 90% 的內(nèi)存管理問題,但是另外還有 10% 內(nèi)存管理,是需要開發(fā)者自己處理的,這主要就是與底層 Core Foundation 對象交互的那部分,底層的 Core Foundation 對象由于不在 ARC 的管理下,所以需要自己維護(hù)這些對象的引用計(jì)數(shù)。
對于 ARC 盲目依賴的 iOS 新人們,由于不知道引用計(jì)數(shù),他們的問題主要體現(xiàn)在:
- 過度使用 block 之后,無法解決循環(huán)引用問題。
- 遇到底層 Core Foundation 對象,需要自己手工管理它們的引用計(jì)數(shù)時,顯得一籌莫展。
循環(huán)引用(Reference Cycle)問題
引用計(jì)數(shù)這種管理內(nèi)存的方式雖然很簡單,但是有一個比較大的瑕疵,即它不能很好的解決循環(huán)引用問題。如下圖所示:對象 A 和對象 B,相互引用了對方作為自己的成員變量,只有當(dāng)自己銷毀時,才會將成員變量的引用計(jì)數(shù)減 1。因?yàn)閷ο?A 的銷毀依賴于對象 B 銷毀,而對象 B 的銷毀與依賴于對象 A 的銷毀,這樣就造成了我們稱之為循環(huán)引用(Reference Cycle)的問題,這兩個對象即使在外界已經(jīng)沒有任何指針能夠訪問到它們了,它們也無法被釋放。
不止兩對象存在循環(huán)引用問題,多個對象依次持有對方,形式一個環(huán)狀,也可以造成循環(huán)引用問題,而且在真實(shí)編程環(huán)境中,環(huán)越大就越難被發(fā)現(xiàn)。下圖是 4 個對象形成的循環(huán)引用問題。
主動斷開循環(huán)引用
解決循環(huán)引用問題主要有兩個辦法,第一個辦法是我明確知道這里會存在循環(huán)引用,在合理的位置主動斷開環(huán)中的一個引用,使得對象得以回收。如下圖所示:
主動斷開循環(huán)引用這種方式常見于各種與 block 相關(guān)的代碼邏輯中。例如在我開源的 YTKNetwork 網(wǎng)絡(luò)庫中,網(wǎng)絡(luò)請求的回調(diào) block 是被持有的,但是如果這個 block 中又存在對于 View Controller 的引用,就很容易產(chǎn)生從循環(huán)引用,因?yàn)椋?/p>
- Controller 持有了網(wǎng)絡(luò)請求對象
- 網(wǎng)絡(luò)請求對象持有了回調(diào)的 block
- 回調(diào)的 block 里面使用了
self,所以持有了 Controller
解決辦法就是,在網(wǎng)絡(luò)請求結(jié)束后,網(wǎng)絡(luò)請求對象執(zhí)行完 block 之后,主動釋放對于 block 的持有,以便打破循環(huán)引用。相關(guān)的代碼見:
|
<pre style="font-family: "Source Code Pro", Consolas, Menlo, Monaco, "Courier New", monospace; overflow: auto; font-size: 0.9rem; margin: 0px; padding: 0px; line-height: 1.3rem; font-weight: 400; color: rgb(171, 178, 191); background: rgb(40, 44, 52); border: none;">// https://github.com/yuantiku/YTKNetwork/blob/master/YTKNetwork/YTKBaseRequest.m
// 第 147 行:
- (void)clearCompletionBlock {
// 主動釋放掉對于 block 的引用
self.successCompletionBlock = nil;
self.failureCompletionBlock = nil;
}
</pre>
|
不過,主動斷開循環(huán)引用這種操作依賴于程序員自己手工顯式地控制,相當(dāng)于回到了以前 “誰申請誰釋放” 的內(nèi)存管理年代,它依賴于程序員自己有能力發(fā)現(xiàn)循環(huán)引用并且知道在什么時機(jī)斷開循環(huán)引用回收內(nèi)存(這通常與具體的業(yè)務(wù)邏輯相關(guān)),所以這種解決方法并不常用,更常見的辦法是使用弱引用 (weak reference) 的辦法。
使用弱引用
弱引用雖然持有對象,但是并不增加引用計(jì)數(shù),這樣就避免了循環(huán)引用的產(chǎn)生。在 iOS 開發(fā)中,弱引用通常在 delegate 模式中使用。舉個例子來說,兩個 ViewController A 和 B,ViewController A 需要彈出 ViewController B,讓用戶輸入一些內(nèi)容,當(dāng)用戶輸入完成后,ViewController B 需要將內(nèi)容返回給 ViewController A。這個時候,View Controller 的 delegate 成員變量通常是一個弱引用,以避免兩個 ViewController 相互引用對方造成循環(huán)引用問題,如下所示:
弱引用的實(shí)現(xiàn)原理
弱引用的實(shí)現(xiàn)原理是這樣,系統(tǒng)對于每一個有弱引用的對象,都維護(hù)一個表來記錄它所有的弱引用的指針地址。這樣,當(dāng)一個對象的引用計(jì)數(shù)為 0 時,系統(tǒng)就通過這張表,找到所有的弱引用指針,繼而把它們都置成 nil。
從這個原理中,我們可以看出,弱引用的使用是有額外的開銷的。雖然這個開銷很小,但是如果一個地方我們肯定它不需要弱引用的特性,就不應(yīng)該盲目使用弱引用。舉個例子,有人喜歡在手寫界面的時候,將所有界面元素都設(shè)置成 weak 的,這某種程度上與 Xcode 通過 Storyboard 拖拽生成的新變量是一致的。但是我個人認(rèn)為這樣做并不太合適。因?yàn)椋?/p>
- 我們在創(chuàng)建這個對象時,需要注意臨時使用一個強(qiáng)引用持有它,否則因?yàn)?weak 變量并不持有對象,就會造成一個對象剛被創(chuàng)建就銷毀掉。
- 大部分 ViewController 的視圖對象的生命周期與 ViewController 本身是一致的,沒有必要額外做這個事情。
- 早先蘋果這么設(shè)計(jì),是有歷史原因的。在早年,當(dāng)時系統(tǒng)收到 Memory Warning 的時候,ViewController 的 View 會被 unLoad 掉。這個時候,使用 weak 的視圖變量是有用的,可以保持這些內(nèi)存被回收。但是這個設(shè)計(jì)已經(jīng)被廢棄了,替代方案是將相關(guān)視圖的 CALayer 對應(yīng)的 CABackingStore 類型的內(nèi)存區(qū)會被標(biāo)記成 volatile 類型,詳見《再見,viewDidUnload方法》。
使用 Xcode 檢測循環(huán)引用
Xcode 的 Instruments 工具集可以很方便的檢測循環(huán)引用。為了測試效果,我們在一個測試用的 ViewController 中填入以下代碼,該代碼中的 firstArray 和 secondArray 相互引用了對方,構(gòu)成了循環(huán)引用。
|
<pre style="font-family: "Source Code Pro", Consolas, Menlo, Monaco, "Courier New", monospace; overflow: auto; font-size: 0.9rem; margin: 0px; padding: 0px; line-height: 1.3rem; font-weight: 400; color: rgb(171, 178, 191); background: rgb(40, 44, 52); border: none;">- (void)viewDidLoad
{
[super viewDidLoad];
NSMutableArray *firstArray = [NSMutableArray array];
NSMutableArray *secondArray = [NSMutableArray array];
[firstArray addObject:secondArray];
[secondArray addObject:firstArray];
}
</pre>
|
在 Xcode 的菜單欄選擇:Product -> Profile,然后選擇 “Leaks”,再點(diǎn)擊右下角的”Profile” 按鈕開始檢測。如下圖
這個時候 iOS 模擬器會運(yùn)行起來,我們在模擬器里進(jìn)行一些界面的切換操作。稍等幾秒鐘,就可以看到 Instruments 檢測到了我們的這次循環(huán)引用。Instruments 中會用一條紅色的條來表示一次內(nèi)存泄漏的產(chǎn)生。如下圖所示:
我們可以切換到 Leaks 這欄,點(diǎn)擊”Cycles & Roots”,就可以看到以圖形方式顯示出來的循環(huán)引用。這樣我們就可以非常方便地找到循環(huán)引用的對象了。
Core Foundation 對象的內(nèi)存管理
下面我們就來簡單介紹一下對底層 Core Foundation 對象的內(nèi)存管理。底層的 Core Foundation 對象,在創(chuàng)建時大多以 XxxCreateWithXxx 這樣的方式創(chuàng)建,例如:
|
<pre style="font-family: "Source Code Pro", Consolas, Menlo, Monaco, "Courier New", monospace; overflow: auto; font-size: 0.9rem; margin: 0px; padding: 0px; line-height: 1.3rem; font-weight: 400; color: rgb(171, 178, 191); background: rgb(40, 44, 52); border: none;">// 創(chuàng)建一個 CFStringRef 對象
CFStringRef str= CFStringCreateWithCString(kCFAllocatorDefault, “hello world", kCFStringEncodingUTF8);
// 創(chuàng)建一個 CTFontRef 對象
CTFontRef fontRef = CTFontCreateWithName((CFStringRef)@"ArialMT", fontSize, NULL);
</pre>
|
對于這些對象的引用計(jì)數(shù)的修改,要相應(yīng)的使用 CFRetain 和 CFRelease 方法。如下所示:
|
<pre style="font-family: "Source Code Pro", Consolas, Menlo, Monaco, "Courier New", monospace; overflow: auto; font-size: 0.9rem; margin: 0px; padding: 0px; line-height: 1.3rem; font-weight: 400; color: rgb(171, 178, 191); background: rgb(40, 44, 52); border: none;">
// 創(chuàng)建一個 CTFontRef 對象
CTFontRef fontRef = CTFontCreateWithName((CFStringRef)@"ArialMT", fontSize, NULL);
// 引用計(jì)數(shù)加 1
CFRetain(fontRef);
// 引用計(jì)數(shù)減 1
CFRelease(fontRef);
</pre>
|
對于 CFRetain 和 CFRelease 兩個方法,讀者可以直觀地認(rèn)為,這與 Objective-C 對象的 retain 和 release 方法等價。
所以對于底層 Core Foundation 對象,我們只需要延續(xù)以前手工管理引用計(jì)數(shù)的辦法即可。
除此之外,還有另外一個問題需要解決。在 ARC 下,我們有時需要將一個 Core Foundation 對象轉(zhuǎn)換成一個 Objective-C 對象,這個時候我們需要告訴編譯器,轉(zhuǎn)換過程中的引用計(jì)數(shù)需要做如何的調(diào)整。這就引入了bridge相關(guān)的關(guān)鍵字,以下是這些關(guān)鍵字的說明:
-
__bridge: 只做類型轉(zhuǎn)換,不修改相關(guān)對象的引用計(jì)數(shù),原來的 Core Foundation 對象在不用時,需要調(diào)用 CFRelease 方法。 -
__bridge_retained:類型轉(zhuǎn)換后,將相關(guān)對象的引用計(jì)數(shù)加 1,原來的 Core Foundation 對象在不用時,需要調(diào)用 CFRelease 方法。 -
__bridge_transfer:類型轉(zhuǎn)換后,將該對象的引用計(jì)數(shù)交給 ARC 管理,Core Foundation 對象在不用時,不再需要調(diào)用 CFRelease 方法。
我們根據(jù)具體的業(yè)務(wù)邏輯,合理使用上面的 3 種轉(zhuǎn)換關(guān)鍵字,就可以解決 Core Foundation 對象與 Objective-C 對象相對轉(zhuǎn)換的問題了。
總結(jié)
在 ARC 的幫助下,iOS 開發(fā)者的內(nèi)存管理工作已經(jīng)被大大減輕,但是我們?nèi)匀恍枰斫庖糜?jì)數(shù)這種內(nèi)存管理方式的優(yōu)點(diǎn)和常見問題,特別要注意解決循環(huán)引用問題。對于循環(huán)引用問題有兩種主要的解決辦法,一是主動斷開循環(huán)引用,二是使用弱引用的方式避免循環(huán)引用。對于 Core Foundation 對象,由于不在 ARC 管理之下,我們?nèi)匀恍枰永m(xù)以前手工管理引用計(jì)數(shù)的辦法。
在調(diào)試內(nèi)存問題時,Instruments 工具可以很好地對我們進(jìn)行輔助,善用 Instruments 可以節(jié)省我們大量的調(diào)試時間。
愿每一個 iOS 開發(fā)者都可以掌握 iOS 的內(nèi)存管理技能。










