標籤:

理解 iOS 和 macOS 的內存管理

在 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
}
}

接下來我們圖解下這部分代碼,圖中,矩形為變數(指針),圓圈為實際對象,剪頭表示變數指向的對象

上面的寫法是 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

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

推薦閱讀:

TAG:iOS | iOS開發 |