理解 iOS 和 macOS 的記憶體管理

noark9發表於2018-11-17

在 iOS 和 macOS 應用的開發中,無論是使用 Objective-C 還是使用 swift 都是通過引用計數策略來進行記憶體管理的,但是在日常開發中80%(這裡,我瞎說的,8020 原則嘛?)以上的情況,我們不需要考慮記憶體問題,因為 Objective-C 2.0 引入的自動引用計數(ARC)技術為開發者們自動的完成了記憶體管理這項工作。ARC 的出現,在一定程度上拯救了當時剛入門的 iOS 程式設計師們,如果是沒有接觸過記憶體管理的開發者,在第一次遇到殭屍物件時一定是嚇得發抖???My Brains~。但是 ARC 只是在程式碼層面上自動新增了記憶體管理的程式碼,並不能真正的自動記憶體管理,以及一些高記憶體消耗的特殊場景我們必須要進行手動記憶體管理,所以理解記憶體管理是每一個 iOS 或者 macOS 應用開發者的必備能力。

本文將會介紹 iOS 和 macOS 應用開發過程中,如何進行記憶體管理,以及介紹一些記憶體管理使用的場景,幫助大家解決記憶體方面的問題,本文將會重點介紹記憶體管理的邏輯、思路,而不是類似教你分分鐘手寫 weak 的實現,之類的問題,畢竟大家一般擰螺絲比較多,至於✈️??的製造技藝嘛,還是要靠萬能的 Google 了。

本文其實是記憶體管理的起點,而不是結束,各位 iOS 大佬們肯定會發現很多東西在本文中是找不到的,因為這裡的內容非常基礎,只是幫助初學 iOS 的同學們能夠快速理解如何管理記憶體而寫的。

什麼是記憶體管理

很多人接觸到記憶體管理可以追溯到大學時候的 C 語言程式設計課程,在大學中為數不多的實踐型語言課程中相信 C 語言以及 C 語言中的指標是很多人的噩夢,並且這個噩夢延續到了 C++,當然這個是後話了。所以 Java 之類的,擁有垃圾回收機制的語言,也就慢慢的變得越來越受歡迎(大霧???)。

記憶體管理基本原則:

在需要的時候分配記憶體,在不需要的時候釋放記憶體

這裡來一段簡單的 C 程式碼~

#define BUFFER_SIZE 128

void dosth() {
    char *some_string = malloc(BUFFER_SIZE);
    // 對 some_string 做各種操作
    free(some_string);
}
複製程式碼

這麼一句話看起來似乎不是很複雜,但是光這一個記憶體管理,管得無數英雄盡折腰啊,因為實際的程式碼並不會像上面那麼簡單,比如上面我要把字串 some_string 返回出來的話要怎麼辦呢?(我不會回答你的?)

iOS 的記憶體管理

記憶體引用計數(Reference Counting,RC)以及 MRC

Objective-C 和 Swift 的記憶體管理策略都是引用計數,什麼是引用計數呢?下面是 wiki 上摘抄而來的內容:

引用計數是計算機程式語言中的一種記憶體管理技術,是指將資源(可以是物件記憶體磁碟空間等等)的被引用次數儲存起來,當被引用次數變為零時就將其釋放的過程。使用引用計數技術可以實現自動資源管理的目的。同時引用計數還可以指使用引用計數技術回收未使用資源的垃圾回收演算法。

當建立一個物件的例項並在堆上申請記憶體時,物件的引用計數就為1,在其他物件中需要持有這個物件時,就需要把該物件的引用計數加1,需要釋放一個物件時,就將該物件的引用計數減1,直至物件的引用計數為0,物件的記憶體會被立刻釋放。

來源:zh.wikipedia.org/wiki/引用計數

似乎有點抽象,這裡使用 setter 方法的經典實現作為例子我們來看下程式碼~

- (void)setSomeObject:(NSObject *aSomeObject) {
	if (_someObject != aSomeObject) {
		id oldValue = _someObject;
		_someObject = [aSomeObject retain];  // aSomeObject retain count +1
		[oldValue release];  // oldValue retain count -1
	}
}
複製程式碼

接下來我們圖解下這部分程式碼,圖中,矩形為變數(指標),圓圈為實際物件,剪頭表示變數指向的物件

1

2

3

4

上面的寫法是 MRC 時代的經典方式,這裡就不多說了,因為本文的目的是讓大家理解 ARC 下的記憶體管理。

人工記憶體管理時代 —— Manual Reference Counting(MRC)

人工管理記憶體引用計數的方法叫做 Manual Reference Counting(MRC),在上一節的最後,我們已經看到了記憶體管理的一些些程式碼,也看到了記憶體管理時發生了一些什麼,因為 MRC 是 ARC 的基礎,為了更好地理解 ARC,下面是我對 iOS,macOS 下記憶體管理的總結:

物件之間存在持有關係,是否被持有,決定了物件是否被銷燬

也就是說,對於引用計數的記憶體管理,最重要的事情是理清楚物件之間的持有關係,而不關注實際的引用數字,也就是邏輯關係清楚了,那麼實際的引用數也就不會出問題了。

例子 這裡引用《Objective-C 高階程式設計》裡面辦公室的燈的例子,不過我們稍微改改

  1. 自習室有一個燈,燈可以建立燈光,老師要求大家節約用電,只有在有人需要使用的時候才開啟燈
  2. 同學 A 來看書,他開啟了燈(建立燈光) —— A 持有燈光
  3. 同學 B,C,D 也來看書,他們也需要燈光 —— B,C,D 分別持有燈光
  4. 這時候 A,B,C 回宿舍了,他們不需要開燈了 —— A,B,C 釋放了燈光
  5. 由於這時候 D 還需要燈光,所以燈一直是開啟的 —— D 依然持有燈光
  6. 當 D 離開自習室時 —— D 釋放了燈光
  7. 這時候自習室裡面已經沒有人需要燈光了,於是燈光被釋放了(燈被關了)

上面的例子“燈光”就是我們的被持有的物件,同學們是持有“燈光”的物件,在這個場景,只要我們理清楚誰持有了“燈光”,那麼我們就能完美的控制“燈光”,不至於沒人的時候“燈光”一直存在導致浪費電(記憶體洩漏),也不至於有同學需要“燈光”的時候“燈光”被釋放。

這裡看上去很簡單,但是實際專案中將會是這樣的場景不斷的疊加,從而產生非常複雜的持有關係。例子中的同學 A,B,C,D,自習室以及燈也是被其他物件持有的。所以對於最小的一個場景,我們再來一遍:

物件之間存在持有關係,是否被持有,決定了物件是否被銷燬

創造力的解放 —— Automatic Reference Counting(ARC)

但是平時大家會發現從來沒用過 retainrelease 之類的函式啊?特別是剛入門的同學,CoreFoundation 也沒有使用過就更納悶了

原因很簡單,因為這個時代我們用上了 ARC,ARC 號稱幫助程式設計師管理記憶體,而很多人曲解了“幫助”這個詞,在佈道的時候都會說:

ARC 已經是自動記憶體管理了,我們不需要管理記憶體

這是一句誤導性的話,ARC 只是幫我們在程式碼中他可以推斷的部分,自動的新增了 retainrelease 等程式碼,但是並不代表他幫我們管理記憶體了,實際上 ARC 只是幫我們省略了部分程式碼,在 ARC 無法推斷的部分,是需要我們告訴 ARC 如何管理記憶體的,所以就算是使用 ARC,本質依然是開發者自己管理記憶體,只是 ARC 幫我們把簡單情況搞定了而已

但是,就算是 ARC 僅僅幫我們把簡單的情況搞定了,也非常大的程度上解放了大家的創造力、生產力,因為畢竟很多時候記憶體管理程式碼都是會被漏寫的,並且由於漏寫的時候不一定會發現問題,而是隨著程式執行才會出現問題,在開發後期解決起來其實挺麻煩的

ARC 下的記憶體管理

那麼我們來說說 ARC 中如何進行記憶體管理,當然核心還是這句話:物件之間存在持有關係,是否被持有,決定了物件是否被銷燬,當然我們補充一句話:ARC 中的記憶體管理,就是理清物件之間的持有關係

strongweak

在上面一節中,其實大家應該發現只寫了 retain,是因為 MRC 的時代只有 retainreleaseautorelease 這幾個手動記憶體管理的函式。而 strongweak__weak 之類的關鍵字是 Objective-C 2.0 跟著 ARC 一起引入的,可以認為他們就是 ARC 時代的記憶體管理程式碼

對於屬性 strongweakassigncopy 告訴 ARC 如何構造屬性對應變數的 setter 方法,對於記憶體管理的意義來說,就是告訴編譯器物件屬性和物件之間的關係,也就是說平時開發過程中,一直在使用的 strongweak 其實就是在做記憶體管理,只是大部分時間大家沒有意識到而已

  • strong:設定屬性時,將會持有(retain)物件
  • weak:設定屬性時,不會持有物件,並且在物件被釋放時,屬性值將會被設定為 nil
  • assign:設定屬性時,不會持有物件(僅在屬性為基本型別時使用,因為基本型別不是物件,不存在釋放)
  • copy:設定屬性時,會呼叫物件的 copy 方法獲取物件的一個副本並持有(對於不可變型別非常有用)

一般情況下,我們都會使用 strong 來描述一個物件的屬性,也就是大部分場景下,物件都會持有他的屬性,那麼下面看下不會持有的情況

屬性描述的場景 —— delegate 模式

這裡用經典的 UITableViewDelegateUITableViewDataSource 來進行舉例

UITableView 的 delegate 和 datasource 應該是學習 iOS 開發過程中最早接觸到的 iOS 中的 delegate 模式 在很多的的例子中,教導我們自己開發的物件,使用的 delegate 的屬性要設定為 weak 的,但是很少有說為什麼(因為迴圈引用),更少有人會說為什麼會產生迴圈引用,接下來這裡用 UITableView 的來詳解下

先看 UITableView 中的定義

@interface UITableView : UIScrollView <NSCoding, UIDataSourceTranslating>
// Other Definations ...
@property (nonatomic, weak, nullable) id <UITableViewDataSource> dataSource;
@property (nonatomic, weak, nullable) id <UITableViewDelegate> delegate;
// Other Definations ...
@end
複製程式碼

接下來看下 UITableViewController 中一般的寫法

@interface XXXTableViewController : UITableViewController

@property (nonatomic, strong) UITableView *tableView;

@end

@implementation XXXTableViewController()

- (void)viewDidLoad {
	[super viewDidLoad];
	self.tableView.delegate = self;
	self.tableView.dataSource = self;
}

@end
複製程式碼

下面用一個圖梳理一下持有關係

持有關係

圖上有三個物件關係

  1. controller 持有 tableViewstrong 屬性
  2. tableView 沒有持有 conntrollerweak 屬性
  3. 其他物件持有 controllerstrong 屬性

那麼當第三個關係被打破時,也就是沒有物件持有 controller 了(發生 [controller release],這時候 controller 會釋放他所有的記憶體,發生下面的事情:

  1. 其他物件呼叫 [controller release],沒有物件持有 controllercontroller 開始釋放記憶體(呼叫 dealloc
  2. [tableView release],沒有物件持有 tableView 記憶體被釋放
  3. controller 記憶體被釋放

因為 weak 屬性不會發生持有關係,所以上面過程完成後,都沒有任何物件持有 tableViewcontroller 於是都被釋放

假設上面物件關係中的 2 變為 tableView 持有 conntrollerstrong 屬性

那麼當第三個關係被打破時,也就是沒有物件持有 controller 了(發生 [controller release],這時候 controller 會釋放他所有的記憶體,發生下面的事情:

  • 其他物件呼叫 [controller release]tableView 依然持有 controllercontroller 不會釋放記憶體(不會呼叫 dealloc

這樣,tableViewcontroller 互相持有,但是沒有任何物件在持有他們,但是他們不會被釋放,因為都有一個物件持有著他們,於是記憶體洩漏,這種情況是一種簡單的迴圈引用

所以,這就是為什麼我們寫的程式碼如果會使用到 delegate 模式,需要將 delegate 的屬性設定為 weak,但是從上面例子我們可以理解到,並不是 delegate 需要 weak 而是因為出現了 delegate 和使用 delegate 的物件互相持有(迴圈引用),那麼如果我們的程式碼中不會出現迴圈引用,那麼使用 weak 反而會出錯(delegate 被過早的釋放),不過這種時候往往有其他物件會持有 delegate

上面其實只描述了最簡單的迴圈引用場景,在複雜的場景中,可能會有很多個物件依次持有直到迴圈,面對各種各樣複雜的場景,本文認為解決記憶體問題的方法都是,針對每個物件,每個類,理清他們之間的持有關係,也就是:

物件之間存在持有關係,是否被持有,決定了物件是否被銷燬,ARC 中的記憶體管理,就是理清物件之間的持有關係

__weak__strong

strongweak 是在設定屬性的時候使用的,__weak__strong 是用於變數的,這兩個關鍵字在開發的過程中不會頻繁的用到,是因為如果沒有指定,那麼變數預設是通過 __strong 修飾的,不過當我們需要使用這兩個關鍵字的時候,那麼也將是我們面對坑最多的情況的時候 —— block 的使用

  • __strong:變數預設的修飾符,對應 property 的 strong,會持有(這裡可以認為是當前程式碼塊持有)變數,這裡的持有相當於在變數賦值後呼叫 retain 方法,在程式碼塊結束時呼叫 release 方法
  • __weak:對應 property 的 weak,同樣在變數被釋放後,變數的值會變成 nil
變數描述符場景 —— block 的迴圈引用

下面我們來看個平常經常會遇到的場景,考慮下面的程式碼:

// 檔案 Dummy.h
@interface Dummy : NSObject

@property (nonatomic, strong) void (^do_block)();

- (void)do_sth:(NSString *)msg;

@end

// 檔案 Dummy.m
@interface Dummy()
@end

@implementation Dummy

- (void)do_sth:(NSString *)msg {
    NSLog(@"Enter do_sth");
    self.do_block = ^() {
        [self do_sth_inner:msg];
    };
    self.do_block();
    NSLog(@"Exit do_sth");
}

- (void)do_sth_inner:(NSString *)msg {
    NSLog(@"do sth inner: %@", msg);
}

@end

// 檔案 AppDelegate.m
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    Dummy *dummy = [[Dummy alloc] init];
    [dummy do_sth:@"hello"];
    return YES;
}
複製程式碼

新建一個空白的單頁面 iOS 應用,這裡大家一定知道結果了,在控制檯會輸出這樣的內容:

2018-11-15 22:56:34.281346+0800 iOSPlayground[42178:5466855] Enter do_sth
2018-11-15 22:56:34.281445+0800 iOSPlayground[42178:5466855] do sth inner: hello
2018-11-15 22:56:34.281536+0800 iOSPlayground[42178:5466855] Exit do_sth
複製程式碼

當然相信大家已經看出問題來了,上面的程式碼會造成迴圈引用,當然很多時候我們在學習寫 iOS 程式碼的時候,都會有人教導過我們 block 裡面的 self 是會存在迴圈引用的(如上程式碼的結果),必須要使用 __weak,那麼為什麼呢?這裡依然回到上面的記憶體管理原則,我們來梳理一下持有關係,首先這裡有一個基礎知識,那就是 block 是一個物件,並且他會持有所有他捕獲的變數,這裡我們來看下記憶體持有關係:

持有關係

同樣,我們來分析下這個持有關係

  1. self 物件持有了 do_block 物件
  2. 由於 selfdo_block 中使用了,所以 do_block 的程式碼區塊持有了 self
  3. 其他物件(這裡是 AppDelegate 例項)通過變數的方式持有對外的 dummy 物件

那麼在我們的程式碼執行到 -application:didFinishLaunchingWithOptions: 最後一行的時候,由於程式碼塊的結束,ARC 將會對塊內產生的物件分別呼叫 release 釋放物件,這時候,上面 3 的持有關係被打破了

但是,由於 1,2 這兩條持有關係存在,所以無論是 self 物件,還是 do_sth block 他們都至少被一個物件所持有,所以,他們無法被釋放,並且也無法被外界所訪問到,形成了迴圈引用導致記憶體洩漏,通過 Xcode 提供的記憶體圖(Debug Memeory Graph)我們也可以看到,這一現象:

記憶體圖

那麼這裡的解決方法就是,進行下面的修改:

- (void)do_sth:(NSString *)msg {
    NSLog(@"Enter do_sth");
    __weak typeof(self) weakself = self;
    self.do_block = ^() {
        [weakself do_sth_inner:msg];
    };
    self.do_block();
    NSLog(@"Exit do_sth");
}
複製程式碼

這樣打破了上面持有關係 2 中,do_block 持有 self 的問題,這樣就和上面描述 delegate 的場景一樣了

變數描述符場景 —— block 的迴圈引用 2

接下來看下另外一個迴圈引用的場景,Dummy 類的定義不變,使用方法做一些調整:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    Dummy *dummy = [[Dummy alloc] init];    
    dummy.do_block = ^{
        [dummy do_sth_inner:@"hello2"];
    };
    dummy.do_block();
    return YES;
}
複製程式碼

奇怪,這裡沒有 self 了啊,為什麼依然迴圈引用了啊?接著繼續看持有關係圖:

持有關係

是不是和上一個場景很像?因為就是一樣的,只是一個視野在類的內部,另一個視野在類的外部,在類的內部那就是 selfdo_block 互相持有,形成迴圈引用;在類的外部那就是 dummydo_block 互相持有,形成迴圈應用

一點個人經驗

實際專案肯定不會是本文中這麼明顯簡單的場景,但是再多複雜的場景肯定是這些簡單的場景不斷的巢狀組合而成,所以保證程式碼記憶體沒有問題的最好的方法是每次遇到需要處理記憶體場景時,仔細分析物件間的持有關係,也就是保證組成複雜場景的每個小場景都沒有問題,那麼基本就不會出現問題了,對於出現記憶體管理出現問題的情況,一般我們都能定位到是某一部分程式碼記憶體洩漏了,那麼直接分析那部分程式碼的持有關係是否正確

iOS macOS 開發中的記憶體管理不要在意引用計數,引用計數是給執行時看的東西,作為人類我們需要在意物件間的持有關係,理清持有關係那麼就表明引用計數不會有問題

結語

到此對於記憶體管理的思路算是結束了,但是就像本文一開始所說的,這裡並不是結束而是開始,接下來建議大家在有了一定經驗後可以再去深入瞭解下面的內容:

  • Core Foundation 框架的記憶體管理,沒有 ARC 的眷顧
  • Core Foundation 框架和 Objective-C 的記憶體互動 —— Toll-Free Bridging,ARC 和 CF 框架的橋樑
  • Objective-C 高階程式設計 —— 《iOS 與 OS X 多執行緒和記憶體管理》,我從這本書裡面收益良多
  • Swift 下的記憶體管理,分清 weakunowned 有什麼區別,邏輯依然是理清持有關係
  • C 語言入門,Objective-C 源自於 C 語言,所有 C 語言的招式在 Objective-C 中都好用,在某些特殊場景會必定會用到

最後歡迎大家訂閱我的微信公眾號 Little Code

Little Code

  • 公眾號主要發一些開發相關的技術文章
  • 談談自己對技術的理解,經驗
  • 也許會談談人生的感悟
  • 本人不是很高產,但是力求保證質量和原創

相關文章