iOS 开发:彻底理解 iOS 内存管理(MRC 篇)
- 本文首發于我的個人博客:「程序員充電站」
- 文章鏈接:「傳送門」
- 本文更新時間:2021年08月17日17:11:59
本文是 「iOS 開發:徹底理解 iOS 內存管理」系列的「MRC 篇」。
用來對 Objective-C 語法中,手動管理內存 MRC 相關知識進行講解。
1. 什么是內存管理
程序在運行的過程中,往往涉及到創建對象、定義變量、調用函數或方法,而這些行為都會增加程序的內存占用。
而一個移動設備的內存是有限的,每個軟件所能占用的內存也是有限的。
當程序所占用的內存較多時,系統就會發出內存警告,這時就得回收一些不需要再使用的內存空間。比如回收一些不需要再使用的對象、變量等。
如果程序占用內存過大,系統可能會強制關閉程序,造成程序崩潰、閃退現象,影響用戶體驗。
所以,我們需要對 「內存」 進行合理的分配內存、清除內存,回收不需要再使用的對象。從而保證程序的穩定性。
在 iOS 中,我們通常將內存分為五大部分:
- 代碼區:用于存放程序的代碼,即 CPU 執行的機器指令,并且是只讀的。
- 全局區 / 靜態區:它主要存放靜態數據、全局數據和常量。分為未初始化全局區(BSS 段)、初始化全局區:(數據段)。程序結束后由系統釋放。
- 數據段:用于存放可執行文件中已經初始化的全局變量,也就是用來存放靜態分配的變量和全局變量。
- BSS 段:用于存放程序中未初始化的全局變量。
- 常量區:用于存儲已經初始化的常量。程序結束后由系統釋放。
- 棧區(Stack):用于存放程序臨時創建的變量、存放函數的參數值、局部變量等。由編譯器自動分配釋放。
- 堆區(Heap):用于存放進程運行中被動態分配的內存段。它大小不固定,可動態擴張和縮減。由程序員分配和釋放。
從上邊內存的各個部分說明可以看出:只有堆區存放的數據需要由程序員分配和釋放。
堆區存放的,主要是繼承了 NSObject 的對象,需要由程序員進行分配和釋放。其他非對象類型(int、char、float、double、struct、enum 等)則存放在棧區,由系統進行分配和釋放。
- 示例:
2. 內存管理機制
移動端的內存管理機制,主要有三種:
- 自動垃圾收集(GC)
- 手工引用計數和自動釋放池(MRC)
- 自動引用計數(ARC)
其中 iOS 運行環境不支持自動垃圾收集機制(GC)。蘋果公司使用的是手工引用計數(MRC)和自動引用計數(ARC)機制。
在自動引用計數(ARC)出現機制之前,一直是通過手工引用計數(MRC)機制這種手寫大量管理代碼的方式來管理內存。后來蘋果公司開發了自動引用計數(ARC)技術,把這部分工作交給了編譯器來完成,從而大大簡化了開發工作。但是 ARC 依然還是需要注意循環引用的問題。
下面來詳細講解一下「手工引用計數(MRC)」和「自動引用計數(ARC)」。
3. MRC 手動管理內存(Manual Reference Counting)
3.1 引用計數器
引用計數器:
一個整數,表示為「對象被引用的次數」。系統需要根據對象的引用計數器來判斷對象是否需要被回收。
從字面意義上,可以把引用計數器理解為「對象被引用的次數」,也可以理解為: 「有多少人正在用這個對象」。
系統根據引用計數器的機制來判斷對象是否需要被回收。在每次 RunLoop 迭代結束后,都會檢查對象的引用計數器,如果引用計數器等于 0,則說明該對象沒有地方繼續使用它了,可以將其釋放掉。
關于「引用計數器」,有以下幾個特點:
- 每個 OC 對象都有自己的引用計數器。
- 任何一個對象,剛創建的時候,初始的引用計數為 1。
- 即使用 alloc、new 或者 copy 創建一個對象時,對象的引用計數器默認就是 1。
- 當沒有任何人使用這個對象時,系統才會回收這個對象。也就是說:
- 當對象的引用計數器為 0 時,對象占用的內存就會被系統回收。
- 如果對象的引用計數器不為 0 時,那么在整個程序運行過程,它占用的內存就不可能被回收(除非整個程序已經退出)。
3.2 引用計數器操作
- 為保證對象的存在,每當創建引用到對象需要給對象發送一條 retain 消息,可以使引用計數器值 +1 ( retain 方法返回對象本身)。
- 當不再需要對象時,通過給對象發送一條 release 消息,可以使引用計數器值 -1。
- 給對象發送 retainCount 消息,可以獲得當前的引用計數器值。
- 當對象的引用計數為 0 時,系統就知道這個對象不再需要使用了,所以可以釋放它的內存,通過給對象發送 dealloc 消息發起這個過程。
- 需要注意的是:release 并不代表銷毀 / 回收對象,僅僅是將計數器 -1。
3.3 dealloc 方法
- 當一個對象的引用計數器值為 0 時,這個對象即將被銷毀,其占用的內存被系統回收。
- 對象即將被銷毀時系統會自動給對象發送一條 dealloc 消息(因此,從 dealloc 方法有沒有被調用,就可以判斷出對象是否被銷毀)
- dealloc 方法的重寫(注意是在 MRC 中)
- 一般會重寫 dealloc 方法,在這里釋放相關資源,dealloc 就是對象的遺言
- 一旦重寫了 dealloc 方法,就必須調用 [super dealloc],并且放在最后面調用。
dealloc 使用注意:
- 不能直接調用 dealloc 方法。
- 一旦對象被回收了, 它占用的內存就不再可用,堅持使用會導致程序崩潰(野指針錯誤)。
3.4 野指針和空指針
- 只要一個對象被釋放了,我們就稱這個對象為「僵尸對象(不能再使用的對象)」。
- 當一個指針指向一個僵尸對象(不能再使用的對象),我們就稱這個指針為「野指針」。
- 只要給一個野指針發送消息就會報錯(EXC_BAD_ACCESS 錯誤)。
- 為了避免給野指針發送消息會報錯,一般情況下,當一個對象被釋放后我們會將這個對象的指針設置為空指針。
- 空指針:
- 沒有指向存儲空間的指針(里面存的是 nil, 也就是 0)。
- 給空指針發消息是沒有任何反應的。
3.5 內存管理思想
3.5.1 單個對象內存管理思想
思想一:自己創建的對象,自己持有,自己負責釋放
- 通過 alloc、new、copy 或 mutableCopy 方法創建并持有對象。
- 當自己持有的對象不再被需要時,必須調用 release 或 autorelease 方法釋放對象。
同樣,new 方法也能持有對象:
id obj = [NSObject new]; // 自己創建的對象,自己持有 [obj release];而由各類實現的 copyWithZone: 方法和 mutableCopyWithZone: 方法將生成并持有對象的副本。
另外,除了上面四種方法之外,由上面四種方法名稱開頭的方法名,也將生成并持有對象:
- allocMyObject
- newMyObject
- copyMyObject
- mutableCopyMyObject
思想二:非自己創建的對象,自己也能持有
- 除了用上面方法(alloc / new / copy / mutableCopy 方法)所取得的的對象,因為非自己生成并持有,所以自己不是該對象的持有者。
- 通過調用 retain 方法,即便是非自己創建的對象,自己也能持有對象。
- 同樣當自己持有的對象不再被需要時,必須調用 release 方法來釋放對象。
總結:
- 無論是否是自己創建的對象,自己都可以持有,并負責釋放。
- 計數器有加就有減。
- 曾經讓對象的計數器 +1,就必須在最后讓對象計數器 -1。
3.5.2 多個對象內存管理思想
多個對象之間往往是通過 setter 方法產生聯系的,其內存管理的方法也是在 setter 方法、dealloc 方法中實現的。所以只有了解了 setter 方法是如何實現的,我們才能了解到多個對象之間的內存管理思想。接下來我們將從零開始,一步步實現 setter 方法,了解多個對象之間的內存管理思想。
我們用一個線上斗地主游戲例子來類比一下。假如有一款斗地主游戲,游戲大廳有不同的游戲房間,可供玩家選擇。我們定義游戲房間為 Room 類對象,定義玩家為 Person 類對象,玩家對象擁有 _room 作為成員變量。
一個玩家對象,如果想要玩游戲,就要持有一個房間對象,并保證在使用房間期間,這個房間對象一直存在,并且在游戲房間沒人的時候,還需要將這個房間對象釋放。
根據上面的描述,我們可以制定以下規則:
- 只要一個玩家想使用房間(進入房間),就需要對這個游戲房間的引用計數器 +1。
- 只要一個玩家不想再使用房間(離開房間),就需要對這個游戲房間的引用計數器 -1。
- 只要還有至少一個玩家在用某個房間,那么這個游戲房間就不會被回收,引用計數至少為 1。
下面來定義兩個類 玩家類:Person 和 房間類:Room。
- 房間類(Room 類)
- 玩家類(Person 類)
現在我們通過幾個玩家使用房間的不同應用場景來逐步深入理解內存管理。
1. 玩家沒有使用房間的情況
int main(int argc, const char * argv[]) {@autoreleasepool {// 1. 創建兩個對象Person *p = [[Person alloc] init]; // 玩家 pRoom *r = [[Room alloc] init]; // 房間 rr.no = 888; // 房間號賦值[r release]; // 釋放房間[p release]; // 釋放玩家}return 0; }上述代碼執行完第 4~6 行,即:
// 1.創建兩個對象 Person *p = [[Person alloc] init]; // 玩家 p Room *r = [[Room alloc] init]; // 房間 r r.no = 888; // 房間號賦值之后在內存中的表現如下圖所示:
image可見,Room 實例對象和 Person 實例對象之間沒有相互聯系,所以各自釋放不會報錯。執行完第 8~9 行代碼,即:
[r release]; // 釋放房間 [p release]; // 釋放玩家后,將房間對象和玩家對象各自釋放掉,在內存中的表現如下圖所示:
image最后各自實例對象的內存就會被系統回收。
2. 一個玩家使用一個游戲房間的情況
int main(int argc, const char * argv[]) {@autoreleasepool {// 1. 創建兩個對象Person *p = [[Person alloc] init]; // 玩家 pRoom *r = [[Room alloc] init]; // 房間 rr.no = 888; // 房間號賦值// 將房間賦值給玩家,表示玩家在使用房間// 玩家需要使用這間房,只要玩家在,房間就一定要在p.room = r; // [p setRoom:r][r release]; // 釋放房間// 在這行代碼之前,玩家都沒有被釋放,但是因為玩家還在,那么房間就不能銷毀NSLog(@"-----");[p release]; // 釋放玩家}return 0; }上邊代碼執行完第 4~6 行的時候和之前在內存中的表現一樣,如圖所示:
image當執行完第 10 行代碼 p.room = r; 時,因為調用了 setter 方法,將 Room 實例對象賦值給了 Person 的成員變量,不做其他設置的話,在內存中的表現如下圖(做法不對):
image在調用 setter 方法的時候,因為 Room 實例對象多了一個 Person 對象引用,所以應將 Room 實例對象的引用計數 +1 才對,即 setter 方法應該像下邊一樣,對 room 進行一次 retain 操作。
- (void)setRoom:(Room *)room { // 調用 room = r;// 對房間的引用計數器 +1[room retain];_room = room; }那么執行完第 10 行代碼 p.room = r;,在內存中的表現為:
image繼續執行第 12 行代碼[r release];,釋放房間,Room 實例對象引用計數 -1,在內存中的表現如下圖所示:
image然后執行第 17 行代碼 [p release];,釋放玩家。這時候因為玩家不在房間里了,房間也沒有用了,所以在釋放玩家的時候,要把房間也釋放掉,也就是在 delloc 里邊對房間再進行一次 release 操作。
這樣對房間對象來說,每一次 retain / alloc 操作都對應一次 release 操作。
- (void)dealloc {// 人釋放了, 那么房間也需要釋放[_room release];NSLog(@"%s", __func__);[super dealloc]; }那么在內存中的表現最終如下圖所示:
image最后實例對象的內存就會被系統回收
3. 一個玩家使用一個游戲房間 r 后,換到另一個游戲房間 r2 的情況
int main(int argc, const char * argv[]) {@autoreleasepool {// 1. 創建兩個對象Person *p = [[Person alloc] init]; // 玩家 pRoom *r = [[Room alloc] init]; // 房間 rr.no = 888; // 房間號賦值// 2. 將房間 r 賦值給玩家 p,表示玩家 p 在使用房間 rp.room = r; // [p setRoom:r][r release]; // 釋放房間 r// 3. 換房Room *r2 = [[Room alloc] init];r2.no = 444;p.room = r2;[r2 release]; // 釋放房間 r2[p release]; // 釋放玩家 p}return 0; }執行下邊幾行代碼:
// 1. 創建兩個對象 Person *p = [[Person alloc] init]; // 玩家 p Room *r = [[Room alloc] init]; // 房間 r r.no = 888; // 房間號賦值// 2. 將房間 r 賦值給玩家 p,表示玩家 p 在使用房間 r p.room = r; // [p setRoom:r] [r release]; // 釋放房間 r之后的內存表現為:
image接著執行換房操作而不進行其他操作的話,即:
// 3. 換房 Room *r2 = [[Room alloc] init]; r2.no = 444; p.room = r2;后的內存表現為:
image最后執行完代碼:
[r2 release]; // 釋放房間 r2 [p release]; // 釋放玩家 p后的內存表現為:
image可以看出房間 r 并沒有被釋放,這是因為在進行換房的時候,并沒有對房間 r 進行釋放。所以應在調用 setter 方法的時候,對之前的變量進行一次 release 操作。具體 setter 方法代碼如下:- (void)setRoom:(Room *)room { // room = r// 將以前的房間釋放掉 -1[_room release];// 對房間的引用計數器 +1[room retain];_room = room;} }
這樣在執行完 p.room = r2; 之后就會將 房間 r 釋放掉,最終內存表現為:
image4. 一個玩家使用一個游戲房間,不再使用游戲房間,將游戲房間釋放掉之后,再次使用該游戲房間的情況
int main(int argc, const char * argv[]) {@autoreleasepool {// 1. 創建兩個對象Person *p = [[Person alloc] init];Room *r = [[Room alloc] init];r.no = 888;// 2. 將房間 r 賦值給玩家 pp.room = r; // [p setRoom:r][r release]; // 釋放房間 r// 3. 再次使用房間 rp.room = r;[r release]; // 釋放房間 r[p release]; // 釋放玩家 p}return 0; }執行下面代碼:
// 1.創建兩個對象 Person *p = [[Person alloc] init]; Room *r = [[Room alloc] init]; r.no = 888;// 2.將房間賦值給人 p.room = r; // [p setRoom:r] [r release]; // 釋放房間 r之后的內存表現為:
image然后再執行 p.room = r;,因為 setter 方法會將之前的 Room 實例對象先釋放掉,此時內存表現為:
image此時 _room、r 已經變成了一個野指針。之后再對野指針 r 發出 retain 消息,程序就會崩潰。所以我們在進行 setter 方法的時候,要先判斷一下是否是重復賦值,如果是同一個實例對象,就不需要重復進行 release 和 retain。換句話說,如果我們使用的還是之前的房間,那換房的時候就不需要對這個房間再進行 release 和 retain。則 setter 方法具體代碼如下:
- (void)setRoom:(Room *)room { // room = r// 只有房間不同才需用 release 和 retainif (_room != room) { // 0ffe1 != 0ffe1// 將以前的房間釋放掉 -1[_room release];// 對房間的引用計數器+1[room retain];_room = room;} }因為 retain 不僅僅會對引用計數器 +1, 而且還會返回當前對象,所以上述代碼可最終簡化成:
- (void)setRoom:(Room *)room { // room = r// 只有房間不同才需用 release 和 retainif (_room != room) { // 0ffe1 != 0ffe1// 將以前的房間釋放掉 -1[_room release];_room = [room retain];} }以上就是 setter 方法的終極形式。通過上面多個例子,我們也理解了多個對象之間的內存管理思想。
3.6 @property 參數
- 在成員變量前加上 @property,系統就會自動幫我們生成基本的 setter / getter 方法,但是不會生成內存管理相關的代碼。
- 同樣如果在 property 后邊加上 assign,系統也不會幫我們生成 setter 方法內存管理的代碼,僅僅只會生成普通的 getter / setter 方法,默認什么都不寫就是 assign。
- 如果在 property 后邊加上 retain,系統就會自動幫我們生成 getter / setter 方法內存管理的代碼,但是仍需要我們自己重寫 dealloc 方法。
3.7 自動釋放池
當我們不再使用一個對象的時候應該將其空間釋放,但是有時候我們不知道何時應該將其釋放。為了解決這個問題,Objective-C 提供了 autorelease 方法。
- autorelease 是一種支持引用計數的內存管理方式,只要給對象發送一條 autorelease 消息,會將對象放到一個自動釋放池中,當自動釋放池被銷毀時,會對池子里面的「所有對象」做一次 release 操作。
注意:這里只是發送 release 消息,如果當時的引用計數(reference-counted)依然不為 0,則該對象依然不會被釋放。
- autorelease 方法會返回對象本身,且調用完 autorelease 方法后,對象的計數器不變。
3.7.1 使用 autorelease 有什么好處呢?
- 不用再關心對象釋放的時間
- 不用再關心什么時候調用release
3.7.2 autorelease 的原理實質上是什么?
autorelease 實際上只是把對 release 的調用延遲了,對于每一個 autorelease,系統只是把該對象放入了當前的 autorelease pool 中,當該 pool 被釋放時,該 pool 中的所有對象會被調用 release 方法。
3.7.3 autorelease 的創建方法
3.7.4 autorelease 的使用方法
NSAutoreleasePool *autoreleasePool = [[NSAutoreleasePool alloc] init]; Person *p = [[[Person alloc] init] autorelease]; [autoreleasePool drain]; @autoreleasepool { // 創建一個自動釋放池Person *p = [[Person new] autorelease];// 將代碼寫到這里就放入了自動釋放池 } // 銷毀自動釋放池(會給池子中所有對象發送一條 release 消息)3.7.5 autorelease 的注意事項
- 并不是放到自動釋放池代碼中,都會自動加入到自動釋放池
- 在自動釋放池的外部發送 autorelease 不會被加入到自動釋放池中
- autorelease 是一個方法,只有在自動釋放池中調用才有效。
3.7.6 自動釋放池的嵌套使用
- 自動釋放池是以棧的形式存在。
- 由于棧只有一個入口,所以調用 autorelease 會將對象放到棧頂的自動釋放池。
棧頂就是離調用 autorelease 方法最近的自動釋放池。
@autoreleasepool { // 棧底自動釋放池@autoreleasepool {@autoreleasepool { // 棧頂自動釋放池Person *p = [[[Person alloc] init] autorelease];}Person *p = [[[Person alloc] init] autorelease];} }- 自動釋放池中不適宜放占用內存比較大的對象。
- 盡量避免對大內存使用該方法,對于這種延遲釋放機制,還是盡量少用。
- 不要把大量循環操作放到同一個 @autoreleasepool 之間,這樣會造成內存峰值的上升。
3.7.7 autorelease 錯誤用法
- 不要連續調用 autorelease。
- 調用 autorelease 后又調用 release(錯誤)。
3.8 MRC 中避免循環引用
定義兩個類 Person 類和 Dog 類
- Person 類:
- Dog 類:
執行以下代碼:
int main(int argc, const char * argv[]) {Person *p = [Person new];Dog *d = [Dog new];p.dog = d; // retaind.owner = p; // retain assign[p release];[d release];return 0; }就會出現 A 對象要擁有 B 對象,而 B 對應又要擁有 A 對象,此時會形成循環 retain,導致 A 對象和 B 對象永遠無法釋放。
那么如何解決這個問題呢?
- 不要讓 A retain B,B retain A。
- 讓其中一方不要做 retain 操作即可。
- 當兩端互相引用時,應該一端用 retain,一端用 assign。
參考資料
- 【書籍】Objective-C 高級編程 iOS 與 OS X 多線程和內存管理
- 【博文】《Objective-C 高級編程》干貨三部曲(一):引用計數篇
總結
以上是生活随笔為你收集整理的iOS 开发:彻底理解 iOS 内存管理(MRC 篇)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 目前世界上最主要的导航电子地图数据标准/
- 下一篇: 缓冲管理