作者:明仔Su(簡書)
前言
現在iOS開發已經是arc甚至是swift的時代,但是內存管理仍是一個重點關注的問題,如果只知盲目開發而不知個中原理,踩坑就跳不出來了,理解好內存管理,能讓我們寫出更有質量的代碼。
內存管理是程序設計中很重要的一部分,程序在運行的過程中消耗內存,運行結束後釋放占用的內存。如果程序運行時一直分配內存而不及時釋放無用的內存,會造成這樣的後果:程序占用的內存越來越大,直至內存消耗殚盡,程序因無內存可用導致崩潰,這樣的情況我們稱之為內存洩漏。
ObjC的內存管理比較簡潔,然而要深刻理解也不是一件易事,本文將介紹如何使用ObjC進行內存管理。
1、引用計數
在ObjC中,對象什麼時候會被釋放(或者對象占用的內存什麼時候會被回收利用)?
答案是:當對象沒有被任何變量引用(也可以說是沒有指針指向該對象)的時候,就會被釋放。
那怎麼知道對象已經沒有被引用了呢?
ObjC采用引用計數(reference counting)的技術來進行管理:
1)每個對象都有一個關聯的整數,稱為引用計數器
2)當代碼需要使用該對象時,則將對象的引用計數加1
3)當代碼結束使用該對象時,則將對象的引用計數減1
4)當引用計數的值變為0時,表示對象沒有被任何代碼使用,此時對象將被釋放。
與之對應的消息發送方法如下:
1)當對象被創建(通過alloc、new或copy等方法)時,其引用計數初始值為1
2)給對象發送retain消息,其引用計數加1
3)給對象發送release消息,其引用計數減1
4)當對象引用計數歸0時,ObjC給對象發送dealloc消息銷毀對象
下面通過一個簡單的例子來說明:
場景:有一個寵物中心(內存),可以派出小動物(對象)陪小朋友們玩耍(對象引用者),現在xiaoming想和小狗一起玩耍。
新建Dog類,重寫其創建和銷毀的方法:
@implementation Dog - (instancetype)init { if (self = [super init]) { NSLog(@"小狗被派出去啦!初始引用計數為 %ld",self.retainCount); } return self; } - (void)dealloc { NSLog(@"小狗回到寵物中心"); [super dealloc]; } @end
在main方法中創建dog對象,給dog發送消息
//模擬:寵物中心派出小狗 Dog * dog = [[Dog alloc]init]; //模擬:xiaoming需要和小狗玩耍,需要將其引用計數加1 [dog retain]; NSLog(@"小狗的引用計數為 %ld",dog.retainCount); //模擬:xiaoming不和小狗玩耍了,需要將其引用計數減1 [dog release]; NSLog(@"小狗的引用計數為 %ld",dog.retainCount); //沒人需要和小狗玩耍了,將其引用計數減1 [dog release]; //將指針置nil,否則變為野指針 dog = nil;
輸出結果為
[34691:7638855] 初始引用計數為 1 [34691:7638855] 小狗的引用計數為 2 [34691:7638855] 小狗的引用計數為 1 [34691:7638855] 銷毀Dog
可以看到,引用計數幫助寵物中心很好的標記了小狗的使用狀態,在完成任務的時候及時收回到寵物中心。
思考幾個問題:
1)NSString引用計數問題
如果我們嘗試查看一個string的引用計數
NSString * str = @"hello guys"; NSLog(@"%ld", str.retainCount);
會發現引用計數為-1,這可以理解為NSString實際上是一個字符串常量,是沒有引用計數的(或者它的引用計數是一個很大的值(使用%lu可以打印查看),對它做引用計數操作沒實質上的影響)。
2)賦值不會擁有某個對象
NSString * name = dog.name;
這裡僅僅是指針賦值操作,並不會增加name的引用計數,需要持有對象必須要發送retain消息。
3)dealloc
由於釋放對象是會調用dealloc方法,因此重寫dealloc方法來查看對象釋放的情況,如果沒有調用則會造成內存洩露。在上面的例子中我們通過重寫dealloc讓小狗被釋放的時候打印日志來告訴我們已經完成釋放。
4)在上面例子中,如果我們增加這樣一個操作
//沒人需要和小狗玩耍了,將其引用計數減1 [dog release]; NSLog(@"%ld",dog.retainCount);
會發現獲取到的引用計數為1,為什麼不是0呢?
這是因為對引用計數為1的對象release時,系統知道該對象將被回收,就不會再對該對象的引用計數進行減1操作,這樣可以增加對象回收的效率。
另外,對已釋放的對象發送消息是不可取的,因為對象的內存已被回收,如果發送消息時,該內存已經被其他對象使用了,得到的結果是無法確定的,甚至會造成崩潰。
2、自動釋放池
現在已經明確了,當不再使用一個對象時應該將其釋放,但是在某些情況下,我們很難理清一個對象什麼時候不再使用(比如xiaoming和小狗玩耍結束的時間不確定),這可怎麼辦?
ObjC提供autorelease方法來解決這個問題,當給一個對象發送autorelease消息時,方法會在未來某個時間給這個對象發送release消息將其釋放,在這個時間段內,對象還是可以使用的。
那autorelease的原理是什麼呢?
原理就是對象接收到autorelease消息時,它會被添加到了當前的自動釋放池中,當自動釋放池被銷毀時,會給池裡所有的對象發送release消息。
這裡就引出了自動釋放池這個概念,什麼是自動釋放池呢? 顧名思義,就是一個池,這個池可以容納對象,而且可以自動釋放,這就大大增加了我們處理對象的靈活性。
自動釋放池怎樣創建?
ObjC提供兩種方法創建自動釋放池:
方法一:使用NSAutoreleasePool來創建
NSAutoreleasePool * pool = [[NSAutoreleasePool alloc]init]; //這裡寫代碼 [pool release];
方法二:使用@autoreleasepool創建
@autoreleasepool { //這裡寫代碼 }
自動釋放池創建後,就會成為活動的池子,釋放池子後,池子將釋放其所包含的所有對象。
以上兩種方法推薦第一種,因為將內存交給ObjC管理更高效。
自動釋放池什麼時候創建?
app使用過程中,會定期自動生成和銷毀自動釋放池,一般是在程序事件處理之前創建,當然我們也可以自行創建自動釋放池,來達到我們一些特定的目的。
自動釋放池什麼時候銷毀?
自動釋放池的銷毀時間是確定的,一般是在程序事件處理之後釋放,或者由我們自己手動釋放。
下面舉例說明自動釋放池的工作流程:
場景:現在xiaoming和xiaohong都想和小狗一起玩耍,但是他們的需求不一樣,他們的玩耍時間不一樣,流程如下
//創建一個自動釋放池 NSAutoreleasePool * pool = [[NSAutoreleasePool alloc] init]; //模擬:寵物中心派出小狗 Dog * dog = [[Dog alloc]init]; //模擬:xiaoming需要和小狗玩耍,需要將其引用計數加1 [dog retain]; NSLog(@"小狗的引用計數為 %ld",dog.retainCount); //模擬:xiaohong需要和小狗玩耍,需要將其引用計數加1 [dog retain]; NSLog(@"小狗的引用計數為 %ld",dog.retainCount); //模擬:xiaoming確定不想和小狗玩耍了,需要將其引用計數減1 [dog release]; NSLog(@"小狗的引用計數為 %ld",dog.retainCount); //模擬:xiaohong不確定何時不想和小狗玩耍了,將其設置為自動釋放 [dog autorelease]; NSLog(@"小狗的引用計數為 %ld",dog.retainCount); //沒人需要和小狗玩耍了,將其引用計數減1 [dog release]; NSLog(@"釋放池子"); [pool release]; //創建一個自動釋放池 @autoreleasepool { //模擬:寵物中心派出小狗 Dog * dog = [[Dog alloc]init]; //模擬:xiaoming需要和小狗玩耍,需要將其引用計數加1 [dog retain]; NSLog(@"小狗的引用計數為 %ld",dog.retainCount); //模擬:xiaohong需要和小狗玩耍,需要將其引用計數加1 [dog retain]; NSLog(@"小狗的引用計數為 %ld",dog.retainCount); //模擬:xiaoming確定不想和小狗玩耍了,需要將其引用計數減1 [dog release]; NSLog(@"小狗的引用計數為 %ld",dog.retainCount); //模擬:xiaohong不確定何時不想和小狗玩耍了,將其設置為自動釋放 [dog autorelease]; NSLog(@"小狗的引用計數為 %ld",dog.retainCount); //沒人需要和小狗玩耍了,將其引用計數減1 [dog release]; NSLog(@"釋放池子"); }
輸出結果如下:
[34819:7801589] 初始引用計數為 1 [34819:7801589] 小狗的引用計數為 2 [34819:7801589] 小狗的引用計數為 3 [34819:7801589] 小狗的引用計數為 2 [34819:7801589] 小狗的引用計數為 2 [34819:7801589] 釋放池子 [34819:7801589] 銷毀Dog
可以看到,當池子釋放後,dog對象才被釋放,因此在池子釋放之前,xiaohong都可以盡情地和小狗玩耍。
使用自動釋放池需要注意:
1)自動釋放池實質上只是在釋放的時候給池中所有對象對象發送release消息,不保證對象一定會銷毀,如果自動釋放池向對象發送release消息後對象的引用計數仍大於1,對象就無法銷毀。
2)自動釋放池中的對象會集中同一時間釋放,如果操作需要生成的對象較多占用內存空間大,可以使用多個釋放池來進行優化。比如在一個循環中需要創建大量的臨時變量,可以創建內部的池子來降低內存占用峰值。
3)autorelease不會改變對象的引用計數
自動釋放池的常見問題:
在管理對象釋放的問題上,自動幫助我們釋放池節省了大量的時間,但是有時候它卻未必會達到我們期望的效果,比如在一個循環事件中,如果循環次數較大或者事件處理占用內存較大,就會導致內存占用不斷增長,可能會導致不希望看到的後果。
示例代碼:
for (int i = 0; i < 100000; i ++) { NSString * log = [NSString stringWithFormat:@"%d", i]; NSLog(@"%@", log); }
前面講過,自動釋放池的釋放時間是確定的,這個例子中自動釋放池會在循環事件結束時釋放,那問題來了:在這個十萬次的循環中,每次都會生成一個字符串並打印,這些字符串對象都放在池子中並直到循環結束才會釋放,因此在循環期間內存不增長。
這類問題的解決方案是在循環中創建新的自動釋放池,多少個循環釋放一次由我們自行決定。
for (int i = 0; i < 100000; i ++) { @autoreleasepool { NSString * log = [NSString stringWithFormat:@"%d", i]; NSLog(@"%@", log); } }
3、iOS的內存管理規則
3.1 基本原則
無規矩不成方圓,在iOS開發中也存在規則來約束開發者進行內存管理,總的來講有三點:
1)當你通過new、alloc或copy方法創建一個對象時,它的引用計數為1,當不再使用該對象時,應該向對象發送release或者autorelease消息釋放對象。
2)當你通過其他方法獲得一個對象時,如果對象引用計數為1且被設置為autorelease,則不需要執行任何釋放對象的操作;
3)如果你打算取得對象所有權,就需要保留對象並在操作完成之後釋放,且必須保證retain和release的次數對等。
應用到文章開頭的例子中,小朋友每申請一個小狗(生成對象),最後都要歸還到寵物中心(釋放對象),如果只申請而不歸還(對象創建了沒有釋放),那寵物中心的小狗就會越來越少(可用內存越來越少),到最後一個小狗都沒有了(內存被耗盡),其他小朋友就再也沒有小狗可申請了(無資源可申請使用),因此,必須要遵守規則:申請必須歸還(規則1),申請幾個必須歸還幾個(規則3),如果小狗被設定歸還時間則不用小朋友主動歸還(規則2)。
有興趣的讀者可以思考:
以上原則可以總結成一句簡潔的話,是什麼呢?
3.2 ARC
在MRC時代,必須嚴格遵守以上規則,否則內存問題將成為惡魔一樣的存在,然而來到ARC時代,事情似乎變得輕松了,不用再寫無止盡的ratain和release似乎讓開發變得輕松了,對初學者變得更友好。
ObjC2.0引入了垃圾回收機制,然而由於垃圾回收機制會對移動設備產生某些不好的影響(例如由於垃圾清理造成的卡頓),iOS並不支持這個機制,蘋果的解決方案就是ARC(自動引用計數)。
iOS5以後,我們可以開啟ARC模式,ARC可以理解成一位管家,這個管家會幫我們向對象發送retain和release語句,不再需要我們手動添加了,我們可以更舒心地創建或引用對象,簡化內存管理步驟,節省大量的開發時間。
實際上,ARC不是垃圾回收,也並不是不需要內存管理了,它是隱式的內存管理,編譯器在編譯的時候會在代碼插入合適的ratain和release語句,相當於在背後幫我們完成了內存管理的工作。
下面將自動釋放池的例子轉化成ARC來看看
@autoreleasepool { Dog * dog = [[Dog alloc]init]; [xiaoming playWithDog:dog]; [xiaohong playWithDog:dog]; NSLog(@"釋放池子"); }
怎麼樣,是不是簡潔了很多,是不是很熟悉的感覺呢。
注意:
1)如果你的工程歷史比較久,可以將其從MRC轉換成ARC,跟上時代的步伐更好地維護
2)如果你的工程引用了某些不支持ARC的庫,可以在Build Phases的Compile Sources將對應的m文件的編譯器參數配置為-fno-objc-arc
3)ARC能幫我們簡化內存管理問題,但不代表它是萬能的,還是有它不能處理的情況,這就需要我們自己手動處理,比如循環引用、非ObjC對象、Core Foundation中的malloc()或者free()等等
有興趣的讀者可以思考:
MRC有什麼缺點?ARC有什麼局限性?請列舉。
3.3 ARC的修飾符
ARC提供四種修飾符,分別是strong, weak, autoreleasing, unsafe_unretained。
__strong:強引用,持有所指向對象的所有權,無修飾符情況下的默認值。如需強制釋放,可置nil。
比如我們常用的定時器
NSTimer * timer = [NSTimer timerWith...];
相當於
NSTimer * __strong timer = [NSTimer timerWith...];
當不需要使用時,強制銷毀定時器
[timer invalidate]; timer = nil;
__weak:弱引用,不持有所指向對象的所有權,引用指向的對象內存被回收之後,引用本身會置nil,避免野指針。
比如避免循環引用的弱引用聲明:
__weak __typeof(self) weakSelf = self;
__autoreleasing:自動釋放對象的引用,一般用於傳遞參數
比如一個讀取數據的方法
- (void)loadData:(NSError **)error;
當你調用時會發現這樣的提示
NSError * error; [dataTool loadData:(NSError *__autoreleasing *)]
這是編譯器自動幫我們插入以下代碼
NSError * error; NSError * __autoreleasing tmpErr = error; [dataTool loadData:&tmpErr];
__unsafe_unretained:為兼容iOS5以下版本的產物,可以理解成MRC下的weak,現在基本用不到,這裡不作描述。
有興趣的讀者可以思考:
1)__strong NSTimer * timer 和 NSTimer * __strong timer哪個寫法是正確的, 為什麼編譯器不報錯?
2)使用__autoreleasing可能會遇到哪些問題?
3.4 屬性的內存管理
ObjC2.0引入了@property,提供成員變量訪問方法、權限、環境、內存管理類型的聲明,下面主要說明ARC中屬性的內存管理。
屬性的參數分為三類,基本數據類型默認為(atomic,readwrite,assign),對象類型默認為(atomic,readwrite,strong),其中第三個參數就是該屬性的內存管理方式修飾,修飾詞可以是以下之一:
1)assign:直接賦值
assign一般用來修飾基本數據類型
@property (nonatomic, assign) NSInteger count;
當然也可以修飾ObjC對象,但是不推薦,因為被assign修飾的對象釋放後,指針還是指向釋放前的內存,在後續操作中可能會導致內存問題引發崩潰。
2)retain:release舊值,再retain新值(引用計數+1)
retain和strong一樣,都用來修飾ObjC對象。
使用set方法賦值時,實質上是會先保留新值,再釋放舊值,再設置新值,避免新舊值一樣時導致對象被釋放的的問題。
MRC寫法如下
- (void)setCount:(NSObject *)count { [count retain]; [_count release]; _count = count; }
ARC對應寫法
- (void)setCount:(NSObject *)count { _count = count; }
3)copy:release舊值,再copy新值(拷貝內容)
一般用來修飾String、Dict、Array等需要保護其封裝性的對象,尤其是在其內容可變的情況下,因此會拷貝(深拷貝)一份內容給屬性使用,避免可能造成的對源內容進行改動。
使用set方法賦值時,實質上是會先拷貝新值,再釋放舊值,再設置新值。
實際上,遵守NSCopying的對象都可以使用copy,當然,如果你確定是要共用同一份可變內容,你也可以使用strong或retain。
@property (nonatomic, copy) NSString * name;
4)weak:ARC新引入修飾詞,可代替assign,比assign多增加一個特性(置nil,見上文)。
weak和strong一樣用來修飾ObjC對象。
使用set方法賦值時,實質上不保留新值,也不釋放舊值,只設置新值。
比如常用的代理的聲明
@property (weak) iddelegate;
Xib控件的引用
@property (weak, nonatomic) IBOutlet UIImageView *productImage;
5)strong:ARC新引入修飾詞,可代替retain
可參照retain,這裡不再作描述。
有興趣的讀者可以思考:
1)各個屬性修飾詞和3.3中的修飾詞的對應關系?
2)屬性的本質是什麼?
3.5 block的內存管理
iOS中使用block必須自己管理內存,錯誤的內存管理將導致循環引用等內存洩漏問題,這裡主要說明在ARC下block聲明和使用的時候需要注意的兩點:
1)如果你使用@property去聲明一個block的時候,一般使用copy來進行修飾(當然也可以不寫,編譯器自動進行copy操作),盡量不要使用retain。
@property (nonatomic, copy) void(^block)(NSData * data);
2)block會對內部使用的對象進行強引用,因此在使用的時候應該確定不會引起循環引用,當然保險的做法就是添加弱引用標記。
__weak typeof(self) weakSelf = self;
有興趣的讀者可以深入了解:
1、block的內部實現原理是什麼?
2、從內存位置來看block有幾種類型?它們的內存管理方式各是怎樣的?
3、對於不同類型的外部變量,block的內存管理都是怎樣的?
4 經典內存洩漏及其解決方案
雖然ARC好處多多,然而也並無法避免內存洩漏問題,下面介紹在ARC中常見的內存洩漏。
4.1 僵屍對象和野指針
僵屍對象:內存已經被回收的對象。
野指針:指向僵屍對象的指針,向野指針發送消息會導致崩潰。
野指針錯誤形式在Xcode中通常表現為:Thread 1:EXC_BAD_ACCESS,因為你訪問了一塊已經不屬於你的內存。
例子代碼:(沒有出現錯誤的筒子多運行幾遍,因為獲取野指針指向的結果是不確定的)
Dog * dog = [[Dog alloc]init]; NSLog(@"before"); NSLog(@"%s",object_getClassName(dog)); [dog release]; NSLog(@"after"); NSLog(@"%s",object_getClassName(dog));
運行結果:
[15184:5811062] before [15184:5811062] Dog [15184:5811062] after (lldb)
可以看到,當運行到第六行的時候崩潰了,並給出了EXC_BAD_ACCESS的提示。
解決方案:
對象已經被釋放後,應將其指針置為空指針(沒有指向任何對象的指針,給空指針發送消息不會報錯)。
然而在實際開發中實際遇到EXC_BAD_ACCESS錯誤時,往往很難定位到錯誤點,幸好Xcode提供方便的工具給我們來定位及分析錯誤。
1)在product-scheme-edit scheme-diagnostics中將enable zombie objects勾選上,下次再出現這樣的錯誤就可以准確定位了。
運行結果:
[15169:5801945] before [15169:5801945] Dog [15169:5801945] after [15169:5801945] _NSZombie_Dog
可以看到,當運行到第六行時並沒有崩潰,並給出了NSZombie的提示。
2)在Xcode-open developer tool-Instruments打開工具集,選擇Zombies工具可以對已安裝的應用進行僵屍對象檢測。
4.2 循環引用
循環引用是ARC中最常出現的問題,對於可能引發循環引用的一些原因在前一篇文章iOS總結篇:影響控制器正常釋放的常見問題中有提及,大家可以看看。
一般來講循環引用也是可以使用工具來檢測到的,分為兩種:
1)在product-Analyze中使用靜態分析來檢測代碼中可能存在循環引用的問題。
2)在Xcode-open developer tool-Instruments打開工具集,選擇Leaks工具可以對已安裝的應用進行內存洩漏檢測,此工具能檢測靜態分析不會提示,但是到運行時才會出現的內存洩漏問題。
Leaks工具雖然強大,但是它不能檢測到block循環引用導致的內存洩漏,這種情況一般需要自行排查問題(考驗你的基本功時候到了),傻瓜式的方案當然是重寫對象的dealloc方法來監測對象是否正常釋放,來確認沒有形成循環引用。
由於ARC中循環引用出現的幾率相對較大,很多大神或者團隊都提供了很多解決此問題的思路和方法,甚至開發了插件和類庫來幫助開發者更好地檢測問題,有興趣的讀者可以研究一下,是否好用,孰好孰壞就由讀者自行評判了。
4.3 循環中對象占用內存大
這個問題常見於循環次數較大,循環體生成的對象占用內存較大的情景。
例子代碼:我需要10000個演員來打仗
for (int i = 0; i < 10000; i ++) { Person * soldier = [[Person alloc]init]; [soldier fight]; }
該循環內產生大量的臨時對象,直至循環結束才釋放,可能導致內存洩漏,解決方法和上文中提到的自動釋放池常見問題類似:在循環中創建自己的autoReleasePool,及時釋放占用內存大的臨時變量,減少內存占用峰值。
for (int i = 0; i < 10000; i ++) { @autoreleasepool { Person * soldier = [[Person alloc]init]; [soldier fight]; } }
然而有時候autoReleasePool也不是萬能的:
例子:假如有2000張圖片,每張1M左右,現在需要獲取所有圖片的尺寸,你會怎麼做?
如果這樣做
for (int i = 0; i < 2000; i ++) { CGSize size = [UIImage imageNamed:[NSString stringWithFormat:@"%d.jpg",i]].size; //add size to array }
用imageNamed方法加載圖片占用Cache的內存,autoReleasePool也不能釋放,對此問題需要另外的解決方法,當然保險的當然是雙管齊下了
for (int i = 0; i < 2000; i ++) { @autoreleasepool { CGSize size = [UIImage imageWithContentsOfFile:filePath].size; //add siez to array } }
4.4 無限循環
這個是比4.3更極端的情況,無論你出於什麼原因,當你啟動了一個無限循環的時候,ARC會默認該方法用不會執行完畢,方法裡面的對象就永不釋放,內存無限上漲,導致內存洩漏。
例子:
NSLog(@"start !"); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ BOOL isSucc = YES; while (isSucc) { [NSThread sleepForTimeInterval:1.0]; NSLog(@"create an obj"); } });
輸出結果為
[7026:3555827] start ! [7026:3556236] create an obj [7026:3556236] create an obj [7026:3556236] create an obj [7026:3556236] create an obj [7026:3555827] dealloc [7026:3556236] create an obj [7026:3556236] create an obj [7026:3556236] create an obj
可以看到,當控制器釋放後該循環還在繼續。
對於這類問題解決方案是什麼呢?留給讀者思考吧~ ^_^
提示:解決方法有autoreleasepool、block、timer等等
後記
關於iOS內存管理的知識點很多,如果展開來講,本文涉及的知識點都可以寫成一篇長文,因此,本文只是做一個概述,試圖起到拋磚引玉的作用,幫助iOS開發的初學者更快地理解內存管理。
關於第四點“經典內存洩漏及其解決方案”,將專門寫一篇文章在本文的基礎上詳細介紹(圖文並茂),敬請期待。