iOS 記憶體管理MRC

左忠飛發表於2019-03-25

1. 什麼是記憶體管理

  • 程式在執行的過程中通常通過以下行為,來增加程式的的記憶體佔用
    • 建立一個OC物件
    • 定義一個變數
    • 呼叫一個函式或者方法
  • 而一個移動裝置的記憶體是有限的,每個軟體所能佔用的記憶體也是有限的
  • 當程式所佔用的記憶體較多時,系統就會發出記憶體警告,這時就得回收一些不需要再使用的記憶體空間。比如回收一些不需要使用的物件、變數等
  • 如果程式佔用記憶體過大,系統可能會強制關閉程式,造成程式崩潰、閃退現象,影響使用者體驗

所以,我們需要對記憶體進行合理的分配記憶體、清除記憶體,回收那些不需要再使用的物件。從而保證程式的穩定性。

那麼,那些物件才需要我們進行記憶體管理呢?

  • 任何繼承了NSObject的物件需要進行記憶體管理
  • 而其他非物件型別(int、char、float、double、struct、enum等) 不需要進行記憶體管理

這是因為

  • 繼承了NSObject的物件的儲存在作業系統的裡邊。
  • 作業系統的:一般由程式設計師分配釋放,若程式設計師不釋放,程式結束時可能由OS回收,分配方式類似於連結串列
  • 非OC物件一般放在作業系統的裡面
  • 作業系統的:由作業系統自動分配釋放,存放函式的引數值,區域性變數的值等。其操作方式類似於資料結構中的棧(先進後出)
  • 示例:
int main(int argc, const char * argv[])
{
    @autoreleasepool {
        int a = 10; // 棧
        int b = 20; // 棧
        // p : 棧
        // Person物件(計數器==1) : 堆
        Person *p = [[Person alloc] init];
    }
    // 經過上面程式碼後, 棧裡面的變數a、b、p 都會被回收
    // 但是堆裡面的Person物件還會留在記憶體中,因為它是計數器依然是1
    return 0;
}
複製程式碼
iOS 記憶體管理MRC
圖片1.png

2. 記憶體管理模型

提供給Objective-C程式設計師的基本記憶體管理模型有以下3種:

  • 自動垃圾收集(iOS執行環境不支援)
  • 手工引用計數和自動釋放池(MRC)
  • 自動引用計數(ARC)

3.MRC 手動管理記憶體(Manual Reference Counting)

1. 引用計數器

系統是根據物件的引用計數器來判斷什麼時候需要回收一個物件所佔用的記憶體

  • 引用計數器是一個整數
  • 從字面上, 可以理解為”物件被引用的次數”
  • 也可以理解為: 它表示有多少人正在用這個物件
  • 每個OC物件都有自己的引用計數器
  • 任何一個物件,剛建立的時候,初始的引用計數為1
    • 當使用alloc、new或者copy建立一個物件時,物件的引用計數器預設就是1
  • 當沒有任何人使用這個物件時,系統才會回收這個物件, 也就是說
    • 當物件的引用計數器為0時,物件佔用的記憶體就會被系統回收
    • 如果物件的計數器不為0,那麼在整個程式執行過程,它佔用的記憶體就不可能被回收(除非整個程式已經退出 )

2. 引用計數器操作

  • 為保證物件的存在,每當建立引用到物件需要給物件傳送一條retain訊息,可以使引用計數器值+1 ( retain 方法返回物件本身)
  • 當不再需要物件時,通過給物件傳送一條release訊息,可以使引用計數器值-1
  • 給物件傳送retainCount訊息,可以獲得當前的引用計數器值
  • 當物件的引用計數為0時,系統就知道這個物件不再需要使用了,所以可以釋放它的記憶體,通過給物件傳送dealloc訊息發起這個過程。
  • 需要注意的是:release並不代表銷燬\回收物件,僅僅是計數器-1
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // 只要建立一個物件預設引用計數器的值就是1
        Person *p = [[Person alloc] init];
        NSLog(@"retainCount = %lu", [p retainCount]); // 1

        // 只要給物件傳送一個retain訊息, 物件的引用計數器就會+1
        [p retain];

        NSLog(@"retainCount = %lu", [p retainCount]); // 2
        // 通過指標變數p,給p指向的物件傳送一條release訊息
        // 只要物件接收到release訊息, 引用計數器就會-1
        // 只要一個物件的引用計數器為0, 系統就會釋放物件

        [p release];
        // 需要注意的是: release並不代表銷燬\回收物件, 僅僅是計數器-1
        NSLog(@"retainCount = %lu", [p retainCount]); // 1

        [p release]; // 0
        NSLog(@"--------");
    }
//    [p setAge:20];    // 此時物件已經被釋放
    return 0;
}
複製程式碼

3. dealloc方法

  • 當一個物件的引用計數器值為0時,這個物件即將被銷燬,其佔用的記憶體被系統回收
  • 物件即將被銷燬時系統會自動給物件傳送一條dealloc訊息(因此,從dealloc方法有沒有被呼叫,就可以判斷出物件是否被銷燬)
  • dealloc方法的重寫
    • 一般會重寫dealloc方法,在這裡釋放相關資源,dealloc就是物件的遺言
    • 一旦重寫了dealloc方法,就必須呼叫[super dealloc],並且放在最後面呼叫
- (void)dealloc
{
    NSLog(@"Person dealloc");
    // 注意:super dealloc一定要寫到所有程式碼的最後
    // 一定要寫在dealloc方法的最後面
    [super dealloc]; 
}
複製程式碼
  • 使用注意
    • 不能直接呼叫dealloc方法
    • 一旦物件被回收了, 它佔用的記憶體就不再可用,堅持使用會導致程式崩潰(野指標錯誤)

4. 野指標和空指標

  • 只要一個物件被釋放了,我們就稱這個物件為 "殭屍物件(不能再使用的物件)"
  • 當一個指標指向一個殭屍物件(不可用記憶體),我們就稱這個指標為野指標
  • 只要給一個野指標傳送訊息就會報錯(EXC_BAD_ACCESS錯誤)
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Person *p = [[Person alloc] init]; // 執行完引用計數為1       

        [p release]; // 執行完引用計數為0,例項物件被釋放
        [p release]; // 此時,p就變成了野指標,再給野指標p傳送訊息就會報錯
        [p release];
    }
    return 0;
}
複製程式碼
  • 為了避免給野指標傳送訊息會報錯,一般情況下,當一個物件被釋放後我們會將這個物件的指標設定為空指標
  • 空指標
    • 沒有指向儲存空間的指標(裡面存的是nil, 也就是0)
    • 給空指標發訊息是沒有任何反應的
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Person *p = [[Person alloc] init]; // 執行完引用計數為1

        [p release]; // 執行完引用計數為0,例項物件被釋放
        p = nil; // 此時,p變為了空指標
        [p release]; // 再給空指標p傳送訊息就不會報錯了
        [p release];
    }
    return 0;
}
複製程式碼

5. 記憶體管理規律

單個物件記憶體管理規律

  • 誰建立誰release :
    • 如果你通過alloc、new、copy或mutableCopy來建立一個物件,那麼你必須呼叫release或autorelease
  • 誰retain誰release:
    • 只要你呼叫了retain,就必須呼叫一次release
  • 總結一下就是
    • 有加就有減
    • 曾經讓物件的計數器+1,就必須在最後讓物件計數器-1

多個物件記憶體管理規律

因為多個物件之間往往是聯絡的,所以管理起來比較複雜。這裡用一個玩遊戲例子來類比一下。

遊戲可以提供給玩家(A類物件) 遊戲房間(B類物件)來玩遊戲。

  • 只要一個玩家想使用房間(進入房間),就需要對這個房間的引用計數器+1
  • 只要一個玩家不想再使用房間(離開房間),就需要對這個房間的引用計數器-1
  • 只要還有至少一個玩家在用某個房間,那麼這個房間就不會被回收,引用計數至少為1
iOS 記憶體管理MRC
圖片2.png

下面來定義兩個類 玩家類:Person 和 房間類:Room

房間類:Room,房間類中有房間號

#import <Foundation/Foundation.h>

@interface Room : NSObject
@property int no; // 房間號
@end

複製程式碼

玩家類:Person

#import <Foundation/Foundation.h>
#import "Room.h"

@interface Person : NSObject
{
    Room *_room;
}

- (void)setRoom:(Room *)room;

- (Room *)room;
@end

複製程式碼

現在我們通過幾個玩家使用房間的不同應用場景來逐步深入理解記憶體管理。

1. 玩家沒有使用房間,玩家和房間之間沒有聯絡的情況

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // 1.建立兩個物件
        Person *p = [[Person alloc] init];    // 玩家 p
        Room *r = [[Room alloc] init];        // 房間 r
        r.no = 888;    // 房間號賦值

        [r release];    // 釋放房間      
        [p release];   // 釋放玩家
    }
    return 0;
}
複製程式碼

上述程式碼執行完前3行

        // 1.建立兩個物件
        Person *p = [[Person alloc] init];    // 玩家 p
        Room *r = [[Room alloc] init];        // 房間 r
        r.no = 888;    // 房間號賦值
複製程式碼

之後在記憶體中的表現如下圖所示:

iOS 記憶體管理MRC
圖片3.png

可見,Room例項物件和Person例項物件之間沒有相互聯絡,所以各自釋放不會報錯。執行完4、5行程式碼

        [r release];    // 釋放房間      
        [p release];   // 釋放玩家
複製程式碼

後,將房間物件和玩家物件各自釋放掉,在記憶體中的表現如下圖所示:

iOS 記憶體管理MRC
圖片4.png

最後各自例項物件的記憶體就會被系統回收

2. 一個玩家使用一個遊戲房間,玩家和房間之間相關聯的情況

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // 1.建立兩個物件
        Person *p = [[Person alloc] init];    // 玩家 p
        Room *r = [[Room alloc] init];        // 房間 r
        r.no = 888;    // 房間號賦值
     
        // 將房間賦值給玩家,表示玩家在使用房間
        // 玩家需要使用這間房,只要玩家在,房間就一定要在
        p.room = r; // [p setRoom:r]
     
        [r release];    // 釋放房間
       
        // 在這行程式碼之前,玩家都沒有被釋放,但是因為玩家還在,那麼房間就不能銷燬
        NSLog(@"-----");
       
        [p release];    // 釋放玩家
    }
    return 0;
}

複製程式碼

上邊程式碼執行完前3行的時候和之前在記憶體中的表現一樣,如圖

iOS 記憶體管理MRC
圖片3.png

當執行完第4行程式碼p.room = r;時,因為呼叫了setter方法,將Room例項物件賦值給了Person的成員變數,不做其他設定的話,在記憶體中的表現如下圖(做法不對):

iOS 記憶體管理MRC
圖片5.png

在呼叫setter方法的時候,因為Room例項物件多了一個Person物件引用,所以應將Room例項物件的引用計數+1才對,即setter方法應該像下邊一樣,對room進行一次retain操作。

- (void)setRoom:(Room *)room // room = r
{
    // 對房間的引用計數器+1
    [room retain];
    _room = room;
}

複製程式碼

那麼執行完第4行程式碼p.room = r;,在記憶體中的表現為:

iOS 記憶體管理MRC
圖片6.png

繼續執行第5行程式碼[r release];,釋放房間,Room例項物件引用計數-1,在記憶體中的表現如下圖所示:

iOS 記憶體管理MRC
圖片5.png

然後執行第6行程式碼[p release];,釋放玩家。這時候因為玩家不在房間裡了,房間也沒有用了,所以在釋放玩家的時候,要把房間也釋放掉,也就是在delloc裡邊對房間再進行一次release操作。

這樣對房間物件來說,每一次retain/alloc操作都對應一次release操作。

- (void)dealloc
{
    // 人釋放了, 那麼房間也需要釋放
    [_room release];
    NSLog(@"%s", __func__);

    [super dealloc];
}

複製程式碼

那麼在記憶體中的表現最終如下圖所示:

iOS 記憶體管理MRC
圖片7.png

最後例項物件的記憶體就會被系統回收

3. 一個玩家使用一個遊戲房間r後,換到另一個遊戲房間r2,玩家和房間相關聯的情況

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // 1.建立兩個物件
        Person *p = [[Person alloc] init];    // 玩家 p
        Room *r = [[Room alloc] init];        // 房間 r
        r.no = 888;    // 房間號賦值          

        // 2.將房間賦值給玩家,表示玩家在使用房間
        p.room = r; // [p setRoom:r]
        [r release];    // 釋放房間 r

        // 3. 換房
        Room *r2 = [[Room alloc] init];
        r2.no = 444;
        p.room = r2;
        [r2 release];    // 釋放房間 r2
     
        [p release];    // 釋放玩家 p
    }
    return 0;
}

複製程式碼

執行下邊幾行程式碼

        // 1.建立兩個物件
        Person *p = [[Person alloc] init];    // 玩家 p
        Room *r = [[Room alloc] init];        // 房間 r
        r.no = 888;    // 房間號賦值          

        // 2.將房間賦值給玩家,表示玩家在使用房間
        p.room = r; // [p setRoom:r]
        [r release];    // 釋放房間 r

複製程式碼

之後的記憶體表現為:

iOS 記憶體管理MRC
圖片8.png

接著執行換房操作而不進行其他操作的話,

        // 3. 換房
        Room *r2 = [[Room alloc] init];
        r2.no = 444;
        p.room = r2;
複製程式碼

記憶體的表現為:

iOS 記憶體管理MRC
圖片9.png

最後執行完

        [r2 release];    // 釋放房間 r2
        [p release];    // 釋放玩家 p
複製程式碼

記憶體的表現為:

iOS 記憶體管理MRC
圖片10.png

可以看出房間 r 並沒有被釋放,這是因為在進行換房的時候,並沒有對房間 r 進行釋放。所以應在呼叫setter方法的時候,對之前的變數進行一次release操作。具體setter方法程式碼如下:

- (void)setRoom:(Room *)room // room = r
{
        // 將以前的房間釋放掉 -1
        [_room release];     

        // 對房間的引用計數器+1
        [room retain];

        _room = room;
    }
}

複製程式碼

這樣在執行完p.room = r2;之後就會將 房間 r 釋放掉,最終記憶體表現為:

iOS 記憶體管理MRC
圖片11.png

4. 一個玩家使用一個遊戲房間,不再使用遊戲房間,將遊戲房間釋放掉之後,再次使用該遊戲房間的情況

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // 1.建立兩個物件
        Person *p = [[Person alloc] init];
        Room *r = [[Room alloc] init];
        r.no = 888;

        // 2.將房間賦值給人
        p.room = r; // [p setRoom:r]
        [r release];    // 釋放房間 r  
        
        // 3.再次使用房間 r
        p.room = r;
        [r release];    // 釋放房間 r  
        [p release];    // 釋放玩家 p
    }
    return 0;
}
複製程式碼

執行下面程式碼

        // 1.建立兩個物件
        Person *p = [[Person alloc] init];
        Room *r = [[Room alloc] init];
        r.no = 888;

        // 2.將房間賦值給人
        p.room = r; // [p setRoom:r]
        [r release];    // 釋放房間 r  
複製程式碼

之後的記憶體表現為:

iOS 記憶體管理MRC
圖片12.png

然後再執行p.room = r;,因為setter方法會將之前的Room例項物件先release掉,此時記憶體表現為:

iOS 記憶體管理MRC
圖片13.png

此時_room、r 已經變成了一個野指標。之後再對野指標 r 發出retain訊息,程式就會崩潰。所以我們在進行setter方法的時候,要先判斷一下是否是重複賦值,如果是同一個例項物件,就不需要重複進行release和retain。換句話說,如果我們使用的還是之前的房間,那換房的時候就不需要對這個房間再進行release和retain。則setter方法具體程式碼如下:

- (void)setRoom:(Room *)room // room = r
{
    // 只有房間不同才需用release和retain
    if (_room != room) {    // 0ffe1 != 0ffe1
        // 將以前的房間釋放掉 -1
        [_room release];

        // 對房間的引用計數器+1
        [room retain];

        _room = room;
    }
}
複製程式碼

因為retain不僅僅會對引用計數器+1, 而且還會返回當前物件,所以上述程式碼可最終簡化成:

- (void)setRoom:(Room *)room // room = r
{
    // 只有房間不同才需用release和retain
    if (_room != room) {    // 0ffe1 != 0ffe1
        // 將以前的房間釋放掉 -1
        [_room release];      

        _room = [room retain];
    }
}
複製程式碼

以上就是setter方法的最終形式。



轉自:https://www.jianshu.com/p/48665652e4e4


相關文章