理解 iOS 的記憶體管理

唐巧發表於2016-08-05

遠古時代的故事

那些經歷過手工管理記憶體(MRC)時代的人們,一定對 iOS 開發中的記憶體管理記憶猶新。那個時候大約是 2010 年,國內 iOS 開發剛剛興起,tinyfool 大叔的大名已經如雷貫耳,而我還是一個默默無聞的剛畢業的小子。那個時候的 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 技術,作為其記憶體管理方式。

為什麼我要提這段歷史呢?就是因為現在的 iOS 開發者實在太舒服了,大部分時候,他們根本都不用關心程式的記憶體管理行為。但是,雖然 ARC 幫我們解決了引用計數的大部分問題,一些年輕的 iOS 開發者仍然會做不好記憶體管理工作。他們甚至不能理解常見的迴圈引用問題,而這些問題會導致記憶體洩漏,最終使得應用執行緩慢或者被系統終止程式。

所以,我們每一個 iOS 開發者,需要理解引用計數這種記憶體管理方式,只有這樣,才能處理好記憶體管理相關的問題。

什麼是引用計數

引用計數(Reference Count)是一個簡單而有效的管理物件生命週期的方式。當我們建立一個新物件的時候,它的引用計數為 1,當有一個新的指標指向這個物件時,我們將其引用計數加 1,當某個指標不再指向這個物件是,我們將其引用計數減 1,當物件的引用計數變為 0 時,說明這個物件不再被任何指標指向了,這個時候我們就可以將物件銷燬,回收記憶體。由於引用計數簡單有效,除了 Objective-C 和 Swift 語言外,微軟的 COM(Component Object Model )、C++11(C++11 提供了基於引用計數的智慧指標 share_prt)等語言也提供了基於引用計數的記憶體管理方式。

為了更形象一些,我們再來看一段 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。所有物件都遵守這個規則的話,物件的生命期管理就可以完全交給引用計數了。我們也可以很方便地享受到共享物件帶來的好處。

不要向已經釋放的物件傳送訊息

有些同學想測試當物件釋放時,其 retainCount 是否變成了 0,他們的試驗程式碼如下:

- (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;
}

但是,如果你真的這麼實驗,你得到的輸出結果可能是以下這樣:

Reference Count = 1
Reference Count = 1

我們注意到,最後一次輸出,引用計數並沒有變成 0。這是為什麼呢?因為該物件的記憶體已經被回收,而我們向一個已經被回收的物件發了一個 retainCount 訊息,所以它的輸出結果應該是不確定的,如果該物件所佔的記憶體被複用了,那麼就有可能造成程式異常崩潰。

那為什麼在這個物件被回收之後,這個不確定的值是 1 而不是 0 呢?這是因為當最後一次執行 release 時,系統知道馬上就要回收記憶體了,就沒有必要再將 retainCount 減 1 了,因為不管減不減 1,該物件都肯定會被回收,而物件被回收後,它的所有的記憶體區域,包括 retainCount 值也變得沒有意義。不將這個值從 1 變成 0,可以減少一次記憶體的寫操作,加速物件的回收。

拿我們之前提到的 Linux 檔案系統舉列,Linux 檔案系統下刪除一個檔案,也不是真正的將檔案的磁碟區域進行抹除操作,而只是刪除該檔案的索引節點號。這也和引用計數的記憶體回收方式類似,即回收時只做標記,並不抹除相關的資料。

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 可以節省我們大量的除錯時間。

願每一個 iOS 開發者都可以掌握 iOS 的記憶體管理技能。

相關文章