2014年3月11日

開發iOS應用程式時的記憶體管理(一) - 淺談參考計數(Reference Counting)

前言

參考計數(Reference Counting)是一種撰寫程式時所用到的記憶體管理的技術。在早期開發iOS應用程式時,常在程式中使用許多與參考計數有關的程式碼;在這些程式碼中,只要程式設計師稍有疏漏,即可能導致記憶體滲漏(Memort leak)或是使應用程式異常終止。在XCode版本4.2之後引入了自動參考計數(Automatic Reference Counting, ARC)的功能,這些與參考計數有關的程式碼將不再需要由程式設計師負責,而是改由編譯器處理,大幅降低記憶體管理出錯的機率。儘管如此,iOS程式設計師仍應理解參考計數,了解編譯器究竟幫助我們做了什麼,才能在較複雜的環境中正確除錯。

何謂參考計數?以及參考計數的運作方式

參考計數指的是程式中某個物件被參考的次數,也就是有幾個變數同時指向該物件。
當物件被建立時,其參考計數為1,每增加一個變數指向該物件,則該物件參考計數加1,當某個變數不再指向該物件,則參考計數減1。
當某個物件的參考計數歸為0時,該物件的記憶體空間將被取消配置,也就是被系統標記為無人使用,稍後其他新建立的物件將很有可能使用此記憶體空間。
然而,很重要的一點是:在沒有使用ARC的情況下,參考計數的增減皆需由程式設計師自行負責

使用retain, release方法來管理參考計數

程式設計師需要使用retain方法來增加物件的參考計數,使用release方法來減少物件的參考計數。 以下以NSString物件為例:
NSString* myString = [[NSString alloc] initWithString:@"myString"];

// do something with myString...

[myString release];

//以下程式將不可再使用myString,
使用alloc建立物件時,myString預設參考計數為1;當release方法執行後,myString物件理論上會歸0(假設myString物件沒有在其他地方呼叫retain方法),myString物件的記憶體空間被取消配置。由於當物件被取消配置時,該記憶體空間將可能變成其他物件,但不一定馬上發生;因此,錯誤的取消配置往往不一定能在第一時間察覺,使得除錯變得相當困難。
此外,單純使用assign運算子將一個變數指向同一個物件,並不會增加參考計數:
NSString* myString = [[NSString alloc] initWithString:@"myString"];
NSString* anotherString = myString;
// retain count remains 1 ...
上述例子,儘管有兩個變數都指向同一個物件,參考計數依舊是1。 新手程式設計師很容易誤以為此時參考計數變為2,並在之後分別對myString以及anotherString呼叫release,導致發生錯誤:
// after do something...
[myString release];
[anotherString release];    //系統發出重複釋放記憶體的錯誤

程式設計師必須自行管理參考計數,正確做法應如下:
NSString* myString = [[NSString alloc] initWithString:@"myString"];
NSString* anotherString = myString;
[myString retain];
// retain count is 2.

// after do something...
[myString release];         // retain count is 1.
[anotherString release];    // retain count is 0, 取消配置

使用autorelease方法讓物件於稍後再減少參考計數

在需要將物件以函式返回值回傳時,該物件在函式中不能中釋放:
-(NSString*) generateMyStr {
    NSString* s = [[NSString alloc] initWithString:@"myString"];
    // do something     
    [s release]; //錯誤,不可在此release,否則返回值將被取消配置
    return s;
}
此時,該物件必須要在函式外部使用完畢後才能release。這樣變成呼叫generateMyStr方法的程式碼,也要負責release該物件。不過,更好的做法是使用autorelease方法
-(NSString*) generateMyStr {
    NSString* s = [[NSString alloc] initWithString:@"myString"];
    // do something     
    return [s autorelease];
}
autorelease方法讓物件於自動釋放緩衝池(Autorelease Pool)進行排放時,自動執行release方法。
如此一來,呼叫generateMyStr方法的程式碼將不再需要負責物件的釋放。降低了程式設計師的負擔,尤其是在使用第三方API的時候。

函式命名規則與記憶體管理

由上述情況看來,如果程式中大量地使用了第三方所撰寫的程式嗎,情況將變得相當複雜;你可能很難了解什麼時候必須正確地使用release方法來釋放物件,使程式不會有過多的記憶體滲漏,卻也不會因為錯誤地釋放資源而造成程式異常。
官方的開發文件有提到幾點基本的記憶體管理規則
  1. 當你明確對一個物件使用了retain方法後,若你將確定不再使用該物件時,務必呼叫release方法。
  2. 使用allocnewcopy, 與mutableCopy 開頭的函式所建立的物件,若你將確定不再使用該物件時,務必呼叫release方法。
需要特別注意第2點,基本的Cocoa API都遵循這項規則:
NSString* str1 = [[NSString alloc] initWithFormat:@"myStr%i",999]; 
// str1使用完之後,必須呼叫release方法,才不會造成記憶體滲漏

NSString* str2 = [NSString stringWithFormat:@"myStr%i",999]; 
// str2使用完之後,不可以呼叫release方法,否則會造成重複釋放

使用自動釋放緩衝池(Autorelease Pool)

自動釋放緩衝池是用來管理需要稍後釋放的物件。
建立自動釋放緩衝池的語法如下:
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
//在此範圍內使用autorelease方法的物件,將被加入緩衝池內;
//當此pool執行drain或release方法時,池內物件的release方法也都會被呼叫
[pool release];
在範圍內使用autorelease方法的物件,將被加入緩衝池內,子函式中使用autorelease的物件也包括在內; 當緩衝池釋放時,將對所有池內物件呼叫release方法。
下方兩圖皆於緩衝池釋放後,刻意印出已釋放的變數,證明該變數所指空間已被取消配置。


而新版的XCode使用@autoreleasepool語法建立自動釋放緩衝池:
@autoreleasepool {
//在此範圍內使用autorelease方法的物件,將被加入緩衝池內;
//當此pool執行drain或release方法時,池內物件的release方法也都會被呼叫
}
這個方法的好處是,因為作用域的關係,因此可以在編譯時期就偵測到部分不合理存取的情況並阻止:

如果你打開XCode的iOS專案自動產生的main.m檔,將可以看到XCode已經預設為我們建立了一個自動釋放緩衝池:
int main(int argc, char *argv[])
{
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
    int retVal = UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    [pool release];
    return retVal;
}
使用@autoreleasepool的版本:
int main(int argc, char * argv[])
{
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

使用自動釋放緩衝池降低暫時性的高記憶體使用量

假設某段程式碼需要在迴圈中建立大型物件,此大型物件在子函式中使用autorelease方法來建立:
NSArray* objectIndices = ...;
for(NSNumber* index in objectIndices) {
    LargeObject* obj = [self createLargeObjectWithIndex:index]; 
    //do something...
}
即使大型物件終將在稍後被釋放,不造成記憶體滲漏;但在執行迴圈的這段期間,所有的大型物件都還不會被釋放,這將導致暫時性的高記憶體使用量。
此時,若在迴圈的每一次迭代時,分別使用@autorelease建立自動釋放緩衝池:
NSArray* objectIndices = ...;
for(NSNumber* index in objectIndices) {
    @autorelease {
        LargeObject* obj = [self createLargeObjectWithIndex:index];
        //do something...
    }
}
則可使每一次迭代時建立的大型物件即刻釋放,避免暫時性的高記憶體使用量。

使用自動參考計數(ARC)

自動參考計數是編譯器的一項功能,能夠自動在程式碼中為物件呼叫入retain、release以及autorelease。
因此,很重要的,啟用自動參考計數功能時,程式設計師將不可以在程式中使用retain、release以及autorelease方法。
啟用/停用自動參考計數:

一旦啟用自動參考計數後,程式中若又使用retain、release以及autorelease方法,則將無法通過編譯。 NSAutoreleasePool類別也不可以使用,需要改用@autoreleasepool語法。

結語

使用自動參考計數已經是近期開發iOS應用程式的趨勢,程式設計師可以更專注在程式的商務邏輯、界面設計而無需再煩惱參考計數的撰寫是否正確,編譯器會做好大部份的工作。
XCode預設啟用自動參考計數,多數的第三方套件也支援自動參考計數。新進的iOS程式設計師在完全不了解參考計數如何運作的情況下開發iOS應用程式也是可能的。
然而即便如此,理解參考計數仍然是iOS應用程式開發者應當做足的功課之一,尤其是在記憶體空間有限的行動裝置上,理解系統、編譯器如何管理記憶體,才能撰寫出更高效率的應用程式。

Reference

1 ARC is supported in Xcode 4.2 for OS X v10.6 and v10.7 (64-bit applications) and for iOS 4 and iOS 5. See: Transitioning to ARC Release Notes
2 You own any object you create (You create an object using a method whose name begins with “alloc”, “new”, “copy”, or “mutableCopy”.) See: Memory Management Policy