iOS概念攻堅之路(三):內存管理

前言

iOS 的內存管理不止是 「引用計數表」。ios

iOS 開發者基本都知道 iOS 是經過「引用計數」來管理內存的,可是也許並不知道 iOS 其餘的內存管理方式,好比 「Tagged Pointer」(帶標記的指針),好比 「NONPOINTER_ISA」(非指針型 isa),這個要根據不一樣的場景進行區分。c++

咱們就這篇文章主要來談一談這三種內存管理方式。git

關於內存

在說內存管理以前,咱們先來講一下關於內存的概念。程序員

內存是計算機中重要的部件之一,它是與 CPU 進行溝通的橋樑。計算機中全部的程序都是在內存中進行的。內存(Menory)也被成爲「內存儲器」和「主存儲器」,其做用是用於暫時存放 CPU 中的運算數據,以及與硬盤等外部存儲器交換的數據。只要計算機在運行中,CPU 就會把須要運算的數據調到內存中進行運算,當運算完成後 CPU 再將結果傳送出來,內存的運行也決定了計算機的穩定運行。(來自 度娘github

在 App 啓動後,系統會把 App 程序拷貝到內存中,而後在內存中執行代碼。編程

內存的概念你們多多少少都有點了解,咱們也不說那麼多。一塊內存條,是一個從下至上、地址依次遞增結構。來看一下內存的分區:swift

上面這張圖來自 這裏數組

大體說一下 iOS 內存分區的狀況,五大區域:安全

  • 棧區(Stack)數據結構

    • 由編譯器自動分配釋放,存放函數的參數,局部變量的值等
    • 棧是向低地址擴展的數據結構,是一塊連續的內存區域
  • 堆區(Heap)

    • 由程序員分配釋放
    • 是向高地址擴展的數據結構,是不連續的內存區域
  • 全局區

    • 全局變量和靜態變量的存儲是放在一塊的,初始化的全局變量和靜態變量在一塊區域,未初始化的全局變量和未初始化的靜態變量在相鄰的另外一塊區域
    • 程序結束後由系統釋放
  • 常量區

    • 常量字符串就是放在這裏的
    • 程序結束後由系統釋放
  • 代碼區

    • 存放函數體的二進制代碼

另外說一下一些值得注意的地方:

  1. 在 iOS 中,堆區的內存是應用程序內共享的,一個應用程序其實就是一個進程,也就是進程內的內存是共享的。堆中的內存分配是須要程序員負責的
  2. 系統使用一個鏈表來維護全部已經分配的內存空間(系統僅僅記錄,並無論理具體的內容)
  3. 變量使用結束後,須要釋放內存,OC 中是判斷引用計數是否爲 0,若是是就說明沒有任何變量使用該空間,那麼系統將其回收
  4. 當一個 app 啓動後,代碼區、常量區、全局區大小就已經固定,所以指向這些區的指針不會產生崩潰性的錯誤。而堆區和棧區是時時刻刻變化的(堆的建立銷燬,棧的彈入彈出),因此當使用一個指針指向這個區裏面的內存時,必定要注意內存是否已經被釋放,不然會產生程序崩潰(也便是野指針報錯)

Tagged Pointer

爲了節省內存和提升執行效率,蘋果提出了 Tagged Pointer 的概念。對於 64 位程序,引入 Tagged Pointer 後,相關邏輯能減小一半的內存佔用,以及 3 倍的訪問速度提高,100 倍的建立、銷燬速度提高。

(有沒有那麼牛逼咱也不知道,咱也不敢問)

咱們先看看原有的對象爲何會浪費內存,假設咱們要存儲一個 NSNumber 對象,其值是一個整數。正常狀況下,若是這個整數只是一個 NSInteger 的普通變量,那麼它所佔用的內存是與 CPU 的位數有關,在 32 位 CPU 下佔 4 個字節,在 64 位 CPU 下是佔 8 個字節的。而指針類型的大小一般也是與 CPU 位數相關的,一個指針所佔用的內存在 32 位 CPU 下爲 4 個字節,在 64 位 CPU 下也是 8 個字節。

因此一個普通的 iOS 程序,若是沒有 Tagged Pointer 對象,從 32 位機器遷移到 64 位機器中後,雖然邏輯沒有任何變化,但這種 NSNumberNSDate 一類的對象所佔用的內存會翻倍。

咱們再來看看效率上的問題,爲了存儲和訪問一個 NSNumber 對象,咱們須要在堆上爲其分配內存,另外還要維護它的引用計數,管理它的生命週期。這些都給程序增長了額外的邏輯,形成了運行效率上的損失。

因此爲了改進上面提到的內存佔用和效率問題,蘋果提出了 Tagged Pointer 對象,因爲 NSNumberNSDate 一類的變量自己的值須要佔用的內存大小經常不須要 8 個字節,拿整數來講,4 個字節所能表示的有符號整數就能夠達到 20 多億(2 ^ 31 = 2147483648,另外 1 位做爲符號位),對於絕大多數狀況都是能夠處理的。

因此咱們能夠將一個對象的指針拆分紅兩部分,一部分直接保存數據,另外一部分做爲特殊標記,表示這是一個特別的指針,不指向任何一個地址。

Tagged Pointer 特色:

  1. Tagged Pointer 專門用來存儲小的對象,例如 NSNumberNSDate
  2. Tagged Pointer 指針的值再也不是地址了,而是真正的值。因此,實際上它再也不是一個對象了,它只是一個披着對象皮的普通變量而已。因此,它的內存並不存儲在堆中,也不須要 mallocfree
  3. 在內存讀取上有着 3 倍的效率,建立時比之前快 106 倍
  4. objc_msgSend 能識別 Tagged Pointer,好比 NSNumberintValue 方法,直接從指針提取數據
  5. 使用 Tagged Pointer 後,指針內存儲的數據變成了 Tag + Data,也就是將數據直接存儲在了指針中

NONPOINTER_ISA

蘋果將 isa 設計成了聯合體,在 isa 中存儲了與該對象相關的一些內存的信息,緣由也如上面所說,並不須要 64 個二進制位所有都用來存儲指針。

來看一下 isa 的結構:

// x86_64 架構
struct {
    uintptr_t nonpointer        : 1;  // 0:普通指針,1:優化過,使用位域存儲更多信息
    uintptr_t has_assoc         : 1;  // 對象是否含有或曾經含有關聯引用
    uintptr_t has_cxx_dtor      : 1;  // 表示是否有C++析構函數或OC的dealloc
    uintptr_t shiftcls          : 44; // 存放着 Class、Meta-Class 對象的內存地址信息
    uintptr_t magic             : 6;  // 用於在調試時分辨對象是否未完成初始化
    uintptr_t weakly_referenced : 1;  // 是否被弱引用指向
    uintptr_t deallocating      : 1;  // 對象是否正在釋放
    uintptr_t has_sidetable_rc  : 1;  // 是否須要使用 sidetable 來存儲引用計數
    uintptr_t extra_rc          : 8;  // 引用計數可以用 8 個二進制位存儲時,直接存儲在這裏
};

// arm64 架構
struct {
    uintptr_t nonpointer        : 1;  // 0:普通指針,1:優化過,使用位域存儲更多信息
    uintptr_t has_assoc         : 1;  // 對象是否含有或曾經含有關聯引用
    uintptr_t has_cxx_dtor      : 1;  // 表示是否有C++析構函數或OC的dealloc
    uintptr_t shiftcls          : 33; // 存放着 Class、Meta-Class 對象的內存地址信息
    uintptr_t magic             : 6;  // 用於在調試時分辨對象是否未完成初始化
    uintptr_t weakly_referenced : 1;  // 是否被弱引用指向
    uintptr_t deallocating      : 1;  // 對象是否正在釋放
    uintptr_t has_sidetable_rc  : 1;  // 是否須要使用 sidetable 來存儲引用計數
    uintptr_t extra_rc          : 19;  // 引用計數可以用 19 個二進制位存儲時,直接存儲在這裏
};
複製代碼

注意這裏的 has_sidetable_rcextra_rchas_sidetable_rc 代表該指針是否引用了 sidetable 散列表,之因此有這個選項,是由於少許的引用計數是不會直接存放在 SideTables 表中的,對象的引用計數會先存放在 extra_rc 中,當其被存滿時,纔會存入相應的 SideTables 散列表中,SideTables 中有不少張 SideTable,每一個 SideTable 也都是一個散列表,而引用計數表就包含在 SideTable 之中。

SideTables

原理

引用計數要麼存放在 isaextra_rc 中,要麼存放在引用計數表中,而引用計數表包含在一個叫 SideTable 的結構中,它是一個散列表,也就是哈希表。而 SideTable 又包含在一個全局的 StripeMap 的哈希映射表中,這個表的名字叫 SideTables

散列表(Hash table,也叫哈希表),是根據建(Key)而直接訪問在內存存儲位置的數據結構。也就是說,它經過一個關於鍵值得函數,將所需查詢的數據映射到表中一個位置來訪問記錄,這加快了查找速度。這個映射函數稱做散列函數,存放記錄的數組稱做散列表。

來看一下 NSObject.mm 中它們對應的源碼:

// SideTables
static StripedMap<SideTable>& SideTables() {
    return *reinterpret_cast<StripedMap<SideTable>*>(SideTableBuf);
}

// SideTable
struct SideTable {
    spinlock_t slock;           // 自旋鎖
    RefcountMap refcnts;        // 引用計數表
    weak_table_t weak_table;    // 弱引用表
    
    // other code ...
};
複製代碼

它們的關係以下圖:

一個 SideTables 包含衆多 SideTable,每一個 SideTable 中又包含了三個元素,spinlock_t 自旋鎖、RefcountMap 引用計數表、weak_table_t 弱引用表。因此既然 SideTables 是一個哈希映射的表,爲何不用 SideTables 直接包含自旋鎖,引用計數表和弱引用表呢?這是由於在衆多線程同時訪問這個 SideTable 表的時候,爲了保證數據安全,須要給其加上自旋鎖,若是隻有一張 SideTable 的表,那麼全部數據訪問都會出一個進一個,單線程進行,很是影響效率,雖然自旋鎖已是效率很是高的鎖,這會帶來很是很差的用戶體驗。針對這種狀況,將一張 SideTable 分爲多張表的 SideTables,再各自加鎖保證數據的安全,這樣就增長了併發量,提升了數據訪問的效率,這就是爲何一個 SideTables 下涵蓋衆多 SideTable 表的緣由。

自旋鎖:計算機科學用於多線程同步的一種鎖,線程會反覆檢查鎖變量是否可用。因爲線程在這一過程當中保持執行(沒有進入休眠),所以是一種忙等。一旦獲取了自旋鎖,線程會一直保持該鎖,直至顯式釋放自旋鎖。

自旋鎖適用於小型數據、耗時不多的操做,速度很快。

弱引用表也是一張哈希表的結構,其內部包含了每一個對象對應的弱引用表 weak_entry_t,而 weak_entry_t 是一個結構體數組,其中包含的則是每個對象弱引用的對象所對應的弱引用指針。

如何進行引用計數操做

當須要去查找一個對象對應的 SideTable 並進行引用計數或者弱引用計數的操做時,系統又是怎樣實現的呢?

當一個對象訪問 SideTables 時:

  1. 首先會取得對象的地址,將地址進行哈希運算,與 SideTablesSideTable 的個數取餘,最後獲得的結果就是該對象所要訪問的 SideTable
  2. 在取得的 SideTable 中的 RefcountMap 表中再進行一次哈希查找,找到該對象在引用計數表中對應的位置
  3. 若是該位置存在對應的引用計數,則對其進行操做,若是沒有對應的引用計數,則建立一個對應的 size_t 對象,其實就是一個 uint 類型的無符號整型

引用計數

引用計數(Reference Count)是一個簡單而有效的管理對象生命週期的方式。當咱們建立一個新對象的時候,它的引用計數爲 1,當有一個新的指針指向這個對象時,咱們將其引用計數加 1,當某個指針再也不指向這個對象時,咱們將其引用計數減 1,當對象的引用計數變爲 0 時,說明這個對象再也不被任何指針指向了,這個時候咱們就能夠將對象銷燬,回收內存。

上面是唐巧的 理解 iOS 的內存管理 中對引用計數的一個定義,簡單來講就是採起計數的方式對內存進行管理,內存首先須要被建立出來,而後有人用這塊內存,計數 +1,那我的不用了,計數 -1,若是計數爲 0,釋放它。

固然,建立、使用、釋放是有一個規則的,來看一下 iOS 中內存管理的思考方式:

  • 本身生成的對象,本身所持有
  • 非本身生成的對象,本身也能持有
  • 再也不須要本身持有的對象時釋放
  • 非本身持有的對象沒法釋放

與之對應的 Objective-C 方法:

對象操做 Objective-C 方法
生成並持有對象 alloc/new/copy/mutableCopy 等方法
持有對象 retain 方法
釋放對象 release 方法
廢棄對象 dealloc 方法

這些有關 Objective-C 內存管理的方法,實際上不包括在 Objective-C 語言中,而是包含在 Cocoa 框架中用於 OS X,iOS 應用開發,swift 也採用引用計數的方式進行內存管理。Cocoa 框架中 Foundation 框架類庫的 NSObject 類擔負內存管理的職責。Objective-C 內存管理中的 alloc/retain/release/dealloc 方法分別指代 NSObject 類的 +alloc-retain-release-dealloc 方法。

而引用計數又分爲 MRC(Manual Reference Counting,手動引用計數)ARC(Automatic Reference Counting,自動引用計數)

咱們來看一下官方對於自動引用計數的說明:

在 Objective-C 中採用 Automatic Reference Counting(ARC)機制,讓編譯器來進行內存管理。在新一代 Apple LLVM 編譯器(LLVM 3.0 或以上)中設置 ARC 爲有效狀態,就無需再次鍵入 retain 或者 release 代碼,這在下降程序崩潰、內存泄漏等風險的同時,很大程度上減小了開發程序的工做量。編譯器徹底清楚目標對象,並能馬上釋放那些再也不被使用的對象,如此一來,應用程序將具備可預測性,且能流暢運行,速度也將大幅提高。

其實最主要的是一點:

在 LLVM 編譯器中設置 ARC 爲有效狀態,就無需再次鍵入 retain 或者是 release 代碼

那麼咱們也就知道了 MRC 是怎麼回事了,MRC 就是須要程序員手動插入 retainrelease 等管理內存的代碼,不過如今 MRC 已經屬於遠古時代的事情了,這裏只是順便提提,咱們主要看 ARC,ARC 其實作的事情不止是自動插入管理內存的方法,還作了一些優化,咱們放到後面一點講。咱們先來看看 alloc/retain/release/dealloc 這幾個方法的大體實現,這裏有一份編譯好的 runtime 源碼,版本是 objc4-750,或者你們能夠到 opensource.apple 去下載。

alloc

NSObject 中類方法 alloc 作的事情:

首先看看 alloc 方法的實現:

+ (id)alloc {
    return _objc_rootAlloc(self);
}
複製代碼

alloc 中調用 _objc_rootAlloc()

id 
_objc_rootAlloc(Class cls)
{
    return callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/);
}
複製代碼

_objc_rootAlloc 中調用 callAlloc()

static ALWAYS_INLINE id
callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
{
    // some code ...
    
    id obj = class_createInstance(cls, 0);
    return obj;
    
}

複製代碼

省略了一部分代碼,callAlloc 中會調用 class_createInstance()

id 
class_createInstance(Class cls, size_t extraBytes)
{
    return _class_createInstanceFromZone(cls, extraBytes, nil);
}
複製代碼

class_createInstance() 中直接調用 _class_createInstanceFromZone,調用 calloc 方法分配內存。

static __attribute__((always_inline)) 
id
_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, 
                              bool cxxConstruct = true, 
                              size_t *outAllocatedSize = nil)
{

    // some code ...
    id obj;
    obj = (id)calloc(1, size);  // 此時分配內存
    obj->initInstanceIsa(cls, hasCxxDtor);
    return obj;
}
複製代碼

_class_createInstanceFromZone 中會調用 obj->initInstanceIsa(),如下就是初始化的方法了,此時內存已經分配。

inline void 
objc_object::initInstanceIsa(Class cls, bool hasCxxDtor)
{
    initIsa(cls, true, hasCxxDtor);
}
複製代碼

initInstanceIsa() 中調用 initIsa()

inline void 
objc_object::initIsa(Class cls, bool nonpointer, bool hasCxxDtor) 
{ 
    if (!nonpointer) {
        isa.cls = cls;
    } else {
        isa_t newisa(0);

#if SUPPORT_INDEXED_ISA
        newisa.bits = ISA_INDEX_MAGIC_VALUE;
        newisa.has_cxx_dtor = hasCxxDtor;
        newisa.indexcls = (uintptr_t)cls->classArrayIndex();
#else
        newisa.bits = ISA_MAGIC_VALUE;
        newisa.has_cxx_dtor = hasCxxDtor;
        newisa.shiftcls = (uintptr_t)cls >> 3;
#endif
        isa = newisa;
    }
}
複製代碼

這裏就是對 isa 的一個初始化。

因此關於 alloc 方法,其大概步驟以下:

  1. alloc/allocWithZone
  2. class_createInstance / initInstanceIsa
  3. calloc (在這一步開始分配內存)
  4. initIsa (初始化 isa 指針裏面的內容)

關於 NSObject 的源碼解析你們能夠看看如下兩篇文章:

iOS底層探索 - 實例對象的建立

iOS NSObject.mm 源碼解析

slowpathfastpath

這裏我想提一嘴 slowpathfastpath,看一下 callAlloc 的完整實現:

static ALWAYS_INLINE id
callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
{
    if (slowpath(checkNil && !cls)) return nil;

#if __OBJC2__
    if (fastpath(!cls->ISA()->hasCustomAWZ())) {
        if (fastpath(cls->canAllocFast())) {
            // No ctors, raw isa, etc. Go straight to the metal.
            bool dtor = cls->hasCxxDtor();
            id obj = (id)calloc(1, cls->bits.fastInstanceSize());
            if (slowpath(!obj)) return callBadAllocHandler(cls);
            obj->initInstanceIsa(cls, dtor);
            return obj;
        }
        else {
            // Has ctor or raw isa or something. Use the slower path.
            id obj = class_createInstance(cls, 0);
            if (slowpath(!obj)) return callBadAllocHandler(cls);
            return obj;
        }
    }
#endif
    if (allocWithZone) return [cls allocWithZone:nil];
    return [cls alloc];
}
複製代碼

注意到方法中使用到的 slowpathfastpath,其實這兩個都是宏定義,與代碼邏輯自己無關,定義以下:

// x 極可能不爲 0,但願編譯器進行優化
#define fastpath(x) (__builtin_expect(bool(x), 1))
// x 極可能爲 0,但願編譯器進行優化
#define slowpath(x) (__builtin_expect(bool(x), 0))
複製代碼

其實它們是所謂的快路徑和慢路徑,爲了解釋這個,咱們來看一段代碼:

if (x)
    return 1;
else 
    return 39;
複製代碼

因爲計算機並不是一次只讀取一條指令,而是讀取多條指令,因此在讀到 if 語句時也會把 return 1 讀取進來。若是 x 爲 0,那麼會從新讀取 return 39,重讀指令相對來講比較耗時。

若是 x 有很是大的機率是 0,那麼 return 1 這條指令每次不可避免的會被讀取,而且實際上幾乎沒有機會執行,形成了沒必要要的指令重讀。

所以,在蘋果定義的兩個宏中,fastpath(x) 依然返回 x,只是告訴編譯器 x 的值通常不爲 0,從而編譯能夠進行優化。同理,slowpath(x) 表示 x 的值極可能爲 0,但願編譯器進行優化。

這個例子的講解來自 bestsswifter 的 深刻理解GCD,你們感興趣能夠看看。

因此如下代碼的解釋就出來了:

// 極可能 cls 是有值的,編譯器能夠不用每次都讀取 return nil 指令
 if (slowpath(checkNil && !cls)) return nil;
複製代碼

fastpath 也是一樣的機制,可是你們要知道,當 checkNil && !cls 判斷成立的時候,return nil 指令仍是會被讀取,而後執行的。

還有一個就是 #if __OBJ2__#endif,若是查看源碼的話,還會碰到 #if !__LP64__#elif 1#else 這類的宏判斷,這是由於蘋果針對不一樣的版本作了不一樣的實現,好比 32 位架構下和 64 位架構下的實現,有一些代碼在不一樣的狀況下是不須要參與編譯的,其實也跟咱們平時的 if-else 是同樣的概念。

retain & release

retain 方法用於增長引用計數,release 用於減小引用計數。那麼引用計數存儲在哪裏?其實有兩個地方,一個是 NONPOINTER_ISA,也就是非指針型 isa 中,isa 有個 extra_rc 屬性,就是用於存放引用計數的,在 ARM 64 下,extra_rc 佔 19 位。

extra_rc 只會保存額外的自動引用計數,對象的實際的引用計數會在這個基礎上 +1。當 isaextra_rc 中存不下的時候,會使用 SideTable 來存儲,SideTable 中包含了咱們你們都知道的引用計數表。

經過引用計數表管理引用計數的好處在於:

  1. 對象用內存塊分配無需考慮內存塊頭部
  2. 引用計數表各記錄中存有內存塊地址,可從各個記錄追溯到各對象的內存塊

第二點在調試時有着舉足輕重的做用,即便出現故障致使對象佔用的內存塊損壞,但只要引用計數表沒有被破壞,就可以確認各內存塊的位置。另外,在利用工具檢測內存泄漏時,引用計數表的記錄也有助於檢測各個對象的持有者是否存在。

若是想了解 retainrelease 的底層實現,能夠看一下 黑箱中的 retain 和 release

autorelease

簡介

顧名思義,autorelease 就是自動釋放。這看上去很像 ARC,但實際上它更相似於 C 語言中自動變量(局部變量)的特性。

在計算機編程領域,自動變量(Automatic Variable)指的是局部做用域變量,具體來講便是在控制流進入變量做用域時系統自動爲其分配存儲空間,並在離開做用域時釋放空間的一類變量

程序執行時,若某自動變量超出其做用域,該自動變量將被自動廢棄。

autorelease 會像 C 語言的自動變量那樣來對待對象實例,當超出其做用域(至關於變量做用域)時,對象實例的 release 實例方法被調用。另外,同 C 語言的自動變量不一樣的是,編程人員能夠設定變量的做用域。


須要被自動釋放的對象會被添加到離它最近的自動釋放池中(AutoreleasePool),咱們先明確什麼對象會自動加入自動釋放池:

  1. MRC 下須要對象調用 autorelease 纔會入池,ARC 下能夠經過 __autoreleasing 修飾符,不然的話看方法名,經過 alloc/new/copy/mutablecopy 之外的方法取得的對象,編譯器幫咱們自動加入 autoreleasepool。(使用 alloc/new/copy/mutablecopy 方法進行初始化時,由系統管理對象,在適當的位置 release,不加入 autoreleasepool)
  2. 使用 array 會自動將返回對象註冊到 autoreleasepool
  3. __weak 修飾的對象,爲了保證在引用時不被廢棄,會被註冊到 autoreleasepool 中
  4. id 的指針或對象的指針,在沒有顯式指定時會被註冊到 autoreleasepool 中

那 Autorelease 的對象何時釋放?

在沒有手動添加 AutoreleasePool 的狀況下,Autorelease 對象是在當前的 runloop 迭代結束時釋放的,而它可以釋放的緣由是系統在每一個 runloop 迭代中都加入了自動釋放池的 Push 和 Pop

App 啓動後,蘋果在主線程 runLoop 裏註冊了兩個 Observer,其回調都是 _wrapRunLoopWithAutoreleasePoolHandler()

第一個 Observer 監視的事件是 Entry(即將進入 loop),其回調會調用 _objc_autoreleasePoolPush() 建立自動釋放吃。其 order-2147483647,優先級最高,保證建立釋放池發生在其餘全部回調以前。

第二個 Observer 監視了兩個事件:BeforeWaiting(準備進入休眠) 時調用 _objc_autoreleasePoolPop()_objc_autoreleasePoolPush() 釋放舊的池並建立新池;Exit(即將退出 Loop) 時調用 _objc_autoreleasePoopPop() 來釋放自動釋放池,這個 Observerorder2147483647,優先級最低,保證釋放池釋放發生在其餘全部回調以後。

在主線程執行的代碼,一般是寫在諸如事件回調、Timer 回調內的。這些回調會被 RunLoop 建立好的 AutoreleasePool 環繞着,因此不會出現內存泄漏,開發者也沒必要顯式建立 Pool。

使用方法

autorelease 的具體使用方法以下:

  1. 生成並持有 NSAutoreleasePool 對象
  2. 調用已分配對象的 autorelease 實例方法
  3. 廢棄 NSAutoreleasePool 對象

NSAutoreleasePool 對象的生存週期至關於 C 語言變量的做用域,對於全部調用過 autorelease 實例方法的對象,在廢棄 NSAutoreleasePool 對象時,都將調用 release 實例方法。

NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];

id obj = [[NSObject alloc] init];

[obj autorelease];

[pool drain];    // 等同於 [obj release]
複製代碼

在 Cocoa 框架中,至關於程序主循環的 NSRunLoop 或者在其餘程序可運行的地方,對 NSAutoreleasePool 對象進行生成、持有和廢棄處理。所以,開發者通常不須要使用手動建立釋放池。Objective-C 的 main.mUIApplicationMain 方法就是被一個自動釋放池環繞着的,也就是說,整個 iOS 應用都是包含在一個自動釋放池 block 中:

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

不過,在大量產生 autorelease 的對象時,只要不廢棄 NSAutoreleasePool 對象,那麼生成的對象就不能被釋放,所以有時會因爲內存不足而到達內存峯值。典型的例子是讀入大量圖片的同時改變其尺寸,圖像文件讀入到 NSData 對象,並從中生成 UIImage 對象,改變該對象尺寸後生成新的 UIImage 對象。這種狀況下,就會大量產生 autorelease 的對象:

for (int i = 0; i < 圖像數 ; ++i) {
    /* 讀入圖像 * 大量產生 autorelease 的對象 * 因爲沒有廢棄 NSAutoreleasePool 對象 * 最終致使內存不足! */
}
複製代碼

在這種狀況下,有必要在適當的地方生成、持有或廢棄 NSAutoreleasePool 對象:

for (int i = 0; i < 圖像數; ++i) {
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
    
    /* * 讀入圖像 * 大量產生 autorelease 的對象 */
     
    [pool drain];
    
    /* * 經過 [pool drain], * autorelease 的對象被一塊兒 release。 */
}
複製代碼

在 ARC 下咱們使用 @autoreleasepool{} 將代碼環繞便可。

原理

那麼系統是如何實現 Autorelease 的,在 ARC 下,咱們使用 @autoreleasepool{} 來使用一個 AutoreleasePool,隨後編譯器將其改寫成下面的樣子:

void *context = objc_autoreleasePoolPush();
// {} 中的代碼
objc_autoreleasePoolPop(context);
複製代碼

這兩個函數都是對 AutoreleasePoolPage 的簡單封裝,因此自動釋放機制的核心就在於這個類。

class AutoreleasePoolPage 
{
    magic_t const magic;
    id *next;
    pthread_t const thread;
    AutoreleasePoolPage * const parent;
    AutoreleasePoolPage *child;
    uint32_t const depth;
    uint32_t hiwat;
    
    // other code ...
}
複製代碼

AutoreleasePoolPage 是一個 C++ 實現的類。

  • AutoreleasePool 並無單獨的結構,而是由若干個 AutoreleasePoolPage 以雙向鏈表的形式組合而成(分別對應結構中的 parent 指針和 child 指針)
  • AutoreleasePool 是按線程一一對應的(結構中的 thread 指針指向當前線程)
  • AutoreleasePoolPage 每一個對象會開闢 4096 字節內存(也就是虛擬內存一頁的大小),除了上面的實例變量所佔空間,剩下的空間所有用來儲存 autorelease 對象的地址
  • 上面的 id *next 指針做爲遊標(哨兵對象)指向棧頂最新 add 進來的 autorelease 對象的下一個位置
  • 一個 AutoreleasePoolPage 的內存被佔滿時,會新建一個 AutoreleasePoolPage 對象,鏈接鏈表,後來的 autorelease 對象會被添加到新的 page

因此,若當前線程中只有一個 AutoreleasePoolPage 對象,並記錄了不少 autorelease 對象地址,以下圖:

圖中的狀況,這一頁再加入一個 autorelease 對象就要滿了(也就是 next 指針立刻指向棧頂),這時就要執行上面說的操做,創建下一頁 page 對象,與這一頁鏈表鏈接完成後,新的 pagenext 指針被初始化在棧底(begin 的位置),而後繼續向棧頂添加新對象。

因此,向一個對象發送 autorelease 消息,就是將這個對象加入到當前的 AutoreleasePoolPage 的棧頂 next 指針指向的位置。


每當進行一次 objc_autoreleasePoolPush 調用時,runtime 向當前的 AutoreleasePoolpageadd 進一個 哨兵對象,值爲 0(也就是個 nil),那麼這一個 page 就變成了下面的樣子:

objc_autoreleasePoolPush 的返回值正是這個哨兵對象的地址,被 objc_autoreleasePoolPop(哨兵對象) 做爲入參,因此:

  1. 根據傳入的哨兵對象地址找到哨兵對象所處的 page
  2. 在當前 page 中,將晚於哨兵對象插入的全部 autorelease 對象都發送一次 -release 消息,並向回移動 next 指針到正確位置,從最新加入的對象一直向前清理,能夠向前跨越若干個 page,直到哨兵對象所在的 page

剛纔的 objc_autoreleasePoopPop 執行後,最終變成了下面的樣子:

知道了上面的原理,嵌套的 AutoreleasePool 就很是簡單了,pop 的時候總會釋放到上次 push 的位置,多層的 pool 就是多個哨兵對象而已,就像剝洋蔥同樣,每次一層,互不影響。

在對象的引用計數歸零時,會調用 dealloc 方法回收對象。

原理部分的講解來自於孫源大神的 黑幕背後的Autorelease,講的很是好,你們能夠看看。

另外說一下 ARC 中對 autoreleaseretain 的一些優化:

若是 ARC 在運行時檢測到類函數中的 autorelease 後緊跟着一個 retain 操做,此時不直接調用對象的 autorelease 方法,而是改成調用 objc_autoreleaseReturnValueobjc_autoreleaseReturnValue 會檢視當前方法返回以後將要執行的那段代碼,若那段代碼要在返回對象上執行 retain 操做,則設置全局數據結構中的一個標誌位,而不執行 autorelease 操做,與之類似,若是方法返回了一個自動釋放的對象,而調用方法的代碼要保留此對象,那麼此時不直接執行 retain,而是改成執行 objc_retainAutoreleasedReturnValue 函數。此函數要檢測剛纔提到的標誌位,若已經置位,則不執行 retain 操做,設置並檢測標誌位,要比調用 autoreleaseretain 更快。

dealloc

當對象的引用計數爲 0 時,也就是對象的全部者都不持有該對象,該對象被廢棄時,無論 ARC 是否有效,都會調用對象的 dealloc 方法,對對象進行析構。

簡單列舉一下 dealooc 的調用流程,你們能夠結合 runtime 源碼來看:

  1. dealloc 調用流程

    1. 首先調用 _objc_rootDealloc()
    2. 接下來調用 rootDealloc()
    3. 這時候會判斷是否能夠被釋放,判斷的依據主要有 5 個:
      • NONPointer_ISA // 是不是非指針類型 isa
      • weakly_reference // 是否有若引用
      • has_assoc // 是否有關聯對象
      • has_cxx_dtor // 是否有 c++ 相關內容
      • has_sidetable_rc // 是否使用到 sidetable
    4. 若是沒有以前 5 種狀況的任意一種,則能夠執行釋放操做,C 函數的 free()
    5. 執行完畢
  2. objc_dispose() 調用流程

    1. 直接調用 objc_destructInstance()
    2. 以後調用 C 函數的 free()
  3. objc_destructInstance() 調用流程

    1. 先判斷 hasCxxDtor,若是有 c++ 相關內容,要調用 object_cxxDestruct(),銷燬 c++ 相關內容
    2. 再判斷 hasAssociatedObjects,若是有關聯對象,要調用 object_remove_associations(),銷燬關聯對象的一系列操做
    3. 而後調用 clearDeallocating()
    4. 執行完畢
  4. clearDeallocating() 調用流程

    1. 先執行 sideTable_clearDeallocating()
    2. 再執行 waek_clear_no_lock,將指向該對象的弱引用指針置爲 nil
    3. 接下來執行 table.refcnts.eraser(),從引用計數表中擦除該對象的引用計數
    4. 至此爲此,dealloc 的執行流程結束

總結

來作一個小總結吧。

內存分區:

  • 棧區
  • 堆區
  • 全局區
    • 未初始化
    • 已初始化
  • 常量區
  • 代碼區

內存管理方式:

  • Tagged Pointer(小對象)
  • NONPOINTER_ISA (指針中存放與該對象內存相關的信息)
  • 散列表(引用計數表、弱引用表)

這篇文章講了內存分區、內存管理方式、SideTables 原理、引用計數、alloc/retain/release/autorelease/dealloc 內存相關方法的介紹,以及自動釋放池。

參考文章

【iOS】內存五大區域

深刻淺出-iOS內存分配與分區

理解 iOS 的內存管理

深刻理解 GCD

iOS底層探索 - 實例對象的建立

神經病院 Objective-C Runtime 入院第一天

iOS 開發筆記(七): 深刻理解 Autorelease

黑幕背後的Autorelease

ARC下dealloc過程及.cxx_destruct的探究

詳解iOS內存管理機制內部原理

相關文章
相關標籤/搜索