NSString內(nèi)存相關(guān),從一個(gè)小demo說(shuō)起

timg1.jpg

首先,我們看下第一段代碼:

- (void)stringTest {
    NSString *string1 = @"string";
    NSString *string2 = [NSString stringWithString:@"string"];
    NSString *string3 = [[NSString alloc] initWithString:@"string"];
    NSString *string4 = [NSString stringWithFormat:@"string"];
    NSString *string5 = [[NSString alloc] initWithFormat:@"string"];
    NSLog(@"1----%p",string1);
    NSLog(@"2----%p",string2);
    NSLog(@"3----%p",string3);
    NSLog(@"4----%p",string4);
    NSLog(@"5----%p",string5);
}

2018-04-27 17:36:17.419547+0800 StringTest[19381:2393524] 1----0x10dd070e8
2018-04-27 17:36:17.419789+0800 StringTest[19381:2393524] 2----0x10dd070e8
2018-04-27 17:36:17.420025+0800 StringTest[19381:2393524] 3----0x10dd070e8
2018-04-27 17:36:17.420166+0800 StringTest[19381:2393524] 4----0xa00676e697274736
2018-04-27 17:36:17.420258+0800 StringTest[19381:2393524] 5----0xa00676e697274736

這里分別用了不同方法創(chuàng)建了NSString對(duì)象
哪它們到底有什么區(qū)別呢?我們先看看存儲(chǔ)區(qū)域的劃分:

存儲(chǔ)區(qū)域

  • 棧區(qū)(stack)
       由編譯器自動(dòng)分配釋放 ,存放函數(shù)的參數(shù)值,局部變量的值等。其操作方式類(lèi)似于數(shù)據(jù)結(jié)構(gòu)中的棧。
  • 堆區(qū)(heap)
       一般由程序員分配釋放, 若程序員不釋放,程序結(jié)束時(shí)可能由OS回收 。與數(shù)據(jù)結(jié)構(gòu)中的堆是兩回事,分配方式倒是類(lèi)似于鏈表。
  • 全局區(qū)(靜態(tài)區(qū))(static)
       全局變量和靜態(tài)變量的存儲(chǔ)是放在一塊的(全局變量就是采取靜態(tài)存儲(chǔ)方式的),初始化的全局變量和靜態(tài)變量在一塊區(qū)域,未初始化的全局變量和未初始化的靜態(tài)變量在相鄰的另
    一塊區(qū)域, 程序結(jié)束后由系統(tǒng)釋放。
  • 文字常量區(qū)
       常量字符串就是放在這里的,程序結(jié)束后由系統(tǒng)釋放。
  • 代碼區(qū)
       存放函數(shù)體的二進(jìn)制代碼。
內(nèi)存分區(qū)
  1. string1通過(guò)字面量創(chuàng)建,它是一個(gè)常量存儲(chǔ)在常量區(qū)。如果其它對(duì)象存儲(chǔ)的內(nèi)容一樣,則指針指向相同的地址。不會(huì)初始化內(nèi)存空間,所以使用結(jié)束后不會(huì)釋放內(nèi)存。
  2. string2通過(guò)類(lèi)方法初始化string創(chuàng)建,是通過(guò)copy @"string"返回一個(gè)字符串,且這個(gè)copy是淺拷貝,會(huì)指向同一塊地址。其實(shí)就是相當(dāng)于字面量創(chuàng)建的方式,完全是多余的,所以這段代碼會(huì)有警告:Using 'initWithString:' with a literal is redundant。
  3. string3通過(guò)實(shí)例方法初始化string創(chuàng)建,和string2類(lèi)似:相當(dāng)于字面量創(chuàng)建,也會(huì)有警告。
  4. string4通過(guò)類(lèi)方法初始化format創(chuàng)建,需要初始化一段動(dòng)態(tài)內(nèi)存空間,存儲(chǔ)在堆中,使用結(jié)束后需釋放內(nèi)存。
  5. string5通過(guò)實(shí)例方法初始化format創(chuàng)建,和string4類(lèi)似。

initWith...和stringWith...這兩種實(shí)例方法和類(lèi)方法的內(nèi)存分配情況都是一樣的,但內(nèi)存釋放卻又有區(qū)別。什么區(qū)別呢,我們繼續(xù)看第二段代碼:

@interface ViewController ()

@property (nonatomic, weak) NSString *string1;
@property (nonatomic, weak) NSString *string2;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    [self stringTest];
    NSLog(@"%@",_string1);
    NSLog(@"%@",_string2);
}

- (void)stringTest {
    NSString *string1 = [NSString stringWithFormat:@"string string1"];
    NSString *string2 = [[NSString alloc] initWithFormat:@"string string2"];
    self.string1 = string1;
    self.string2 = string2;
}

@end

這里兩個(gè)string屬性都用了weak修飾而沒(méi)有使用strong,這樣就可以通過(guò)打印它們的值分析string1,string2內(nèi)存釋放的情況了。一般情況下,只在需要避免循環(huán)引用時(shí)使用weak修飾符。runtime 對(duì)注冊(cè)的類(lèi), 會(huì)進(jìn)行布局,對(duì)于 weak 對(duì)象會(huì)放入一個(gè) hash 表中。 用 weak 指向的對(duì)象內(nèi)存地址作為 key,當(dāng)此對(duì)象的引用計(jì)數(shù)為0的時(shí)候會(huì) dealloc。因此weak修飾的變量所引用對(duì)象被廢棄時(shí)會(huì)經(jīng)過(guò)以下步驟:

1.從weak表中獲取廢棄對(duì)象的地址為鍵值的記錄。
2.將包含在記錄中的weak修飾符變量的地址賦值為nil.
3.從weak表中刪除該記錄。
4.從引用計(jì)數(shù)表中刪除廢棄對(duì)象的地址為鍵值的記錄。

這會(huì)消耗相應(yīng)的CPU資源。這里加weak只是為了方便分析。
這段代碼的輸出結(jié)果是什么呢?
由于_string1和_string2都是弱引用,ARC下string1, string2對(duì)象在調(diào)用stringTest方法后,出了作用域后就銷(xiāo)毀了;你可能會(huì)不假思索的回答:結(jié)果都為null,但我們看下實(shí)際情況:

2018-04-28 11:36:20.445170+0800 StringTest[22110:2816244] string string1
2018-04-28 11:36:20.445294+0800 StringTest[22110:2816244] (null)

為什么string1還會(huì)有值呢?string1引用的對(duì)象在方法作用域外不是應(yīng)該銷(xiāo)毀了嗎?其實(shí)這里涉及到iOS內(nèi)存管理的另外一個(gè)知識(shí)點(diǎn):自動(dòng)釋放池autoreleasepool。

autorelease

我們知道autorelease是一種內(nèi)存自動(dòng)回收機(jī)制,autorelease的對(duì)象會(huì)被添加到autoreleasepool。autoreleasepool中的對(duì)象不會(huì)馬上release。在正常情況下,創(chuàng)建的對(duì)象會(huì)在超出其作用域的時(shí)候release,但是如果將對(duì)象加入autoreleasepool,那么該對(duì)象會(huì)等到autoreleasepool銷(xiāo)毀的時(shí)候再釋放,這使得對(duì)象超出其指定的生存范圍時(shí)能夠自動(dòng)并正確地釋放。通過(guò)類(lèi)似+ (instancetype)stringWithFormat:(NSString *)string方法創(chuàng)建的string1對(duì)象,就是被添加到了autoreleasepool中是一個(gè)autorelease對(duì)象。因?yàn)樵谖覀儧](méi)有手動(dòng)加autoreleasepool的情況下,autorelease對(duì)象要在當(dāng)前的runloop迭代結(jié)束時(shí)才廢棄的,也就是說(shuō)string1要在循環(huán)結(jié)束后才釋放(因?yàn)閞unloop每次循環(huán)過(guò)程中autoreleasepool被生成或廢棄)
因此,我們有兩種方式改寫(xiě)代碼,讓string1釋放:
第一種方式:手動(dòng)加@autoreleasepool,在@autoreleasepool {}后對(duì)象就會(huì)釋放了

- (void)viewDidLoad {
    [super viewDidLoad];
    @autoreleasepool {
        [self stringTest];
    }
    NSLog(@"%@",_string1);
    NSLog(@"%@",_string2);
}

第二種方式:模擬runloop迭代

- (void)viewDidLoad {
    [super viewDidLoad];
    [self stringTest];
    dispatch_async(dispatch_get_main_queue(), ^{
        NSLog(@"%@",_string1);
        NSLog(@"%@",_string2);
    });
}

打印結(jié)果都是一樣的:

2018-04-28 14:18:10.769891+0800 StringTest[22742:2893498] (null)
2018-04-28 14:18:10.770081+0800 StringTest[22742:2893498] (null)

現(xiàn)在我們大致知道了,stringWithFormat創(chuàng)建的對(duì)象會(huì)被添加到自動(dòng)釋放池是自動(dòng)釋放對(duì)象,而initWithFormat創(chuàng)建的對(duì)象不會(huì)被添加到自動(dòng)釋放池。不只是NSString類(lèi),其他類(lèi)都是如此的。ARC下生成的對(duì)象不能調(diào)用autorelease,ARC下為了區(qū)分生成的對(duì)象是不是autorelease,就確立了硬性規(guī)則。這些規(guī)則簡(jiǎn)單地體現(xiàn)在了方法名上。
使用alloc/new/copy/mutableCopy名稱開(kāi)頭的方法意味著生成的對(duì)象調(diào)用者持有,這些自己生成并持有的對(duì)象通過(guò)release釋放(額外說(shuō)下,這也是聲明一個(gè)new前綴的屬性編譯會(huì)報(bào)錯(cuò)的原因,因?yàn)檫@個(gè)屬性的getter方法以new開(kāi)頭,按照硬性規(guī)則內(nèi)存可能就不對(duì)了)。而其他名稱類(lèi)似string array dictionary的方法生成對(duì)象,不歸調(diào)用者持有,這種情況下,對(duì)象是自動(dòng)釋放。也就是說(shuō)

  NSString *string1 = [NSString stringWithFormat:@"string string1"];

等同于

    NSString *string1 = [[[NSString alloc] initWithFormat:@"string string1"] autorelease];

眼尖的小伙伴肯定注意到了,上面的第二段代碼中string1和string2初始化的值分別是@"string string1",@"string string2",這個(gè)是我特意這樣寫(xiě)的為的是字符串的長(zhǎng)度。那這個(gè)長(zhǎng)度對(duì)打印的結(jié)果有什么影響呢?把第二段代碼改下:

- (void)viewDidLoad {
    [super viewDidLoad];
    [self stringTest];
    NSLog(@"%@",_string1);
    NSLog(@"%@",_string2);
}

- (void)stringTest {
    NSString *string1 = [NSString stringWithFormat:@"string1"];
    NSString *string2 = [[NSString alloc] initWithFormat:@"string2"];
    self.string1 = string1;
    self.string2 = string2;
}

2018-04-28 16:10:17.572271+0800 StringTest[23270:2961270] string1
2018-04-28 16:10:17.572456+0800 StringTest[23270:2961270] string2

有沒(méi)有發(fā)現(xiàn),string2也沒(méi)有釋放了。剛才不是說(shuō)了init創(chuàng)建的對(duì)象不會(huì)添加到autoreleasepool,怎么改了字符串值就沒(méi)有釋放了呢?這個(gè)其實(shí)已經(jīng)與autoreleasepool沒(méi)有關(guān)系了,我們可以試下像之前一樣手動(dòng)加@autoreleasepool但結(jié)果并不會(huì)改變。這里又涉及到另個(gè)一知識(shí)點(diǎn)了:

Tagged Pointer

我們通過(guò)lldb看下string1的信息:

(lldb) p string1
(NSTaggedPointerString *) $1 = 0xa31676e697274737 @"string1"

可以看到NSTaggedPointerString這樣奇怪的類(lèi),它就是Tagged Pointer對(duì)象。那它時(shí)干什么的呢?
假設(shè)要存儲(chǔ)一個(gè)NSNumber對(duì)象,其值是一個(gè)整數(shù)。正常情況下,如果這個(gè)整數(shù)只是一個(gè)NSInteger的普通變量,那么它所占用的內(nèi)存是與CPU的位數(shù)有關(guān),在32位CPU下占4個(gè)字節(jié),在64位CPU下是占8個(gè)字節(jié)的。而指針類(lèi)型的大小通常也是與CPU位數(shù)相關(guān),一個(gè)指針?biāo)加玫膬?nèi)存在32位CPU下為4個(gè)字節(jié),在64位CPU下也是8個(gè)字節(jié)。所以一個(gè)普通的iOS程序,從32位機(jī)器遷移到64位機(jī)器中后,雖然邏輯沒(méi)有任何變化,但這種NSNumber、NSDate一類(lèi)的對(duì)象所占用的內(nèi)存會(huì)翻倍。而且為了存儲(chǔ)和訪問(wèn)一個(gè)NSNumber對(duì)象,我們需要在堆上為其分配內(nèi)存,另外還要維護(hù)它的引用計(jì)數(shù),管理它的生命期。這些都給程序增加了額外的邏輯,造成運(yùn)行效率上的損失。


大神的圖

為了改進(jìn)上面提到的內(nèi)存占用和效率問(wèn)題,蘋(píng)果提出了Tagged Pointer對(duì)象。由于NSNumber、NSDate一類(lèi)的變量本身的值需要占用的內(nèi)存大小常常不需要8個(gè)字節(jié),拿整數(shù)來(lái)說(shuō),4個(gè)字節(jié)所能表示的有符號(hào)整數(shù)就可以達(dá)到21億多(2^31)。所以我們可以將一個(gè)對(duì)象的指針拆成兩部分,一部分直接保存數(shù)據(jù),另一部分作為特殊標(biāo)記,表示這是一個(gè)特別的指針,不指向任何一個(gè)地址。所以,實(shí)際上它不再是一個(gè)對(duì)象了,它只是披著對(duì)象皮的普通變量而已。所以,它的內(nèi)存并不存儲(chǔ)在堆中,也不需要malloc和free。假設(shè)你調(diào)用NSNumber的integerValue,它將從數(shù)據(jù)部分中提取數(shù)值并返回。這樣,每訪問(wèn)一個(gè)對(duì)象,就省下了一次真正對(duì)象的內(nèi)存分配,省下了一次間接取值的時(shí)間。同時(shí)引用計(jì)數(shù)可以是空指令,因?yàn)闆](méi)有內(nèi)存需要釋放。對(duì)于常用的類(lèi),這將是一個(gè)巨大的性能提升。引入Tagged Pointer對(duì)象后,64位CPU下NSNumber內(nèi)存圖如下:


大神的圖

寫(xiě)下示例代碼:
- (void)numberTest {
    NSNumber *number = [NSNumber numberWithInt:1];
    NSLog(@"%p",number);
}

StringTest[1309:126876] 0xb000000000000012

這里的指針地址中包含了對(duì)象特殊標(biāo)記及指針指向的內(nèi)容:地址0xb000000000000012最高四位的b就是NSNumber對(duì)象特殊標(biāo)記,最低四位的2是用來(lái)標(biāo)記number值的類(lèi)型,2表示int類(lèi)型(3:long,4:float,5:double)。而其余56位就是用來(lái)存儲(chǔ)數(shù)值本身內(nèi)容的,也就是說(shuō)當(dāng)數(shù)值超過(guò)56位存儲(chǔ)上限的時(shí)候,那么NSNumber才會(huì)用真正的64位內(nèi)存地址存儲(chǔ)數(shù)值,然后用指針指向該內(nèi)存地址。

- (void)numberTest {
    NSNumber *number1 = [NSNumber numberWithInt:1];
    NSNumber *number2 = [NSNumber numberWithLong:2];
    NSNumber *number3 = [NSNumber numberWithFloat:3];
    NSNumber *number4 = @(pow(2, 54));
    NSNumber *normalNumber = @(pow(2, 55));
    NSLog(@"%p\n%p\n%p\n%p\n%p",number1,number2,number3,number4,normalNumber);
}

0xb000000000000012
0xb000000000000023
0xb000000000000034
0xb400000000000005
0x604000038800

可以明顯的看到,數(shù)值為2^55或更大時(shí)才在內(nèi)存中分配一個(gè)NSNumber的對(duì)象來(lái)存儲(chǔ)然后用指針指向該內(nèi)存地址。可見(jiàn),Tagged Pointer是可以與普通類(lèi)共存的,即對(duì)一些值使用Tagged Pointer,另一些則使用一般的指針。

那么NSString對(duì)象同樣也適用于Tagged Pointer。

- (void)stringTest {
    NSString *string1 = [NSString stringWithFormat:@"11"];
    NSString *string2 = [NSString stringWithFormat:@"a"];
    NSLog(@"%p\n%p",string1,string2);
}

0xa000000000031312
0xa000000000000611

和NSNumber一樣,地址最高四位的a就是NSString對(duì)象特殊標(biāo)記,而最低四位的是用來(lái)標(biāo)記string的長(zhǎng)度,其余56位就是用來(lái)存儲(chǔ)字符串內(nèi)容的(字符串內(nèi)容轉(zhuǎn)為為ASCII碼存儲(chǔ))。我們能猜測(cè)當(dāng)字符串所需內(nèi)存小于56位時(shí)會(huì)使用Tagged Pointer,相反就會(huì)使用真正的NSString對(duì)象。實(shí)際情況就是如此嗎?

- (void)stringTest {
    NSString *string1 = [NSString stringWithFormat:@"1234567"];
    NSString *string2 = [NSString stringWithFormat:@"12345678"];
    NSLog(@"%@---%p\n%@---%p",[string1 class],string1,[string2 class],string2);
}

NSTaggedPointerString---0xa373635343332317
NSTaggedPointerString---0xa007a87dcaecc2a8

可以看到,string2內(nèi)存64位,但還是使用了Tagged Pointer存儲(chǔ)。只是編碼的方式不一樣了。具體的編碼方式可以參考這篇博客,這里就簡(jiǎn)單列下不同字符串長(zhǎng)度的編碼方式:

1:如果長(zhǎng)度介于0到7,直接用八位編碼存儲(chǔ)字符串。
2:如果長(zhǎng)度是8或9,用六位編碼存儲(chǔ)字符串,使用編碼表“eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX”。
3:如果長(zhǎng)度是10或11,用五位編碼存儲(chǔ)字符串,使用編碼表“eilotrm.apdnsIc ufkMShjTRxgC4013”

- (void)stringTest {
    NSString *string1 = [NSString stringWithFormat:@"123456789"];
    NSString *string2 = [NSString stringWithFormat:@"1234567890"];
    NSLog(@"%@---%p\n%@---%p",[string1 class],string1,[string2 class],string2);
}

NSTaggedPointerString---0xa1ea1f72bb30ab19
__NSCFString---0x600000420060

當(dāng)長(zhǎng)度大于9時(shí),使用真正的NSString對(duì)象存儲(chǔ)。現(xiàn)在string2釋放的問(wèn)題就很明朗了:string2賦值為@"string string2"(長(zhǎng)度:14)出了作用域后正常釋放,string2賦值為@"string2"(長(zhǎng)度:7)出了作用域不會(huì)釋放。另外當(dāng)字符串的內(nèi)容有中文或者特殊字符(非 ASCII 字符)時(shí),只能用NSString對(duì)象存儲(chǔ)。字面量字符串不會(huì)使用Tagged Pointer。

總結(jié)

我這里用了一道題來(lái)總結(jié)以上有關(guān)內(nèi)存的內(nèi)容:
在64位架構(gòu)下,以下代碼輸出的結(jié)果是?

- (void)viewDidLoad {
    [super viewDidLoad];
    [self stringTest];
    NSLog(@"%@",_string1);
    NSLog(@"%@",_string2);
    NSLog(@"%@",_string3);
    NSLog(@"%@",_string4);
}

- (void)stringTest {
    NSString *string1 = @"1234567890";
    NSString *string2 = [NSString stringWithFormat:@"1"];
    NSString *string3 = [[NSString alloc] initWithFormat:@"2"];
    NSString *string4 = [[NSString alloc] initWithFormat:@"1234567890"];
    _string1 = string1;
    _string2 = string2;
    _string3 = string3;
    _string4 = string4;
}

相信大家很快就有了正確的答案。
寫(xiě)的比較雜,望多多指教。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。