【程式設計師的自我修養①】iOS記憶體管理

JoyZhang發表於2018-01-03

iOS記憶體管理

Objective-C 中的記憶體分配

在 Objective-C 中,物件通常是使用 alloc 方法在堆上建立的。 [NSObject alloc] 方法會在對堆上分配一塊記憶體,按照NSObject的內部結構填充這塊兒記憶體區域。

一旦物件建立完成,就不可能再移動它了。因為很可能有很多指標都指向這個物件,這些指標並沒有被追蹤。因此沒有辦法在移動物件的位置之後更新全部的這些指標。

MRC 與 ARC

Objective-C中提供了兩種記憶體管理機制:MRC(MannulReference Counting)和 ARC(Automatic Reference Counting),分別提供對記憶體的手動和自動管理,來滿足不同的需求。現在蘋果推薦使用 ARC 來進行記憶體管理。

MRC

物件操作的四個類別

物件操作 OC中對應的方法 對應的 retainCount 變化
生成並持有物件 alloc/new/copy/mutableCopy等 +1
持有物件 retain +1
釋放物件 release -1
廢棄物件 dealloc -

四個法則

  • 自己生成的物件,自己持有。
  • 非自己生成的物件,自己也能持有。
  • 不在需要自己持有物件的時候,釋放。
  • 非自己持有的物件無需釋放。

如下是四個黃金法則對應的程式碼示例:

/*
 * 自己生成並持有該物件
 */
 id obj0 = [[NSObeject alloc] init];
 id obj1 = [NSObeject new];
複製程式碼
 /*
 * 持有非自己生成的物件
 */
id obj = [NSArray array]; // 非自己生成的物件,且該物件存在,但自己不持有
[obj retain]; // 自己持有物件
複製程式碼
/*
 * 不在需要自己持有的物件的時候,釋放
 */
id obj = [[NSObeject alloc] init]; // 此時持有物件
[obj release]; // 釋放物件
/*
 * 指向物件的指標仍就被保留在obj這個變數中
 * 但物件已經釋放,不可訪問
 */ 
複製程式碼
/*
 * 非自己持有的物件無法釋放
 */
id obj = [NSArray array]; // 非自己生成的物件,且該物件存在,但自己不持有
[obj release]; // ~~~此時將執行時crash 或編譯器報error~~~ 非 ARC 下,呼叫該方法會導致編譯器報 issues。此操作的行為是未定義的,可能會導致執行時 crash 或者其它未知行為
複製程式碼

其中 非自己生成的物件,且該物件存在,但自己不持有 這個特性是使用autorelease來實現的,示例程式碼如下:

- (id) getAObjNotRetain {
    id obj = [[NSObject alloc] init]; // 自己持有物件
    [obj autorelease]; // 取得的物件存在,但自己不持有該物件
    return obj;
}
複製程式碼

autorelease 使得物件在超出生命週期後能正確的被釋放(通過呼叫release方法)。在呼叫 release 後,物件會被立即釋放,而呼叫 autorelease 後,物件不會被立即釋放,而是註冊到 autoreleasepool 中,經過一段時間後 pool 結束,此時呼叫 release 方法,物件被釋放。在沒有手加 Autorelease Pool 的情況下,Autorelease 物件是在當前的 runloop 迭代結束時釋放的,而它能夠釋放的原因是系統在每個 runloop 迭代中都加入了自動釋放池 PushPop(Page26 AutoreleasePoolPage)。

在MRC的記憶體管理模式下,與對變數的管理相關的方法有:retain, release 和 autorelease。retain 和 release 方法操作的是引用記數,當引用記數為零時,便自動釋放記憶體。並且可以用 NSAutoreleasePool 物件,對加入自動釋放池(autorelease 呼叫)的變數進行管理,當 drain 時回收記憶體。

思考:

  1. autorelease方法會不會影響引用計數?如果有,如何影響?
  2. oc中所有生成的物件都會加入到自動釋放池中管理麼?

回答:

  1. autorelease方法不會影響物件的引用計數,autorelease方法的實現是:
	- (id)autorelease {
		[NSAutoreleasePool addObject:self];
	}
複製程式碼

本文的下方也會對autoreleasepool如何釋放記憶體進行說明。

2. 答案明顯是不會。

ARC

ARC 是蘋果引入的一種自動記憶體管理機制,會根據引用計數自動監視物件的生存週期,實現方式是在編譯時期自動在已有程式碼中插入合適的記憶體管理程式碼以及在 Runtime 做一些優化。

變數識別符號

在ARC中與記憶體管理有關的變數識別符號,有下面幾種:

  • __strong
  • __weak
  • __unsafe_unretained
  • __autoreleasing

__strong 是預設使用的識別符號。只有還有一個強指標指向某個物件,這個物件就會一直存活。

__weak 宣告這個引用不會保持被引用物件的存活,如果物件沒有強引用了,弱引用會被置為 nil

__unsafe_unretained 宣告這個引用不會保持被引用物件的存活,如果物件沒有強引用了,它不會被置為 nil。如果它引用的物件被回收掉了,該指標就變成了野指標(懸垂指標)。

__autoreleasing 用於標示使用引用傳值的引數(id *),在函式返回時會被自動釋放掉。

變數識別符號的用法如下:

Number* __strong num = [[Number alloc] init]; 注意 __strong 的位置應該放到 * 和變數名中間,放到其他的位置嚴格意義上說是不正確的,只不過編譯器不會報錯。

屬性識別符號

類中的屬性也可以加上標誌符:

@property (assign/retain/strong/weak/unsafe_unretained/copy) Number* num
複製程式碼

assign 表明 setter 僅僅是一個簡單的賦值操作,通常用於基本的數值型別,例如CGFloatNSInteger

strong 表明屬性定義一個擁有者關係。當給屬性設定一個新值的時候,首先這個值進行 retain ,舊值進行 release ,然後進行賦值操作。

weak 表明屬性定義了一個非擁有者關係。當給屬性設定一個新值的時候,這個值不會進行 retain,舊值也不會進行 release, 而是進行類似 assign 的操作。不過當屬性指向的物件被銷燬時,該屬性會被置為nil。

unsafe_unretained 的語義和 assign 類似,不過是用於物件型別的,表示一個非擁有(unretained)的,同時也不會在物件被銷燬時置為nil的(unsafe)關係。

copy 類似於 strong,不過在賦值時進行 copy 操作而不是 retain 操作。通常在需要保留某個不可變物件(NSString最常見),並且防止它被意外改變時使用。

@interface ViewController ()

@property (nonatomic, copy) NSString *Str1;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view, typically from a nib.
    
    NSString *str2 = @"copyTest";
    self.Str1 = str2;
    
    str2 = @"changeTest";
    NSLog(@"%@", self.Str1);
}
複製程式碼

錯誤使用屬性識別符號的後果

如果我們給一個原始型別設定 strong\weak\copy ,編譯器會直接報錯:

Property with 'retain (or strong)' attribute must be of object type

設定為 unsafe_unretained 倒是可以通過編譯,只是用起來跟 assign 也沒有什麼區別。

反過來,我們給一個 NSObject 屬性設定為 assign,編譯器會報警:

Assigning retained object to unsafe property; object will be released after assignment

正如警告所說的,物件在賦值之後被立即釋放,對應的屬性也就成了野指標,執行時跑到屬性有關操作會直接崩潰掉。和設定成 unsafe_unretained 是一樣的效果(設定成 weak 不會崩潰)。

unsafe_unretained 的用處

unsafe_unretained 差不多是實際使用最少的一個識別符號了,在使用中它的用處主要有下面幾點:

  1. 相容性考慮。iOS4 以及之前還沒有引入 weak,這種情況想表達弱引用的語義只能使用 unsafe_unretained。這種情況現在已經很少見了。
  2. 效能考慮。使用 weak 對效能有一些影響,因此對效能要求高的地方可以考慮使用 unsafe_unretained 替換 weak。一個例子是 YYModel 的實現,為了追求更高的效能,其中大量使用 unsafe_unretained 作為變數識別符號。

引用迴圈

當兩個物件互相持有對方的強引用,並且這兩個物件的引用計數都不是0的時候,便造成了引用迴圈。

要想破除引用迴圈,可以從以下幾點入手:

  • 注意變數作用域,使用 autorelease 讓編譯器來處理引用
  • 使用弱引用(weak)
  • 當例項變數完成工作後,將其置為nil

Autorelease Pool

Autorelase Pool 提供了一種可以允許你向一個物件延遲傳送 release 訊息的機制。當你想放棄一個物件的所有權,同時又不希望這個物件立即被釋放掉(例如在一個方法中返回一個物件時),Autorelease Pool 的作用就顯現出來了。

所謂的延遲傳送 release 訊息指的是,當我們把一個物件標記為 autorelease 時:

NSString* str = [[[NSString alloc] initWithString:@"hello"] autorelease];
複製程式碼

這個物件的 retainCount 會+1,但是並不會發生 release。當這段語句所處的 autoreleasepool 進行 drain 操作時,所有標記了 autorelease 的物件的 retainCount 會被 -1。即 release 訊息的傳送被延遲到 pool 釋放的時候了。

在 ARC 環境下,蘋果引入了 @autoreleasepool 語法,不再需要手動呼叫 autorelease 和 drain 等方法。

Autorelease Pool 的用處

在 ARC 下,我們並不需要手動呼叫 autorelease 有關的方法,甚至可以完全不知道 autorelease 的存在,就可以正確管理好記憶體。因為 Cocoa Touch 的 Runloop 中,每個 runloop circle 中系統都自動加入了 Autorelease Pool 的建立和釋放。

當我們需要建立和銷燬大量的物件時,使用手動建立的 autoreleasepool 可以有效的避免記憶體峰值的出現。因為如果不手動建立的話,外層系統建立的 pool 會在整個 runloop circle 結束之後才進行 drain,手動建立的話,會在 block 結束之後就進行 drain 操作。詳情請參考蘋果官方文件。一個普遍被使用的例子如下:

for (int i = 0; i < 100000000; i++)
{
    @autoreleasepool
    {
        NSString* string = @"abc";
        NSArray* array = [string componentsSeparatedByString:string];
    }
}
複製程式碼

如果不使用 autoreleasepool ,需要在迴圈結束之後釋放 100000000 個字串,如果 使用的話,則會在每次迴圈結束的時候都進行 release 操作。

Autorelease Pool 進行 Drain 的時機

如上面所說,系統在 runloop 中建立的 autoreleaspool 會在 runloop 一個 event 結束時進行釋放操作。我們手動建立的 autoreleasepool 會在 block 執行完成之後進行 drain 操作。需要注意的是:

  • 當 block 以異常(exception)結束時,pool 不會被 drain
  • Pool 的 drain 操作會把所有標記為 autorelease 的物件的引用計數減一,但是並不意味著這個物件一定會被釋放掉,我們可以在 autorelease pool 中手動 retain 物件,以延長它的生命週期(在 MRC 中)。

main.m 中 Autorelease Pool 的解釋

大家都知道在 iOS 程式的 main.m 檔案中有這樣的語句:

int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}
複製程式碼

在面試中問到有關 autorelease pool 有關的知識也多半會問一下,這裡的 pool 有什麼作用,能不能去掉之類。在這裡我們分析一下。

根據蘋果官方文件, UIApplicationMain 函式是整個 app 的入口,用來建立 application 物件(單例)和 application delegate。儘管這個函式有返回值,但是實際上卻永遠不會返回,當按下 Home 鍵時,app 只是被切換到了後臺狀態。

同時參考蘋果關於 Lifecycle 的官方文件,UIApplication 自己會建立一個 main run loop,我們大致可以得到下面的結論:

  1. main.m 中的 UIApplicationMain 永遠不會返回,只有在系統 kill 掉整個 app 時,系統會把應用佔用的記憶體全部釋放出來;
  2. 因為(1), UIApplicationMain 永遠不會返回,這裡的 autorelease pool 也就永遠不會進入到釋放那個階段;
  3. 在 (2) 的基礎上,假設有些變數真的進入了 main.m 裡面這個 pool(沒有被更內層的 pool 捕獲),那麼這些變數實際上就是被洩露的。這個 autorelease pool 等於是把這種洩露情況給隱藏起來了;
  4. UIApplication 自己會建立 main run loop,在 Cocoa 的 runloop 中實際上也是自動包含 autorelease pool 的,因此 main.m 當中的 pool 可以認為是沒有必要的;

在基於 AppKit 框架的 Mac OS 開發中, main.m 當中就是不存在 autorelease pool 的,也進一步驗證了我們得到的結論。不過因為我們看不到更底層的程式碼,加上蘋果的文件中不建議修改 main.m ,所以我們也沒有理由就直接把它刪掉(親測,刪掉之後不影響 App 執行,用 Instruments 也看不到洩露)。

Autorelease Pool 與函式返回值

如果一個函式的返回值是指向一個物件的指標,那麼這個物件肯定不能在函式返回之前進行 release,這樣呼叫者在呼叫這個函式時得到的就是野指標了,在函式返回之後也不能立刻就 release,因為我們不知道呼叫者是不是 retain 了這個物件,如果我們直接 release 了,可能導致後面在使用這個物件時它已經成為 nil 了。

為了解決這個糾結的問題, Objective-C 中對物件指標的返回值進行了區分,一種叫做 retained return value,另一種叫做 unretained return value。前者表示呼叫者擁有這個返回值,後者表示呼叫者不擁有這個返回值,按照“誰擁有誰釋放”的原則,對於前者呼叫者是要負責釋放的,對於後者就不需要了。

按照蘋果的命名 convention,以 alloc, copy, init, mutableCopynew 這些方法打頭的方法,返回的都是 retained return value,例如 [[NSString alloc] initWithFormat:],而其他的則是 unretained return value,例如 [NSString stringWithFormat:]。我們在編寫程式碼時也應該遵守這個 convention。

我們分別在 MRC 和 ARC 情況下,分析一下兩種返回值型別的區別。

MRC

在 MRC 中我們需要關注這兩種函式返回型別的區別,否則可能會導致記憶體洩露。

對於 retained return value,需要負責釋放

假設我們有一個 property 定義如下:

@property (nonatomic, retain) NSObject *property;
複製程式碼

在對其賦值的時候,我們應該使用:

self.property = [[[NSObject alloc] init] autorelease];
複製程式碼

然後在 dealloc 方法中加入:

[_property release];
_property = nil;
複製程式碼

這樣記憶體的情況大體是這樣的:

	1. init 把 retain count 增加到 1
	2. 賦值給 self.property ,把 retain count 增加到 2
	3. 當 runloop circle 結束時,autorelease pool 執行 drain,把 retain count 減為 1
	4. 當整個物件執行 dealloc 時, release 把 retain count 減為 0,物件被釋放
	5. 可以看到沒有記憶體洩露發生。

複製程式碼

如果我們只是使用:

self.property = [[NSObject alloc] init];
複製程式碼

這一條語句會導致 retain count 增加到 2,而我們少執行了一次 release,就會導致 retain count 不能被減為 0 。

另外,我們也可以使用臨時變數:

NSObject * a = [[NSObject alloc] init];
self.property = a;
[a release];
複製程式碼

這種情況,因為對 a 執行了一次 release,所有不會出現上面那種 retain count 不能減為 0 的情況。

注意:現在大家基本都是 ARC 寫的比較多,會忽略這一點,但是根據上面的內容,我們看到在 MRC 中直接對 self.proprety 賦值和先賦給臨時變數,再賦值給 self.property,確實是有區別的!

我們在編寫自己的程式碼時,也應該遵守上面的原則,對工廠方法方法 使用 autorelease

// 注意函式名的區別
+ (MyCustomClass *) myCustomClass {
    return [[[MyCustomClass alloc] init] autorelease]; // 需要 autorelease
}

- (MyCustomClass *) initWithName:(NSString *) name {
	// 不需要 autorelease
    return [[MyCustomClass alloc] init]; // 對於 unretained return value,不需要負責釋放
}
複製程式碼

當我們呼叫非 alloc,init 系的方法來初始化物件時(通常是工廠方法),我們不需要負責變數的釋放,可以當成普通的臨時變數來使用:

NSString *name = [NSString stringWithFormat:@"%@ %@", firstName, lastName];
self.name = name
// 不需要執行 [name release]
複製程式碼

ARC

在 ARC 中我們完全不需要考慮這兩種返回值型別的區別,ARC 會自動加入必要的程式碼,因此我們可以放心大膽地寫:

self.property = [[NSObject alloc] init];
self.name = [NSString stringWithFormat:@"%@ %@", firstName, lastName];
複製程式碼

以及在自己寫的函式中:

+ (MyCustomClass *) myCustomClass {
    return [[MyCustomClass alloc] init]; // 不用 autorelease
}
複製程式碼

對於 retained return value, Clang 是這樣做的:

When returning from such a function or method, ARC retains the value at the point of evaluation of the return statement, before leaving all local scopes.

When receiving a return result from such a function or method, ARC releases the value at the end of the full-expression it is contained within, subject to the usual optimizations for local values.

表明基本上 ARC 就是幫我們在程式碼塊結束的時候進行了 release:

NSObject * a = [[NSObject alloc] init];
self.property = a;
//[a release]; 我們不需要寫這一句,因為 ARC 會幫我們把這一句加上
複製程式碼

對於 unretained return value:

When returning from such a function or method, ARC retains the value at the point of evaluation of the return statement, then leaves all local scopes, and then balances out the retain while ensuring that the value lives across the call boundary. In the worst case, this may involve an autorelease, but callers must not assume that the value is actually in the autorelease pool.

ARC performs no extra mandatory work on the caller side, although it may elect to do something to shorten the lifetime of the returned value.

這個和我們之前在 MRC 中做的不是完全一樣。ARC 會把物件的生命週期延長,確保呼叫者能拿到並且使用這個返回值,但是並不一定會使用 autorelease,文件寫的是在 worst case 的情況下才可能會使用,因此呼叫者不能假設返回值真的就在 autorelease pool 中。從效能的角度,這種做法也是可以理解的。如果我們能夠知道一個物件的生命週期最長應該有多長,也就沒有必要使用 autorelease 了,直接使用 release 就可以。如果很多物件都使用 autorelease 的話,也會導致整個 pool 在 drain 的時候效能下降。

相關文章