iOS內存管理及要注意的問題

手工管理內存(MRC)時代----------->>>>>>>自動的引用計數(ARC)時代

那些經歷過手工管理內存(MRC)時代的人們,一定對 iOS 開發中的內存管理記憶猶新。那個時候大約是 2010 年,國內 iOS 開發剛剛興起,。那個時候的 iOS 開發過程是這樣的:

先寫好一段 iOS 的代碼,然後屏住呼吸,開始運行它,不出所料,它崩潰了。在 MRC 時代,即使是最牛逼的 iOS 開發者,也不能保證一次性就寫出完美的內存管理代碼。於是,人們開始一步一步調試,試着打印出每個懷疑對象的引用計數(Retain Count),然後再小心翼翼地插入合理的 retain 和 release 代碼。經過一次又一次的應用崩潰和調試,終於有一次,應用能夠正常運行了!於是人們長舒一口氣,露出久違的微笑。

是的,這就是那個年代的 iOS 開發者,通常情況下,我們在開發完一個功能後,需要再花好幾個小時,才能把引用計數管理好。

蘋果在 2011 年的時候,在 WWDC 大會上提出了自動的引用計數(ARC)。ARC 背後的原理是依賴編譯器的靜態分析能力,通過在編譯時找出合理的插入引用計數管理代碼,從而徹底解放程序員。

在 ARC 剛剛出來的時候,業界對此黑科技充滿了懷疑和觀望,加上現有的 MRC 代碼要做遷移本來也需要額外的成本,所以 ARC 並沒有被很快接受。直到 2013 年左右,蘋果認爲 ARC 技術足夠成熟,直接將 macOS(當時叫 OS X)上的垃圾回收機制廢棄,從而使得 ARC 迅速被接受。

2014 年的 WWDC 大會上,蘋果推出了 Swift 語言,而該語言仍然使用 ARC 技術,作爲其內存管理方式

雖然 ARC 幫我們解決了引用計數的大部分問題,但是還是需要理解引用計數這種內存管理方式,不能忽略常見的循環引用問題,因爲這些問題會導致內存泄漏,最終使得應用運行緩慢或者被系統終止進程。

什麼是引用計數

引用計數(Reference Count)是一個簡單而有效的管理對象生命週期的方式。當我們創建一個新對象的時候,它的引用計數爲 1,當有一個新的指針指向這個對象時,我們將其引用計數加 1,當某個指針不再指向這個對象是,我們將其引用計數減 1,當對象的引用計數變爲 0 時,說明這個對象不再被任何指針指向了,這個時候我們就可以將對象銷燬,回收內存。



爲了更形象一些,我們再來看一段 Objective-C 的代碼。新建一個工程,因爲現在默認的工程都開啓了自動的引用計數 ARC(Automatic Reference Count),我們先修改工程設置,給 AppDelegate.m 加上 -fno-objc-arc 的編譯參數(如下圖所示),這個參數可以啓用手工管理引用計數的模式。



然後,我們在中輸入如下代碼,可以通過 Log 看到相應的引用計數的變化。

- (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 的內存被釋放了
return YES;
}

運行結果:

Reference Count = 1
Reference Count = 2
Reference Count = 1
對 Linux 文件系統比較瞭解的同學可能發現,引用計數的這種管理方式類似於文件系統裏面的硬鏈接。在 Linux 文件系統中,我們用 ln 命令可以創建一個硬鏈接(相當於我們這裏的 retain),當刪除一個文件時(相當於我們這裏的 release),系統調用會檢查文件的 link count 值,如果大於 1,則不會回收文件所佔用的磁盤區域。直到最後一次刪除前,系統發現 link count 值爲 1,則系統纔會執行直正的刪除操作,把文件所佔用的磁盤區域標記成未用。

我們爲什麼需要引用計數?

從上面那個簡單的例子中,我們還看不出來引用計數真正的用處。因爲該對象的生命期只是在一個函數內,所以在真實的應用場景下,我們在函數內使用一個臨時的對象,通常是不需要修改它的引用計數的,只需要在函數返回前將該對象銷燬即可。

引用計數真正派上用場的場景是在面向對象的程序設計架構中,用於對象之間傳遞和共享數據。我們舉一個具體的例子:

假如對象 A 生成了一個對象 M,需要調用對象 B 的某一個方法,將對象 M 作爲參數傳遞過去。在沒有引用計數的情況下,一般內存管理的原則是 “誰申請誰釋放”,那麼對象 A 就需要在對象 B 不再需要對象 M 的時候,將對象 M 銷燬。但對象 B 可能只是臨時用一下對象 M,也可能覺得對象 M 很重要,將它設置成自己的一個成員變量,那這種情況下,什麼時候銷燬對象 M 就成了一個難題。


對於這種情況,有一個暴力的做法,就是對象 A 在調用完對象 B 之後,馬上就銷燬參數對象 M,然後對象 B 需要將參數另外複製一份,生成另一個對象 M2,然後自己管理對象 M2 的生命期。但是這種做法有一個很大的問題,就是它帶來了更多的內存申請、複製、釋放的工作。本來一個可以複用的對象,因爲不方便管理它的生命期,就簡單的把它銷燬,又重新構造一份一樣的,實在太影響性能。如下圖所示:


我們另外還有一種辦法,就是對象 A 在構造完對象 M 之後,始終不銷燬對象 M,由對象 B 來完成對象 M 的銷燬工作。如果對象 B 需要長時間使用對象 M,它就不銷燬它,如果只是臨時用一下,則可以用完後馬上銷燬。這種做法看似很好地解決了對象複製的問題,但是它強烈依賴於 AB 兩個對象的配合,代碼維護者需要明確地記住這種編程約定。而且,由於對象 M 的申請是在對象 A 中,釋放在對象 B 中,使得它的內存管理代碼分散在不同對象中,管理起來也非常費勁。如果這個時候情況再複雜一些,例如對象 B 需要再向對象 C 傳遞對象 M,那麼這個對象在對象 C 中又不能讓對象 C 管理。所以這種方式帶來的複雜性更大,更不可取。


所以引用計數很好的解決了這個問題,在參數 M 的傳遞過程中,哪些對象需要長時間使用這個對象,就把它的引用計數加 1,使用完了之後再把引用計數減 1。所有對象都遵守這個規則的話,對象的生命期管理就可以完全交給引用計數了。我們也可以很方便地享受到共享對象帶來的好處。

ARC 下的內存管理問題

ARC 能夠解決 iOS 開發中 90% 的內存管理問題,但是另外還有 10% 內存管理,是需要開發者自己處理的,這主要就是與底層 Core Foundation 對象交互的那部分,底層的 Core Foundation 對象由於不在 ARC 的管理下,所以需要自己維護這些對象的引用計數。

對於 ARC 盲目依賴的 iOS 新人們,由於不知道引用計數,他們的問題主要體現在:

  1. 過度使用 block 之後,無法解決循環引用問題。
  2. 遇到底層 Core Foundation 對象,需要自己手工管理它們的引用計數時,顯得一籌莫展。

循環引用(Reference Cycle)問題

引用計數這種管理內存的方式雖然很簡單,但是有一個比較大的瑕疵,即它不能很好的解決循環引用問題。如下圖所示:對象 A 和對象 B,相互引用了對方作爲自己的成員變量,只有當自己銷燬時,纔會將成員變量的引用計數減 1。因爲對象 A 的銷燬依賴於對象 B 銷燬,而對象 B 的銷燬與依賴於對象 A 的銷燬,這樣就造成了我們稱之爲循環引用(Reference Cycle)的問題,這兩個對象即使在外界已經沒有任何指針能夠訪問到它們了,它們也無法被釋放。


不止兩對象存在循環引用問題,多個對象依次持有對方,形式一個環狀,也可以造成循環引用問題,而且在真實編程環境中,環越大就越難被發現。下圖是 4 個對象形成的循環引用問題。

主動斷開循環引用

解決循環引用問題主要有兩個辦法,第一個辦法是我明確知道這裏會存在循環引用,在合理的位置主動斷開環中的一個引用,使得對象得以回收。如下圖所示:


主動斷開循環引用這種方式常見於各種與 block 相關的代碼邏輯中。例如開源的 YTKNetwork 網絡庫中,網絡請求的回調 block 是被持有的,但是如果這個 block 中又存在對於 View Controller 的引用,就很容易產生從循環引用,因爲:

  • Controller 持有了網絡請求對象
  • 網絡請求對象持有了回調的 block
  • 回調的 block 裏面使用了 self,所以持有了 Controller

解決辦法就是,在網絡請求結束後,網絡請求對象執行完 block 之後,主動釋放對於 block 的持有,以便打破循環引用。相關的代碼見:

// https://github.com/yuantiku/YTKNetwork/blob/master/YTKNetwork/YTKBaseRequest.m
// 第 147 行:
- (void)clearCompletionBlock {
// 主動釋放掉對於 block 的引用
self.successCompletionBlock = nil;
self.failureCompletionBlock = nil;
}

不過,主動斷開循環引用這種操作依賴於程序員自己手工顯式地控制,相當於回到了以前 “誰申請誰釋放” 的內存管理年代,它依賴於程序員自己有能力發現循環引用並且知道在什麼時機斷開循環引用回收內存(這通常與具體的業務邏輯相關),所以這種解決方法並不常用,更常見的辦法是使用弱引用 (weak reference) 的辦法。

使用弱引用

弱引用雖然持有對象,但是並不增加引用計數,這樣就避免了循環引用的產生。在 iOS 開發中,弱引用通常在 delegate 模式中使用。舉個例子來說,兩個 ViewController A 和 B,ViewController A 需要彈出 ViewController B,讓用戶輸入一些內容,當用戶輸入完成後,ViewController B 需要將內容返回給 ViewController A。這個時候,View Controller 的 delegate 成員變量通常是一個弱引用,以避免兩個 ViewController 相互引用對方造成循環引用問題,如下所示:


弱引用的實現原理

弱引用的實現原理是這樣,系統對於每一個有弱引用的對象,都維護一個表來記錄它所有的弱引用的指針地址。這樣,當一個對象的引用計數爲 0 時,系統就通過這張表,找到所有的弱引用指針,繼而把它們都置成 nil。

從這個原理中,我們可以看出,弱引用的使用是有額外的開銷的。雖然這個開銷很小,但是如果一個地方我們肯定它不需要弱引用的特性,就不應該盲目使用弱引用。舉個例子,有人喜歡在手寫界面的時候,將所有界面元素都設置成 weak 的,這某種程度上與 Xcode 通過 Storyboard 拖拽生成的新變量是一致的。但是我個人認爲這樣做並不太合適。因爲:

  1. 我們在創建這個對象時,需要注意臨時使用一個強引用持有它,否則因爲 weak 變量並不持有對象,就會造成一個對象剛被創建就銷燬掉。
  2. 大部分 ViewController 的視圖對象的生命週期與 ViewController 本身是一致的,沒有必要額外做這個事情。
  3. 早先蘋果這麼設計,是有歷史原因的。在早年,當時系統收到 Memory Warning 的時候,ViewController 的 View 會被 unLoad 掉。這個時候,使用 weak 的視圖變量是有用的,可以保持這些內存被回收。但是這個設計已經被廢棄了,替代方案是將相關視圖的 CALayer 對應的 CABackingStore 類型的內存區會被標記成 volatile 類型。

使用 Xcode 檢測循環引用

Xcode 的 Instruments 工具集可以很方便的檢測循環引用。爲了測試效果,我們在一個測試用的 ViewController 中填入以下代碼,該代碼中的 firstArray 和 secondArray 相互引用了對方,構成了循環引用。

- (void)viewDidLoad
{
[super viewDidLoad];
NSMutableArray *firstArray = [NSMutableArray array];
NSMutableArray *secondArray = [NSMutableArray array];
[firstArray addObject:secondArray];
[secondArray addObject:firstArray];
}

在 Xcode 的菜單欄選擇:Product -> Profile,然後選擇 “Leaks”,再點擊右下角的”Profile” 按鈕開始檢測。如下圖


這個時候 iOS 模擬器會運行起來,我們在模擬器裏進行一些界面的切換操作。稍等幾秒鐘,就可以看到 Instruments 檢測到了我們的這次循環引用。Instruments 中會用一條紅色的條來表示一次內存泄漏的產生。如下圖所示:


我們可以切換到 Leaks 這欄,點擊”Cycles & Roots”,就可以看到以圖形方式顯示出來的循環引用。這樣我們就可以非常方便地找到循環引用的對象了。


Core Foundation 對象的內存管理

下面我們就來簡單介紹一下對底層 Core Foundation 對象的內存管理。底層的 Core Foundation 對象,在創建時大多以 XxxCreateWithXxx 這樣的方式創建,例如:

// 創建一個 CFStringRef 對象
CFStringRef str= CFStringCreateWithCString(kCFAllocatorDefault, “hello world", kCFStringEncodingUTF8);
// 創建一個 CTFontRef 對象
CTFontRef fontRef = CTFontCreateWithName((CFStringRef)@"ArialMT", fontSize, NULL);

對於這些對象的引用計數的修改,要相應的使用 CFRetain 和 CFRelease 方法。如下所示:

// 創建一個 CTFontRef 對象
CTFontRef fontRef = CTFontCreateWithName((CFStringRef)@"ArialMT", fontSize, NULL);
// 引用計數加 1
CFRetain(fontRef);
// 引用計數減 1
CFRelease(fontRef);

對於 CFRetain 和 CFRelease 兩個方法,讀者可以直觀地認爲,這與 Objective-C 對象的 retain 和 release 方法等價。

所以對於底層 Core Foundation 對象,我們只需要延續以前手工管理引用計數的辦法即可。

除此之外,還有另外一個問題需要解決。在 ARC 下,我們有時需要將一個 Core Foundation 對象轉換成一個 Objective-C 對象,這個時候我們需要告訴編譯器,轉換過程中的引用計數需要做如何的調整。這就引入了bridge相關的關鍵字,以下是這些關鍵字的說明:

  • __bridge: 只做類型轉換,不修改相關對象的引用計數,原來的 Core Foundation 對象在不用時,需要調用 CFRelease 方法。
  • __bridge_retained:類型轉換後,將相關對象的引用計數加 1,原來的 Core Foundation 對象在不用時,需要調用 CFRelease 方法。
  • __bridge_transfer:類型轉換後,將該對象的引用計數交給 ARC 管理,Core Foundation 對象在不用時,不再需要調用 CFRelease 方法。

我們根據具體的業務邏輯,合理使用上面的 3 種轉換關鍵字,就可以解決 Core Foundation 對象與 Objective-C 對象相對轉換的問題了。

總結

在 ARC 的幫助下,iOS 開發者的內存管理工作已經被大大減輕,但是我們仍然需要理解引用計數這種內存管理方式的優點和常見問題,特別要注意解決循環引用問題。對於循環引用問題有兩種主要的解決辦法,一是主動斷開循環引用,二是使用弱引用的方式避免循環引用。對於 Core Foundation 對象,由於不在 ARC 管理之下,我們仍然需要延續以前手工管理引用計數的辦法。

在調試內存問題時,Instruments 工具可以很好地對我們進行輔助,善用 Instruments 可以節省我們大量的調試時間。





發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章