iOS內功篇:記憶體管理

發表於2016-04-05

前言

現在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類,重寫其建立和銷燬的方法

在main方法中建立dog物件,給dog傳送訊息

輸出結果為

可以看到,引用計數幫助寵物中心很好的標記了小狗的使用狀態,在完成任務的時候及時收回到寵物中心。

  思考幾個問題:

1)NSString引用計數問題
如果我們嘗試檢視一個string的引用計數

會發現引用計數為-1,這可以理解為NSString實際上是一個字串常量,是沒有引用計數的(或者它的引用計數是一個很大的值(使用%lu可以列印檢視),對它做引用計數操作沒實質上的影響)。

2)賦值不會擁有某個物件

這裡僅僅是指標賦值操作,並不會增加name的引用計數,需要持有物件必須要傳送retain訊息。

3)dealloc
由於釋放物件是會呼叫dealloc方法,因此重寫dealloc方法來檢視物件釋放的情況,如果沒有呼叫則會造成記憶體洩露。在上面的例子中我們通過重寫dealloc讓小狗被釋放的時候列印日誌來告訴我們已經完成釋放。

4)在上面例子中,如果我們增加這樣一個操作

會發現獲取到的引用計數為1,為什麼不是0呢?

這是因為對引用計數為1的物件release時,系統知道該物件將被回收,就不會再對該物件的引用計數進行減1操作,這樣可以增加物件回收的效率。
另外,對已釋放的物件傳送訊息是不可取的,因為物件的記憶體已被回收,如果傳送訊息時,該記憶體已經被其他物件使用了,得到的結果是無法確定的,甚至會造成崩潰。

2 自動釋放池

現在已經明確了,當不再使用一個物件時應該將其釋放,但是在某些情況下,我們很難理清一個物件什麼時候不再使用(比如xiaoming和小狗玩耍結束的時間不確定),這可怎麼辦?

ObjC提供autorelease方法來解決這個問題,當給一個物件傳送autorelease訊息時,方法會在未來某個時間給這個物件傳送release訊息將其釋放,在這個時間段內,物件還是可以使用的。

  那autorelease的原理是什麼呢?

原理就是物件接收到autorelease訊息時,它會被新增到了當前的自動釋放池中,當自動釋放池被銷燬時,會給池裡所有的物件傳送release訊息。

這裡就引出了自動釋放池這個概念,什麼是自動釋放池呢? 顧名思義,就是一個池,這個池可以容納物件,而且可以自動釋放,這就大大增加了我們處理物件的靈活性。

  自動釋放池怎樣建立?

ObjC提供兩種方法建立自動釋放池:
方法一:使用NSAutoreleasePool來建立

方法二:使用@autoreleasepool建立

自動釋放池建立後,就會成為活動的池子,釋放池子後,池子將釋放其所包含的所有物件。
以上兩種方法推薦第一種,因為將記憶體交給ObjC管理更高效。

  自動釋放池什麼時候建立?

app使用過程中,會定期自動生成和銷燬自動釋放池,一般是在程式事件處理之前建立,當然我們也可以自行建立自動釋放池,來達到我們一些特定的目的。

  自動釋放池什麼時候銷燬?

自動釋放池的銷燬時間是確定的,一般是在程式事件處理之後釋放,或者由我們自己手動釋放。

下面舉例說明自動釋放池的工作流程:
場景:現在xiaoming和xiaohong都想和小狗一起玩耍,但是他們的需求不一樣,他們的玩耍時間不一樣,流程如下

輸出結果如下:

可以看到,當池子釋放後,dog物件才被釋放,因此在池子釋放之前,xiaohong都可以盡情地和小狗玩耍。

  使用自動釋放池需要注意:

1)自動釋放池實質上只是在釋放的時候給池中所有物件物件傳送release訊息,不保證物件一定會銷燬,如果自動釋放池向物件傳送release訊息後物件的引用計數仍大於1,物件就無法銷燬。

2)自動釋放池中的物件會集中同一時間釋放,如果操作需要生成的物件較多佔用記憶體空間大,可以使用多個釋放池來進行優化。比如在一個迴圈中需要建立大量的臨時變數,可以建立內部的池子來降低記憶體佔用峰值。

3)autorelease不會改變物件的引用計數

  自動釋放池的常見問題:

在管理物件釋放的問題上,自動幫助我們釋放池節省了大量的時間,但是有時候它卻未必會達到我們期望的效果,比如在一個迴圈事件中,如果迴圈次數較大或者事件處理佔用記憶體較大,就會導致記憶體佔用不斷增長,可能會導致不希望看到的後果。
示例程式碼:

前面講過,自動釋放池的釋放時間是確定的,這個例子中自動釋放池會在迴圈事件結束時釋放,那問題來了:在這個十萬次的迴圈中,每次都會生成一個字串並列印,這些字串物件都放在池子中並直到迴圈結束才會釋放,因此在迴圈期間記憶體不增長。
這類問題的解決方案是在迴圈中建立新的自動釋放池,多少個迴圈釋放一次由我們自行決定。

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來看看

怎麼樣,是不是簡潔了很多,是不是很熟悉的感覺呢。

注意:
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。

比如我們常用的定時器

相當於

當不需要使用時,強制銷燬定時器

  __weak:弱引用,不持有所指向物件的所有權,引用指向的物件記憶體被回收之後,引用本身會置nil,避免野指標。

比如避免迴圈引用的弱引用宣告:

  __autoreleasing:自動釋放物件的引用,一般用於傳遞引數

比如一個讀取資料的方法

當你呼叫時會發現這樣的提示

這是編譯器自動幫我們插入以下程式碼

  __unsafe_unretained:為相容iOS5以下版本的產物,可以理解成MRC下的weak,現在基本用不到,這裡不作描述。
  有興趣的讀者可以思考:

1)__strong NSTimer * timerNSTimer * __strong timer哪個寫法是正確的, 為什麼編譯器不報錯?
2)使用__autoreleasing可能會遇到哪些問題?

  3.4 屬性的記憶體管理

ObjC2.0引入了@property,提供成員變數訪問方法、許可權、環境、記憶體管理型別的宣告,下面主要說明ARC中屬性的記憶體管理。

屬性的引數分為三類,基本資料型別預設為(atomic,readwrite,assign),物件型別預設為(atomic,readwrite,strong),其中第三個引數就是該屬性的記憶體管理方式修飾,修飾詞可以是以下之一:

  1)assign:直接賦值

assign一般用來修飾基本資料型別

當然也可以修飾ObjC物件,但是不推薦,因為被assign修飾的物件釋放後,指標還是指向釋放前的記憶體,在後續操作中可能會導致記憶體問題引發崩潰。

  2)retain:release舊值,再retain新值(引用計數+1)

retain和strong一樣,都用來修飾ObjC物件。
使用set方法賦值時,實質上是會先保留新值,再釋放舊值,再設定新值,避免新舊值一樣時導致物件被釋放的的問題。

MRC寫法如下

ARC對應寫法

  3)copy:release舊值,再copy新值(拷貝內容)

一般用來修飾String、Dict、Array等需要保護其封裝性的物件,尤其是在其內容可變的情況下,因此會拷貝(深拷貝)一份內容給屬性使用,避免可能造成的對源內容進行改動。

使用set方法賦值時,實質上是會先拷貝新值,再釋放舊值,再設定新值。
實際上,遵守NSCopying的物件都可以使用copy,當然,如果你確定是要共用同一份可變內容,你也可以使用strong或retain。

  4)weak:ARC新引入修飾詞,可代替assign,比assign多增加一個特性(置nil,見上文)。

weak和strong一樣用來修飾ObjC物件。
使用set方法賦值時,實質上不保留新值,也不釋放舊值,只設定新值。

比如常用的代理的宣告

Xib控制元件的引用

  5)strong:ARC新引入修飾詞,可代替retain

可參照retain,這裡不再作描述。

  有興趣的讀者可以思考:

1)各個屬性修飾詞和3.3中的修飾詞的對應關係?
2)屬性的本質是什麼?

  3.5 block的記憶體管理

iOS中使用block必須自己管理記憶體,錯誤的記憶體管理將導致迴圈引用等記憶體洩漏問題,這裡主要說明在ARC下block宣告和使用的時候需要注意的兩點:
1)如果你使用@property去宣告一個block的時候,一般使用copy來進行修飾(當然也可以不寫,編譯器自動進行copy操作),儘量不要使用retain。

2)block會對內部使用的物件進行強引用,因此在使用的時候應該確定不會引起迴圈引用,當然保險的做法就是新增弱引用標記。

  有興趣的讀者可以深入瞭解:

1、block的內部實現原理是什麼?
2、從記憶體位置來看block有幾種型別?它們的記憶體管理方式各是怎樣的?
3、對於不同型別的外部變數,block的記憶體管理都是怎樣的?

4 經典記憶體洩漏及其解決方案

雖然ARC好處多多,然而也並無法避免記憶體洩漏問題,下面介紹在ARC中常見的記憶體洩漏。

  4.1 殭屍物件和野指標

  殭屍物件:記憶體已經被回收的物件。
  野指標:指向殭屍物件的指標,向野指標傳送訊息會導致崩潰。

野指標錯誤形式在Xcode中通常表現為:Thread 1:EXC_BAD_ACCESS,因為你訪問了一塊已經不屬於你的記憶體。
例子程式碼:(沒有出現錯誤的筒子多執行幾遍,因為獲取野指標指向的結果是不確定的)

執行結果:

可以看到,當執行到第六行的時候崩潰了,並給出了EXC_BAD_ACCESS的提示。

  解決方案:

物件已經被釋放後,應將其指標置為空指標(沒有指向任何物件的指標,給空指標傳送訊息不會報錯)。

然而在實際開發中實際遇到EXC_BAD_ACCESS錯誤時,往往很難定位到錯誤點,幸好Xcode提供方便的工具給我們來定位及分析錯誤。
1)在product-scheme-edit scheme-diagnostics中將enable zombie objects勾選上,下次再出現這樣的錯誤就可以準確定位了。
執行結果:

可以看到,當執行到第六行時並沒有崩潰,並給出了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個演員來打仗

 

該迴圈內產生大量的臨時物件,直至迴圈結束才釋放,可能導致記憶體洩漏,解決方法和上文中提到的自動釋放池常見問題類似:在迴圈中建立自己的autoReleasePool,及時釋放佔用記憶體大的臨時變數,減少記憶體佔用峰值。

然而有時候autoReleasePool也不是萬能的:
例子:假如有2000張圖片,每張1M左右,現在需要獲取所有圖片的尺寸,你會怎麼做?
如果這樣做

用imageNamed方法載入圖片佔用Cache的記憶體,autoReleasePool也不能釋放,對此問題需要另外的解決方法,當然保險的當然是雙管齊下了

  4.4 無限迴圈

這個是比4.3更極端的情況,無論你出於什麼原因,當你啟動了一個無限迴圈的時候,ARC會預設該方法用不會執行完畢,方法裡面的物件就永不釋放,記憶體無限上漲,導致記憶體洩漏。
例子:

輸出結果為

可以看到,當控制器釋放後該迴圈還在繼續。

  對於這類問題解決方案是什麼呢?留給讀者思考吧~ ^_^

提示:解決方法有autoreleasepool、block、timer等等

後記

關於iOS記憶體管理的知識點很多,如果展開來講,本文涉及的知識點都可以寫成一篇長文,因此,本文只是做一個概述,試圖起到拋磚引玉的作用,幫助iOS開發的初學者更快地理解記憶體管理。
關於第四點“經典記憶體洩漏及其解決方案”,將專門寫一篇文章在本文的基礎上詳細介紹(圖文並茂 ^_^),敬請期待。

相關文章