- objc_autoreleaseReturnValue和objc_retainAutoreleasedReturnValue
- 內(nèi)存分區(qū)和tagged Pointer
首先看看一下3段代碼:
- (void)test1
{
for (int i = 0; i < 10000000; ++i) {
NSString *str = [NSString stringWithFormat:@"111111111111"];
}
}
- (void)test2
{
for (int i = 0; i < 10000000; ++i) {
NSString *str = [[NSString alloc] initWithFormat:@"111111111111"];
}
}
- (void)test3
{
NSString *str1 = [NSString stringWithFormat:@"111111111111"];
NSString *str2 =[NSString stringWithFormat:@"1"];
NSString *str3 = [[NSString alloc] initWithFormat:@"1"];
NSString *str4 = @"1";
NSString *str5 = @"111111111111";
NSLog(@"%p, %p, %p, %p, %p",str1, str2, str3, str4, str5);
/// 打印結果:0x28182ad60, 0xb04824e7f56e63bc, 0xb04824e7f56e63bc, 0x10009c0d0, 0x10009c090
}
1. objc_autoreleaseReturnValue和objc_retainAutoreleasedReturnValue
分別運行前兩段代碼,會發(fā)現(xiàn)方法test1內(nèi)存會不斷增加,而方法test2內(nèi)存幾乎沒有變化。會產(chǎn)生這種區(qū)別的原因是initWithFormat和stringWithFormat內(nèi)部實現(xiàn)有一點區(qū)別。
首先是實例方法創(chuàng)建對象:
NSString *str = [[NSString alloc] initWithFormat:@"111111111111"];
上面調(diào)用實例方法模擬轉換為:
{
/*編譯器的模擬代碼*/
id str = objc_msgSend(NSString,@selector(alloc));
objc_msgSend(str,@selector(initWithFormat:), @"111111111111");
objc_release(str);
}
然后類方法對象:
NSString *str = [NSString stringWithFormat:@"111111111111"];
上面調(diào)用類方法模擬轉換為:
{
/*編譯器的模擬代碼*/
id str = objc_msgSend(NSString,@selector(stringWithFormat:), @"111111111111");
objc_retainAutoreleasedReturnValue(str);
objc_release(str);
}
而NSString的stringWithFormat方法編譯器實現(xiàn)
+ (instancetype)stringWithFormat:(NSString *)format
{
return [[NSString alloc] initWithFormat:format];
}
上面又可以模擬轉換為:
+ (instancetype)stringWithFormat:(NSString *)format
{
/*編譯器的模擬代碼*/
id obj = objc_msgSend(NSString,@selector(alloc));
objc_msgSend(obj,@selector(initWithFormat:), @"111111111111");
return objc_autoreleaseReturnValue(obj);
}
類方法和實例方法創(chuàng)建方式上差別就在多了objc_retainAutoreleasedReturnValue函數(shù)和objc_autoreleasedReturnValue函數(shù);在使用非alloc/new/copy/mutableCopy等開頭生成對象的方法,都會調(diào)用這兩個函數(shù)。兩個函數(shù)實現(xiàn)大致如下:
id objc_autoreleaseReturnValue(id obj) {
if ( prepareOptimizedReturn(ReturnAtPlus1)) {
return obj;
} else {
return objc_autorelease(obj);
}
}
// Try to prepare for optimized return with the given disposition (+0 or +1).
// Returns true if the optimized path is successful.
// Otherwise the return value must be retained and/or autoreleased as usual.
static ALWAYS_INLINE bool
prepareOptimizedReturn(ReturnDisposition disposition)
{
ASSERT(getReturnDisposition() == ReturnAtPlus0);
if (callerAcceptsOptimizedReturn(__builtin_return_address(0))) {
if (disposition) setReturnDisposition(disposition);
return true;
}
return false;
}
這里的ReturnAtPlus0是一個枚舉
enum ReturnDisposition : bool {
ReturnAtPlus0 = false, ReturnAtPlus1 = true
};
static ALWAYS_INLINE ReturnDisposition
getReturnDisposition()
{
return (ReturnDisposition)(uintptr_t)tls_get_direct(RETURN_DISPOSITION_KEY);
}
static ALWAYS_INLINE void
setReturnDisposition(ReturnDisposition disposition)
{
tls_set_direct(RETURN_DISPOSITION_KEY, (void*)(uintptr_t)disposition);
}
id
objc_retainAutoreleasedReturnValue(id obj)
{
if (acceptOptimizedReturn() == ReturnAtPlus1) {
return obj;
}
return objc_retain(obj);
}
static ALWAYS_INLINE ReturnDisposition
acceptOptimizedReturn()
{
ReturnDisposition disposition = getReturnDisposition();
setReturnDisposition(ReturnAtPlus0); // reset to the unoptimized state
return disposition;
}
在ARC中原本對象生成之后是要注冊到autoreleasepool中。但非alloc/copy/mutableCopy/new開頭的方法,使用了objc_autoreleseReturnValue函數(shù)返回注冊到autorelease中的對象。它會檢查使用該函數(shù)的方法或函數(shù)調(diào)用方執(zhí)行命令列表,如果緊接著調(diào)用了方法或函數(shù)后緊接著調(diào)用objc_retainAutoreleasedReturnValue()函數(shù),兩者成對出現(xiàn),編譯器會做優(yōu)化,使函數(shù)最優(yōu)化程序運行,不將返回的對象注冊到autoreleasePool中,會先存儲在TLS(Thread Local Storage)中, 然后外部接收方調(diào)用objc_retainAutoreleasedReturnValue時, 發(fā)現(xiàn)TLS中正好存了這個對象便直接返回這個對象,最后直接傳遞到方法或函數(shù)的調(diào)用方,省略注冊到釋放池的過程,達到了即使對象不注冊到autoreleasepool中,也可以返回拿到相應的對象。
結論
調(diào)用類方法stringWithFormat會出現(xiàn)內(nèi)存暴增的現(xiàn)象,正是由于stringWithFormat會調(diào)用這兩個函數(shù),并在內(nèi)部對創(chuàng)建的字符串做一次autorelease處理,這就導致了對象的延遲釋放,因為這里有個for循環(huán),那么autoreleasepool會等到runloop的當前循環(huán)結束后才會對釋放池中的每個對象發(fā)送release消息,而runloop的當前循環(huán)結束的前提是要等for循環(huán)執(zhí)行完,所以for循環(huán)內(nèi)創(chuàng)建的對象就會在for循環(huán)執(zhí)行完之前一直存在在內(nèi)存中,導致暴增。
而調(diào)用實例方法initWithFormat不會出現(xiàn)內(nèi)存暴增的現(xiàn)象,是因為創(chuàng)建對象是在ARC模式中,for的單次循環(huán)結束后,當前作用域已結束,就會release一次并被釋放(MRC下手動管理)。
如果要解決stringWithFormat內(nèi)存暴增現(xiàn)象,可以將代碼手動添加@autoreleasepool中,這樣每次循環(huán)結束都會release一次并自動釋放。
for (int i = 0; i < 10000000; ++i) {
@autoreleasepool {
NSString *str = [NSString stringWithFormat:@"111111111111"];
}
}
2. 內(nèi)存分區(qū)和Tagged Pointer
最上面第三段代碼中5個字符串打印的地址為什么會有如此大的差異呢?
NSString *str1 = [NSString stringWithFormat:@"111111111111"];
NSString *str2 =[NSString stringWithFormat:@"1"];
NSString *str3 = [[NSString alloc] initWithFormat:@"1"];
NSString *str4 = @"1";
NSString *str5 = @"111111111111";
打印結果:0x28182ad60, 0xb04824e7f56e63bc, 0xb04824e7f56e63bc, 0x10009c0d0, 0x10009c090
先說結論,后面再講分區(qū)和tagged Pointer
str1是在堆區(qū);占用內(nèi)存較大的字符串、且通過類生成的,不能再編譯階段確定,會放到堆區(qū)中,如果重復使用類初始化多個相同的字符串,但地址不一定相同。
str2和str3是tagged Pointer;占用內(nèi)存較小的字符串、且通過類生成的,會用tagged Pointer技術存儲,具體可以看下面。
str4和str5是在常量區(qū);在使用字面量聲明字符串的時候,在編譯階段就已經(jīng)確定,字符串是放在常量區(qū)的,所以相同變量無論聲明多少都是引用常量區(qū)里面的同一個字符串。
2.1 內(nèi)存分區(qū)
內(nèi)存的布局如下:

- bss段( bss segment、 全局區(qū))
bss段通常是指用來存放程序中未初始化的全局變量和靜態(tài)變量的一塊內(nèi)存區(qū)域。
通常來說如果不初始化全局變量和靜態(tài)變量,編譯器也會對它們進行一個隱式初始化(直接賦值就是顯示初始化),賦給它們一個缺省值,是我們這里所說的未初始化。
BSS段在程序執(zhí)行之前會清0,所以未初始化的全局變量(靜態(tài)變量)已經(jīng)是0了。所以這種情況還是存放在BSS段,一旦初始化就會從BSS段中回收掉,轉存到data段(數(shù)據(jù)段)中。
bss區(qū)-Block Started by Symbol(未初始化數(shù)據(jù)段):并不給該段的數(shù)據(jù)分配空間,僅僅是記錄了數(shù)據(jù)所需空間的大小。 - 數(shù)據(jù)段(data segment、常量區(qū))
數(shù)據(jù)段分為只讀數(shù)據(jù)段(常量區(qū)) 和 讀寫數(shù)據(jù)段
通常是指用來存放程序中已經(jīng)初始化的全局變量和靜態(tài)變量的一塊內(nèi)存區(qū)域。數(shù)據(jù)段屬于靜態(tài)內(nèi)存分配,可以分為只讀數(shù)據(jù)段和讀寫數(shù)據(jù)段。字符串常量等,是放在只讀數(shù)據(jù)段中,結束程序時才會被收回。 - 代碼段(code segment/text segment、代碼區(qū))
通常是指用來存放程序執(zhí)行代碼的一塊內(nèi)存區(qū)域。這部分區(qū)域的大小在程序運行前就已經(jīng)確定,并且內(nèi)存區(qū)域通常屬于只讀, 某些架構也允許代碼段為可寫,即允許修改程序。在代碼段中,也有可能包含一些只讀的常數(shù)變量,例如字符串常量等,這些常量放在只讀數(shù)據(jù)段(data segment)中,也有叫做常量區(qū)的說法。 - 堆(heap)
堆是用于存放進程運行中被動態(tài)分配的內(nèi)存段,它的大小并不固定,可動態(tài)擴張或縮減。當進程調(diào)用malloc等函數(shù)分配內(nèi)存時,新分配的內(nèi)存就被動態(tài)添加到堆上(堆被擴張); 當利用free等函數(shù)釋放內(nèi)存時,被釋放的內(nèi)存從堆中被剔除(堆被縮減)
堆向高地址擴展的數(shù)據(jù)結構,是不連續(xù)的內(nèi)存區(qū)域。程序員負責在何時釋放內(nèi)存(如用free或delete),在iOS的ARC程序中,系統(tǒng)自動管理計數(shù)器,計數(shù)器為0的時候,在當次的runloop結束后,釋放掉內(nèi)存。堆中的所有東西都是匿名的,這樣不能按名字訪問,而只能通過指針訪問。
對于堆來講,頻繁的new/delete勢必會造成內(nèi)存空間的不連續(xù)性,從而造成大量的碎片 ,使程序效率降低。 - 棧 (stack)
棧又稱堆棧, 是用戶存放程序臨時創(chuàng)建的局部變量,也就是說我們函數(shù)括弧“{}” 中定義的變量(但不包括static聲明的變量,static意味著在數(shù)據(jù)段中存放變量)。除此以外, 在函數(shù)被調(diào)用時,其參數(shù)也會被壓入發(fā)起調(diào)用的進程棧中,并且待到調(diào)用結束后,函數(shù)的返回值 也會被存放回棧中。由于棧的后進先出特點,所以 棧特別方便用來保存/恢復調(diào)用現(xiàn)場。從這個意義上講,我們可以把堆??闯梢粋€寄存、交換臨時數(shù)據(jù)的內(nèi)存區(qū)。
指針都存在棧區(qū),用于指向分配在堆區(qū)的內(nèi)存的地址(待驗證)。
2.2 Tagged Pointer
在 2013 年 9 月,蘋果推出了 iPhone5s,與此同時,iPhone5s 配備了首個采用 64 位架構的 A7 雙核處理器,為了節(jié)省內(nèi)存和提高執(zhí)行效率,蘋果提出了Tagged Pointer的概念。對于 64 位程序,引入 Tagged Pointer 后,相關邏輯能減少一半的內(nèi)存占用,以及 3 倍的訪問速度提升,100 倍的創(chuàng)建、銷毀速度提升。本文從Tagged Pointer試圖解決的問題入手,帶領讀者理解Tagged Pointer的實現(xiàn)細節(jié)和優(yōu)勢,最后指出了使用時的注意事項。
我們先看看原有的對象為什么會浪費內(nèi)存。假設我們要存儲一個 NSNumber 對象,其值是一個整數(shù)。正常情況下,如果這個整數(shù)只是一個 NSInteger 的普通變量,那么它所占用的內(nèi)存是與 CPU 的位數(shù)有關,在 32 位 CPU 下占 4 個字節(jié),在 64 位 CPU 下是占 8 個字節(jié)的。而指針類型的大小通常也是與 CPU 位數(shù)相關,一個指針所占用的內(nèi)存在 32 位 CPU 下為 4 個字節(jié),在 64 位 CPU 下也是 8 個字節(jié)。
所以一個普通的 iOS 程序,如果沒有Tagged Pointer對象,從 32 位機器遷移到 64 位機器中后,雖然邏輯沒有任何變化,但這種 NSNumber、NSDate 一類的對象所占用的內(nèi)存會翻倍。如下圖所示:

我們再來看看效率上的問題,為了存儲和訪問一個 NSNumber 對象,我們需要在堆上為其分配內(nèi)存,另外還要維護它的引用計數(shù),管理它的生命期。這些都給程序增加了額外的邏輯,造成運行效率上的損失。
為了改進上面提到的內(nèi)存占用和效率問題,蘋果提出了Tagged Pointer對象。由于 NSNumber、NSDate 一類的變量本身的值需要占用的內(nèi)存大小常常不需要 8 個字節(jié),拿整數(shù)來說,4 個字節(jié)所能表示的有符號整數(shù)就可以達到 20 多億(注:2^31=2147483648,另外 1 位作為符號位),對于絕大多數(shù)情況都是可以處理的。
所以我們可以將一個對象的指針拆成兩部分,一部分直接保存數(shù)據(jù),另一部分作為特殊標記,表示這是一個特別的指針,不指向任何一個地址。所以,引入了Tagged Pointer對象之后,64 位 CPU 下 NSNumber 的內(nèi)存圖變成了以下這樣:

我們也可以在 WWDC2013 的《Session 404 Advanced in Objective-C》視頻中,看到蘋果對于Tagged Pointer特點的介紹:
- Tagged Pointer專門用來存儲小的對象,例如NSNumber和NSDate
- Tagged Pointer指針的值不再是地址了,而是真正的值。所以,實際上它不再是一個對象了,它只是一個披著對象皮的普通變量而已。所以,它的內(nèi)存并不存儲在堆中,也不需要 malloc 和 free。
- 在內(nèi)存讀取上有著 3 倍的效率,創(chuàng)建時比以前快 106 倍。
由此可見,蘋果引入Tagged Pointer,不但減少了 64 位機器下程序的內(nèi)存占用,還提高了運行效率。完美地解決了小內(nèi)存對象在存儲和訪問效率上的問題。
Tagged Pointer的引入也帶來了問題,即Tagged Pointer因為并不是真正的對象,而是一個偽對象,所以你如果完全把它當成對象來使,可能會讓它露馬腳。唐巧大佬在 《Objective-C 對象模型及應用》 中就寫道,所有對象都有 isa 指針,而Tagged Pointer其實是沒有的,因為它不是真正的對象。
因為不是真正的對象,所以不是正常直接訪問Tagged Pointer的isa成員。應該換成相應的方法調(diào)用,如 isKindOfClass 和 object_getClass。只要避免在代碼中直接訪問對象的 isa 變量,即可避免這個問題。