iOS概念攻堅(jiān)之路(三):內(nèi)存管理

前言

iOS 的內(nèi)存管理不止是 「引用計(jì)數(shù)表」。

iOS 開發(fā)者基本都知道 iOS 是通過「引用計(jì)數(shù)」來管理內(nèi)存的,但是也許并不知道 iOS 其他的內(nèi)存管理方式,比如 「Tagged Pointer」(帶標(biāo)記的指針),比如 「NONPOINTER_ISA」(非指針型 isa),這個(gè)要根據(jù)不同的場(chǎng)景進(jìn)行區(qū)分。

我們就這篇文章主要來談一談這三種內(nèi)存管理方式。

關(guān)于內(nèi)存

在說內(nèi)存管理之前,我們先來說一下關(guān)于內(nèi)存的概念。

內(nèi)存是計(jì)算機(jī)中重要的部件之一,它是與 CPU 進(jìn)行溝通的橋梁。計(jì)算機(jī)中所有的程序都是在內(nèi)存中進(jìn)行的。內(nèi)存(Menory)也被成為「內(nèi)存儲(chǔ)器」和「主存儲(chǔ)器」,其作用是用于暫時(shí)存放 CPU 中的運(yùn)算數(shù)據(jù),以及與硬盤等外部存儲(chǔ)器交換的數(shù)據(jù)。只要計(jì)算機(jī)在運(yùn)行中,CPU 就會(huì)把需要運(yùn)算的數(shù)據(jù)調(diào)到內(nèi)存中進(jìn)行運(yùn)算,當(dāng)運(yùn)算完成后 CPU 再將結(jié)果傳送出來,內(nèi)存的運(yùn)行也決定了計(jì)算機(jī)的穩(wěn)定運(yùn)行。(來自 度娘

在 App 啟動(dòng)后,系統(tǒng)會(huì)把 App 程序拷貝到內(nèi)存中,然后在內(nèi)存中執(zhí)行代碼。

內(nèi)存的概念大家多多少少都有點(diǎn)了解,我們也不說那么多。一塊內(nèi)存條,是一個(gè)從下至上、地址依次遞增結(jié)構(gòu)。來看一下內(nèi)存的分區(qū):

image

上面這張圖來自 這里

大致說一下 iOS 內(nèi)存分區(qū)的情況,五大區(qū)域:

  • 棧區(qū)(Stack)

    • 由編譯器自動(dòng)分配釋放,存放函數(shù)的參數(shù),局部變量的值等
    • 棧是向低地址擴(kuò)展的數(shù)據(jù)結(jié)構(gòu),是一塊連續(xù)的內(nèi)存區(qū)域
  • 堆區(qū)(Heap)

    • 由程序員分配釋放
    • 是向高地址擴(kuò)展的數(shù)據(jù)結(jié)構(gòu),是不連續(xù)的內(nèi)存區(qū)域
  • 全局區(qū)

    • 全局變量和靜態(tài)變量的存儲(chǔ)是放在一塊的,初始化的全局變量和靜態(tài)變量在一塊區(qū)域,未初始化的全局變量和未初始化的靜態(tài)變量在相鄰的另一塊區(qū)域
    • 程序結(jié)束后由系統(tǒng)釋放
  • 常量區(qū)

    • 常量字符串就是放在這里的
    • 程序結(jié)束后由系統(tǒng)釋放
  • 代碼區(qū)

    • 存放函數(shù)體的二進(jìn)制代碼

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

  1. 在 iOS 中,堆區(qū)的內(nèi)存是應(yīng)用程序共享的,堆中的內(nèi)存分配是系統(tǒng)負(fù)責(zé)的
  2. 系統(tǒng)使用一個(gè)鏈表來維護(hù)所有已經(jīng)分配的內(nèi)存空間(系統(tǒng)僅僅記錄,并不管理具體的內(nèi)容)
  3. 變量使用結(jié)束后,需要釋放內(nèi)存,OC 中是判斷引用計(jì)數(shù)是否為 0,如果是就說明沒有任何變量使用該空間,那么系統(tǒng)將其回收
  4. 當(dāng)一個(gè) app 啟動(dòng)后,代碼區(qū)、常量區(qū)、全局區(qū)大小就已經(jīng)固定,因此指向這些區(qū)的指針不會(huì)產(chǎn)生崩潰性的錯(cuò)誤。而堆區(qū)和棧區(qū)是時(shí)時(shí)刻刻變化的(堆的創(chuàng)建銷毀,棧的彈入彈出),所以當(dāng)使用一個(gè)指針指向這個(gè)區(qū)里面的內(nèi)存時(shí),一定要注意內(nèi)存是否已經(jīng)被釋放,否則會(huì)產(chǎn)生程序崩潰(也即是野指針報(bào)錯(cuò))

Tagged Pointer

為了節(jié)省內(nèi)存和提高執(zhí)行效率,蘋果提出了 Tagged Pointer 的概念。對(duì)于 64 位程序,引入 Tagged Pointer 后,相關(guān)邏輯能減少一半的內(nèi)存占用,以及 3 倍的訪問速度提升,100 倍的創(chuàng)建、銷毀速度提升。

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

我們先看看原有的對(duì)象為什么會(huì)浪費(fèi)內(nèi)存,假設(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é)的。而指針類型的大小通常也是與 CPU 位數(shù)相關(guān)的,一個(gè)指針?biāo)加玫膬?nèi)存在 32 位 CPU 下為 4 個(gè)字節(jié),在 64 位 CPU 下也是 8 個(gè)字節(jié)。

所以一個(gè)普通的 iOS 程序,如果沒有 Tagged Pointer 對(duì)象,從 32 位機(jī)器遷移到 64 位機(jī)器中后,雖然邏輯沒有任何變化,但這種 NSNumberNSDate 一類的對(duì)象所占用的內(nèi)存會(huì)翻倍。

我們?cè)賮砜纯葱噬系膯栴},為了存儲(chǔ)和訪問一個(gè) NSNumber 對(duì)象,我們需要在堆上為其分配內(nèi)存,另外還要維護(hù)它的引用計(jì)數(shù),管理它的生命周期。這些都給程序增加了額外的邏輯,造成了運(yùn)行效率上的損失。

所以為了改進(jìn)上面提到的內(nèi)存占用和效率問題,蘋果提出了 Tagged Pointer 對(duì)象,由于 NSNumber、NSDate 一類的變量本身的值需要占用的內(nèi)存大小常常不需要 8 個(gè)字節(jié),拿整數(shù)來說,4 個(gè)字節(jié)所能表示的有符號(hào)整數(shù)就可以達(dá)到 20 多億(2 ^ 31 = 2147483648,另外 1 位作為符號(hào)位),對(duì)于絕大多數(shù)情況都是可以處理的。

所以我們可以將一個(gè)對(duì)象的指針拆分成兩部分,一部分直接保存數(shù)據(jù),另一部分作為特殊標(biāo)記,表示這是一個(gè)特別的指針,不指向任何一個(gè)地址。

Tagged Pointer 特點(diǎn):

  1. Tagged Pointer 專門用來存儲(chǔ)小的對(duì)象,例如 NSNumberNSDate
  2. Tagged Pointer 指針的值不再是地址了,而是真正的值。所以,實(shí)際上它不再是一個(gè)對(duì)象了,它只是一個(gè)披著對(duì)象皮的普通變量而已。所以,它的內(nèi)存并不存儲(chǔ)在堆中,也不需要 mallocfree
  3. 在內(nèi)存讀取上有著 3 倍的效率,創(chuàng)建時(shí)比以前快 106 倍
  4. objc_msgSend 能識(shí)別 Tagged Pointer,比如 NSNumberintValue 方法,直接從指針提取數(shù)據(jù)
  5. 使用 Tagged Pointer 后,指針內(nèi)存儲(chǔ)的數(shù)據(jù)變成了 Tag + Data,也就是將數(shù)據(jù)直接存儲(chǔ)在了指針中

NONPOINTER_ISA

蘋果將 isa 設(shè)計(jì)成了聯(lián)合體,在 isa 中存儲(chǔ)了與該對(duì)象相關(guān)的一些內(nèi)存的信息,原因也如上面所說,并不需要 64 個(gè)二進(jìn)制位全部都用來存儲(chǔ)指針。

來看一下 isa 的結(jié)構(gòu):

// x86_64 架構(gòu)
struct {
    uintptr_t nonpointer        : 1;  // 0:普通指針,1:優(yōu)化過,使用位域存儲(chǔ)更多信息
    uintptr_t has_assoc         : 1;  // 對(duì)象是否含有或曾經(jīng)含有關(guān)聯(lián)引用
    uintptr_t has_cxx_dtor      : 1;  // 表示是否有C++析構(gòu)函數(shù)或OC的dealloc
    uintptr_t shiftcls          : 44; // 存放著 Class、Meta-Class 對(duì)象的內(nèi)存地址信息
    uintptr_t magic             : 6;  // 用于在調(diào)試時(shí)分辨對(duì)象是否未完成初始化
    uintptr_t weakly_referenced : 1;  // 是否被弱引用指向
    uintptr_t deallocating      : 1;  // 對(duì)象是否正在釋放
    uintptr_t has_sidetable_rc  : 1;  // 是否需要使用 sidetable 來存儲(chǔ)引用計(jì)數(shù)
    uintptr_t extra_rc          : 8;  // 引用計(jì)數(shù)能夠用 8 個(gè)二進(jìn)制位存儲(chǔ)時(shí),直接存儲(chǔ)在這里
};

// arm64 架構(gòu)
struct {
    uintptr_t nonpointer        : 1;  // 0:普通指針,1:優(yōu)化過,使用位域存儲(chǔ)更多信息
    uintptr_t has_assoc         : 1;  // 對(duì)象是否含有或曾經(jīng)含有關(guān)聯(lián)引用
    uintptr_t has_cxx_dtor      : 1;  // 表示是否有C++析構(gòu)函數(shù)或OC的dealloc
    uintptr_t shiftcls          : 33; // 存放著 Class、Meta-Class 對(duì)象的內(nèi)存地址信息
    uintptr_t magic             : 6;  // 用于在調(diào)試時(shí)分辨對(duì)象是否未完成初始化
    uintptr_t weakly_referenced : 1;  // 是否被弱引用指向
    uintptr_t deallocating      : 1;  // 對(duì)象是否正在釋放
    uintptr_t has_sidetable_rc  : 1;  // 是否需要使用 sidetable 來存儲(chǔ)引用計(jì)數(shù)
    uintptr_t extra_rc          : 19;  // 引用計(jì)數(shù)能夠用 19 個(gè)二進(jìn)制位存儲(chǔ)時(shí),直接存儲(chǔ)在這里
};

注意這里的 has_sidetable_rcextra_rc,has_sidetable_rc 表明該指針是否引用了 sidetable 散列表,之所以有這個(gè)選項(xiàng),是因?yàn)樯倭康囊糜?jì)數(shù)是不會(huì)直接存放在 SideTables 表中的,對(duì)象的引用計(jì)數(shù)會(huì)先存放在 extra_rc 中,當(dāng)其被存滿時(shí),才會(huì)存入相應(yīng)的 SideTables 散列表中,SideTables 中有很多張 SideTable,每個(gè) SideTable 也都是一個(gè)散列表,而引用計(jì)數(shù)表就包含在 SideTable 之中。

SideTables

原理

引用計(jì)數(shù)要么存放在 isaextra_rc 中,要么存放在引用計(jì)數(shù)表中,而引用計(jì)數(shù)表包含在一個(gè)叫 SideTable 的結(jié)構(gòu)中,它是一個(gè)散列表,也就是哈希表。而 SideTable 又包含在一個(gè)全局的 StripeMap 的哈希映射表中,這個(gè)表的名字叫 SideTables。

散列表(Hash table,也叫哈希表),是根據(jù)建(Key)而直接訪問在內(nèi)存存儲(chǔ)位置的數(shù)據(jù)結(jié)構(gòu)。也就是說,它通過一個(gè)關(guān)于鍵值得函數(shù),將所需查詢的數(shù)據(jù)映射到表中一個(gè)位置來訪問記錄,這加快了查找速度。這個(gè)映射函數(shù)稱作散列函數(shù),存放記錄的數(shù)組稱作散列表。

來看一下 NSObject.mm 中它們對(duì)應(yīng)的源碼:

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

// SideTable
struct SideTable {
    spinlock_t slock;           // 自旋鎖
    RefcountMap refcnts;        // 引用計(jì)數(shù)表
    weak_table_t weak_table;    // 弱引用表
    
    // other code ...
};

它們的關(guān)系如下圖:

image

一個(gè) SideTables 包含眾多 SideTable,每個(gè) SideTable 中又包含了三個(gè)元素,spinlock_t 自旋鎖、RefcountMap 引用計(jì)數(shù)表、weak_table_t 弱引用表。所以既然 SideTables 是一個(gè)哈希映射的表,為什么不用 SideTables 直接包含自旋鎖,引用計(jì)數(shù)表和弱引用表呢?這是因?yàn)樵诒姸嗑€程同時(shí)訪問這個(gè) SideTable 表的時(shí)候,為了保證數(shù)據(jù)安全,需要給其加上自旋鎖,如果只有一張 SideTable 的表,那么所有數(shù)據(jù)訪問都會(huì)出一個(gè)進(jìn)一個(gè),單線程進(jìn)行,非常影響效率,雖然自旋鎖已經(jīng)是效率非常高的鎖,這會(huì)帶來非常不好的用戶體驗(yàn)。針對(duì)這種情況,將一張 SideTable 分為多張表的 SideTables,再各自加鎖保證數(shù)據(jù)的安全,這樣就增加了并發(fā)量,提高了數(shù)據(jù)訪問的效率,這就是為什么一個(gè) SideTables 下涵蓋眾多 SideTable 表的原因。

自旋鎖:計(jì)算機(jī)科學(xué)用于多線程同步的一種鎖,線程會(huì)反復(fù)檢查鎖變量是否可用。由于線程在這一過程中保持執(zhí)行(沒有進(jìn)入休眠),因此是一種忙等。一旦獲取了自旋鎖,線程會(huì)一直保持該鎖,直至顯式釋放自旋鎖。

自旋鎖適用于小型數(shù)據(jù)、耗時(shí)很少的操作,速度很快。

弱引用表也是一張哈希表的結(jié)構(gòu),其內(nèi)部包含了每個(gè)對(duì)象對(duì)應(yīng)的弱引用表 weak_entry_t,而 weak_entry_t 是一個(gè)結(jié)構(gòu)體數(shù)組,其中包含的則是每一個(gè)對(duì)象弱引用的對(duì)象所對(duì)應(yīng)的弱引用指針。

如何進(jìn)行引用計(jì)數(shù)操作

當(dāng)需要去查找一個(gè)對(duì)象對(duì)應(yīng)的 SideTable 并進(jìn)行引用計(jì)數(shù)或者弱引用計(jì)數(shù)的操作時(shí),系統(tǒng)又是怎樣實(shí)現(xiàn)的呢?

當(dāng)一個(gè)對(duì)象訪問 SideTables 時(shí):

  1. 首先會(huì)取得對(duì)象的地址,將地址進(jìn)行哈希運(yùn)算,與 SideTablesSideTable 的個(gè)數(shù)取余,最后得到的結(jié)果就是該對(duì)象所要訪問的 SideTable
  2. 在取得的 SideTable 中的 RefcountMap 表中再進(jìn)行一次哈希查找,找到該對(duì)象在引用計(jì)數(shù)表中對(duì)應(yīng)的位置
  3. 如果該位置存在對(duì)應(yīng)的引用計(jì)數(shù),則對(duì)其進(jìn)行操作,如果沒有對(duì)應(yīng)的引用計(jì)數(shù),則創(chuàng)建一個(gè)對(duì)應(yīng)的 size_t 對(duì)象,其實(shí)就是一個(gè) uint 類型的無符號(hào)整型

引用計(jì)數(shù)

引用計(jì)數(shù)(Reference Count)是一個(gè)簡單而有效的管理對(duì)象生命周期的方式。當(dāng)我們創(chuàng)建一個(gè)新對(duì)象的時(shí)候,它的引用計(jì)數(shù)為 1,當(dāng)有一個(gè)新的指針指向這個(gè)對(duì)象時(shí),我們將其引用計(jì)數(shù)加 1,當(dāng)某個(gè)指針不再指向這個(gè)對(duì)象時(shí),我們將其引用計(jì)數(shù)減 1,當(dāng)對(duì)象的引用計(jì)數(shù)變?yōu)?0 時(shí),說明這個(gè)對(duì)象不再被任何指針指向了,這個(gè)時(shí)候我們就可以將對(duì)象銷毀,回收內(nèi)存。

上面是唐巧的 理解 iOS 的內(nèi)存管理 中對(duì)引用計(jì)數(shù)的一個(gè)定義,簡單來說就是采取計(jì)數(shù)的方式對(duì)內(nèi)存進(jìn)行管理,內(nèi)存首先需要被創(chuàng)建出來,然后有人用這塊內(nèi)存,計(jì)數(shù) +1,那個(gè)人不用了,計(jì)數(shù) -1,如果計(jì)數(shù)為 0,釋放它。

當(dāng)然,創(chuàng)建、使用、釋放是有一個(gè)規(guī)則的,來看一下 iOS 中內(nèi)存管理的思考方式:

  • 自己生成的對(duì)象,自己所持有
  • 非自己生成的對(duì)象,自己也能持有
  • 不再需要自己持有的對(duì)象時(shí)釋放
  • 非自己持有的對(duì)象無法釋放

與之對(duì)應(yīng)的 Objective-C 方法:

對(duì)象操作 Objective-C 方法
生成并持有對(duì)象 alloc/new/copy/mutableCopy 等方法
持有對(duì)象 retain 方法
釋放對(duì)象 release 方法
廢棄對(duì)象 dealloc 方法

這些有關(guān) Objective-C 內(nèi)存管理的方法,實(shí)際上不包括在 Objective-C 語言中,而是包含在 Cocoa 框架中用于 OS X,iOS 應(yīng)用開發(fā),swift 也采用引用計(jì)數(shù)的方式進(jìn)行內(nèi)存管理。Cocoa 框架中 Foundation 框架類庫的 NSObject 類擔(dān)負(fù)內(nèi)存管理的職責(zé)。Objective-C 內(nèi)存管理中的 alloc/retain/release/dealloc 方法分別指代 NSObject 類的 +alloc、-retain、-release、-dealloc 方法。

而引用計(jì)數(shù)又分為 MRC(Manual Reference Counting,手動(dòng)引用計(jì)數(shù))ARC(Automatic Reference Counting,自動(dòng)引用計(jì)數(shù))。

我們來看一下官方對(duì)于自動(dòng)引用計(jì)數(shù)的說明:

在 Objective-C 中采用 Automatic Reference Counting(ARC)機(jī)制,讓編譯器來進(jìn)行內(nèi)存管理。在新一代 Apple LLVM 編譯器(LLVM 3.0 或以上)中設(shè)置 ARC 為有效狀態(tài),就無需再次鍵入 retain 或者 release 代碼,這在降低程序崩潰、內(nèi)存泄漏等風(fēng)險(xiǎn)的同時(shí),很大程度上減少了開發(fā)程序的工作量。編譯器完全清楚目標(biāo)對(duì)象,并能立刻釋放那些不再被使用的對(duì)象,如此一來,應(yīng)用程序?qū)⒕哂锌深A(yù)測(cè)性,且能流暢運(yùn)行,速度也將大幅提升。

其實(shí)最主要的是一點(diǎn):

在 LLVM 編譯器中設(shè)置 ARC 為有效狀態(tài),就無需再次鍵入 retain 或者是 release 代碼。

那么我們也就知道了 MRC 是怎么回事了,MRC 就是需要程序員手動(dòng)插入 retainrelease 等管理內(nèi)存的代碼,不過現(xiàn)在 MRC 已經(jīng)屬于遠(yuǎn)古時(shí)代的事情了,這里只是順便提提,我們主要看 ARC,ARC 其實(shí)做的事情不止是自動(dòng)插入管理內(nèi)存的方法,還做了一些優(yōu)化,我們放到后面一點(diǎn)講。我們先來看看 alloc/retain/release/dealloc 這幾個(gè)方法的大致實(shí)現(xiàn),這里有一份編譯好的 runtime 源碼,版本是 objc4-750,或者大家可以到 opensource.apple 去下載。

alloc

NSObject 中類方法 alloc 做的事情:

首先看看 alloc 方法的實(shí)現(xiàn):

+ (id)alloc {
    return _objc_rootAlloc(self);
}

alloc 中調(diào)用 _objc_rootAlloc()。

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

_objc_rootAlloc 中調(diào)用 callAlloc()。

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

省略了一部分代碼,callAlloc 中會(huì)調(diào)用 class_createInstance() 。

id 
class_createInstance(Class cls, size_t extraBytes)
{
    return _class_createInstanceFromZone(cls, extraBytes, nil);
}

class_createInstance() 中直接調(diào)用 _class_createInstanceFromZone,調(diào)用 calloc 方法分配內(nèi)存。

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);  // 此時(shí)分配內(nèi)存
    obj->initInstanceIsa(cls, hasCxxDtor);
    return obj;
}

_class_createInstanceFromZone 中會(huì)調(diào)用 obj->initInstanceIsa(),以下就是初始化的方法了,此時(shí)內(nèi)存已經(jīng)分配。

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

initInstanceIsa() 中調(diào)用 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;
    }
}

這里就是對(duì) isa 的一個(gè)初始化。

所以關(guān)于 alloc 方法,其大概步驟如下:

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

關(guān)于 NSObject 的源碼解析大家可以看看以下兩篇文章:

iOS底層探索 - 實(shí)例對(duì)象的創(chuàng)建

iOS NSObject.mm 源碼解析

slowpathfastpath

這里我想提一嘴 slowpathfastpath,看一下 callAlloc 的完整實(shí)現(xiàn):

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,其實(shí)這兩個(gè)都是宏定義,與代碼邏輯本身無關(guān),定義如下:

// x 很可能不為 0,希望編譯器進(jìn)行優(yōu)化
#define fastpath(x) (__builtin_expect(bool(x), 1))
// x 很可能為 0,希望編譯器進(jìn)行優(yōu)化
#define slowpath(x) (__builtin_expect(bool(x), 0))

其實(shí)它們是所謂的快路徑和慢路徑,為了解釋這個(gè),我們來看一段代碼:

if (x)
    return 1;
else 
    return 39;

由于計(jì)算機(jī)并非一次只讀取一條指令,而是讀取多條指令,所以在讀到 if 語句時(shí)也會(huì)把 return 1 讀取進(jìn)來。如果 x 為 0,那么會(huì)重新讀取 return 39,重讀指令相對(duì)來說比較耗時(shí)。

如果 x 有非常大的概率是 0,那么 return 1 這條指令每次不可避免的會(huì)被讀取,并且實(shí)際上幾乎沒有機(jī)會(huì)執(zhí)行,造成了不必要的指令重讀。

因此,在蘋果定義的兩個(gè)宏中,fastpath(x) 依然返回 x,只是告訴編譯器 x 的值一般不為 0,從而編譯可以進(jìn)行優(yōu)化。同理,slowpath(x) 表示 x 的值很可能為 0,希望編譯器進(jìn)行優(yōu)化。

這個(gè)例子的講解來自 bestsswifter 的 深入理解GCD,大家感興趣可以看看。

所以以下代碼的解釋就出來了:

// 很可能 cls 是有值的,編譯器可以不用每次都讀取 return nil 指令
 if (slowpath(checkNil && !cls)) return nil;

fastpath 也是同樣的機(jī)制,但是大家要知道,當(dāng) checkNil && !cls 判斷成立的時(shí)候,return nil 指令還是會(huì)被讀取,然后執(zhí)行的。

還有一個(gè)就是 #if __OBJ2__#endif,如果查看源碼的話,還會(huì)碰到 #if !__LP64__#elif 1#else 這類的宏判斷,這是因?yàn)樘O果針對(duì)不同的版本做了不同的實(shí)現(xiàn),比如 32 位架構(gòu)下和 64 位架構(gòu)下的實(shí)現(xiàn),有一些代碼在不同的情況下是不需要參與編譯的,其實(shí)也跟我們平時(shí)的 if-else 是一樣的概念。

retain & release

retain 方法用于增加引用計(jì)數(shù),release 用于減少引用計(jì)數(shù)。那么引用計(jì)數(shù)存儲(chǔ)在哪里?其實(shí)有兩個(gè)地方,一個(gè)是 NONPOINTER_ISA,也就是非指針型 isa 中,isa 有個(gè) extra_rc 屬性,就是用于存放引用計(jì)數(shù)的,在 ARM 64 下,extra_rc 占 19 位。

extra_rc 只會(huì)保存額外的自動(dòng)引用計(jì)數(shù),對(duì)象的實(shí)際的引用計(jì)數(shù)會(huì)在這個(gè)基礎(chǔ)上 +1。當(dāng) isaextra_rc 中存不下的時(shí)候,會(huì)使用 SideTable 來存儲(chǔ),SideTable 中包含了我們大家都知道的引用計(jì)數(shù)表。

通過引用計(jì)數(shù)表管理引用計(jì)數(shù)的好處在于:

  1. 對(duì)象用內(nèi)存塊分配無需考慮內(nèi)存塊頭部
  2. 引用計(jì)數(shù)表各記錄中存有內(nèi)存塊地址,可從各個(gè)記錄追溯到各對(duì)象的內(nèi)存塊

第二點(diǎn)在調(diào)試時(shí)有著舉足輕重的作用,即使出現(xiàn)故障導(dǎo)致對(duì)象占用的內(nèi)存塊損壞,但只要引用計(jì)數(shù)表沒有被破壞,就能夠確認(rèn)各內(nèi)存塊的位置。另外,在利用工具檢測(cè)內(nèi)存泄漏時(shí),引用計(jì)數(shù)表的記錄也有助于檢測(cè)各個(gè)對(duì)象的持有者是否存在。

如果想了解 retainrelease 的底層實(shí)現(xiàn),可以看一下 黑箱中的 retain 和 release

autorelease

簡介

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

在計(jì)算機(jī)編程領(lǐng)域,自動(dòng)變量(Automatic Variable)指的是局部作用域變量,具體來說即是在控制流進(jìn)入變量作用域時(shí)系統(tǒng)自動(dòng)為其分配存儲(chǔ)空間,并在離開作用域時(shí)釋放空間的一類變量

程序執(zhí)行時(shí),若某自動(dòng)變量超出其作用域,該自動(dòng)變量將被自動(dòng)廢棄。

autorelease 會(huì)像 C 語言的自動(dòng)變量那樣來對(duì)待對(duì)象實(shí)例,當(dāng)超出其作用域(相當(dāng)于變量作用域)時(shí),對(duì)象實(shí)例的 release 實(shí)例方法被調(diào)用。另外,同 C 語言的自動(dòng)變量不同的是,編程人員可以設(shè)定變量的作用域。


需要被自動(dòng)釋放的對(duì)象會(huì)被添加到離它最近的自動(dòng)釋放池中(AutoreleasePool),我們先明確什么對(duì)象會(huì)自動(dòng)加入自動(dòng)釋放池:

  1. MRC 下需要對(duì)象調(diào)用 autorelease 才會(huì)入池,ARC 下可以通過 __autoreleasing 修飾符,否則的話看方法名,通過 alloc/new/copy/mutablecopy 以外的方法取得的對(duì)象,編譯器幫我們自動(dòng)加入 autoreleasepool。(使用 alloc/new/copy/mutablecopy 方法進(jìn)行初始化時(shí),由系統(tǒng)管理對(duì)象,在適當(dāng)?shù)奈恢?release,不加入 autoreleasepool)
  2. 使用 array 會(huì)自動(dòng)將返回對(duì)象注冊(cè)到 autoreleasepool
  3. __weak 修飾的對(duì)象,為了保證在引用時(shí)不被廢棄,會(huì)被注冊(cè)到 autoreleasepool 中
  4. id 的指針或?qū)ο蟮闹羔?,在沒有顯式指定時(shí)會(huì)被注冊(cè)到 autoreleasepool 中

那 Autorelease 的對(duì)象什么時(shí)候釋放?

在沒有手動(dòng)添加 AutoreleasePool 的情況下,Autorelease 對(duì)象是在當(dāng)前的 runloop 迭代結(jié)束時(shí)釋放的,而它能夠釋放的原因是系統(tǒng)在每個(gè) runloop 迭代中都加入了自動(dòng)釋放池的 Push 和 Pop。

App 啟動(dòng)后,蘋果在主線程 runLoop 里注冊(cè)了兩個(gè) Observer,其回調(diào)都是 _wrapRunLoopWithAutoreleasePoolHandler()。

第一個(gè) Observer 監(jiān)視的事件是 Entry(即將進(jìn)入 loop),其回調(diào)會(huì)調(diào)用 _objc_autoreleasePoolPush() 創(chuàng)建自動(dòng)釋放吃。其 order-2147483647,優(yōu)先級(jí)最高,保證創(chuàng)建釋放池發(fā)生在其他所有回調(diào)之前。

第二個(gè) Observer 監(jiān)視了兩個(gè)事件:BeforeWaiting(準(zhǔn)備進(jìn)入休眠) 時(shí)調(diào)用 _objc_autoreleasePoolPop()_objc_autoreleasePoolPush() 釋放舊的池并創(chuàng)建新池;Exit(即將退出 Loop) 時(shí)調(diào)用 _objc_autoreleasePoopPop() 來釋放自動(dòng)釋放池,這個(gè) Observerorder2147483647,優(yōu)先級(jí)最低,保證釋放池釋放發(fā)生在其他所有回調(diào)之后。

在主線程執(zhí)行的代碼,通常是寫在諸如事件回調(diào)、Timer 回調(diào)內(nèi)的。這些回調(diào)會(huì)被 RunLoop 創(chuàng)建好的 AutoreleasePool 環(huán)繞著,所以不會(huì)出現(xiàn)內(nèi)存泄漏,開發(fā)者也不必顯式創(chuàng)建 Pool。

使用方法

autorelease 的具體使用方法如下:

  1. 生成并持有 NSAutoreleasePool 對(duì)象
  2. 調(diào)用已分配對(duì)象的 autorelease 實(shí)例方法
  3. 廢棄 NSAutoreleasePool 對(duì)象

NSAutoreleasePool 對(duì)象的生存周期相當(dāng)于 C 語言變量的作用域,對(duì)于所有調(diào)用過 autorelease 實(shí)例方法的對(duì)象,在廢棄 NSAutoreleasePool 對(duì)象時(shí),都將調(diào)用 release 實(shí)例方法。

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

id obj = [[NSObject alloc] init];

[obj autorelease];

[pool drain];    // 等同于 [obj release]

在 Cocoa 框架中,相當(dāng)于程序主循環(huán)的 NSRunLoop 或者在其他程序可運(yùn)行的地方,對(duì) NSAutoreleasePool 對(duì)象進(jìn)行生成、持有和廢棄處理。因此,開發(fā)者一般不需要使用手動(dòng)創(chuàng)建釋放池。Objective-C 的 main.mUIApplicationMain 方法就是被一個(gè)自動(dòng)釋放池環(huán)繞著的,也就是說,整個(gè) iOS 應(yīng)用都是包含在一個(gè)自動(dòng)釋放池 block 中:

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

不過,在大量產(chǎn)生 autorelease 的對(duì)象時(shí),只要不廢棄 NSAutoreleasePool 對(duì)象,那么生成的對(duì)象就不能被釋放,因此有時(shí)會(huì)由于內(nèi)存不足而到達(dá)內(nèi)存峰值。典型的例子是讀入大量圖片的同時(shí)改變其尺寸,圖像文件讀入到 NSData 對(duì)象,并從中生成 UIImage 對(duì)象,改變?cè)搶?duì)象尺寸后生成新的 UIImage 對(duì)象。這種情況下,就會(huì)大量產(chǎn)生 autorelease 的對(duì)象:

for (int i = 0; i < 圖像數(shù) ; ++i) {
    /* 讀入圖像
     * 大量產(chǎn)生 autorelease 的對(duì)象
     * 由于沒有廢棄 NSAutoreleasePool 對(duì)象
     * 最終導(dǎo)致內(nèi)存不足!
     */
}

在這種情況下,有必要在適當(dāng)?shù)牡胤缴?、持有或廢棄 NSAutoreleasePool 對(duì)象:

for (int i = 0; i < 圖像數(shù); ++i) {
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
    
    /*
     * 讀入圖像
     * 大量產(chǎn)生 autorelease 的對(duì)象
     */
     
    [pool drain];
    
    /*
     * 通過 [pool drain],
     * autorelease 的對(duì)象被一起 release。
     */
}

在 ARC 下我們使用 @autoreleasepool{} 將代碼環(huán)繞即可。

原理

那么系統(tǒng)是如何實(shí)現(xiàn) Autorelease 的,在 ARC 下,我們使用 @autoreleasepool{} 來使用一個(gè) AutoreleasePool,隨后編譯器將其改寫成下面的樣子:

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

這兩個(gè)函數(shù)都是對(duì) AutoreleasePoolPage 的簡單封裝,所以自動(dòng)釋放機(jī)制的核心就在于這個(gè)類。

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 是一個(gè) C++ 實(shí)現(xiàn)的類。

  • AutoreleasePool 并沒有單獨(dú)的結(jié)構(gòu),而是由若干個(gè) AutoreleasePoolPage 以雙向鏈表的形式組合而成(分別對(duì)應(yīng)結(jié)構(gòu)中的 parent 指針和 child 指針)
  • AutoreleasePool 是按線程一一對(duì)應(yīng)的(結(jié)構(gòu)中的 thread 指針指向當(dāng)前線程)
  • AutoreleasePoolPage 每個(gè)對(duì)象會(huì)開辟 4096 字節(jié)內(nèi)存(也就是虛擬內(nèi)存一頁的大小),除了上面的實(shí)例變量所占空間,剩下的空間全部用來儲(chǔ)存 autorelease 對(duì)象的地址
  • 上面的 id *next 指針作為游標(biāo)(哨兵對(duì)象)指向棧頂最新 add 進(jìn)來的 autorelease 對(duì)象的下一個(gè)位置
  • 一個(gè) AutoreleasePoolPage 的內(nèi)存被占滿時(shí),會(huì)新建一個(gè) AutoreleasePoolPage 對(duì)象,連接鏈表,后來的 autorelease 對(duì)象會(huì)被添加到新的 page

所以,若當(dāng)前線程中只有一個(gè) AutoreleasePoolPage 對(duì)象,并記錄了很多 autorelease 對(duì)象地址,如下圖:

image

圖中的情況,這一頁再加入一個(gè) autorelease 對(duì)象就要滿了(也就是 next 指針馬上指向棧頂),這時(shí)就要執(zhí)行上面說的操作,建立下一頁 page 對(duì)象,與這一頁鏈表連接完成后,新的 pagenext 指針被初始化在棧底(begin 的位置),然后繼續(xù)向棧頂添加新對(duì)象。

所以,向一個(gè)對(duì)象發(fā)送 autorelease 消息,就是將這個(gè)對(duì)象加入到當(dāng)前的 AutoreleasePoolPage 的棧頂 next 指針指向的位置。


每當(dāng)進(jìn)行一次 objc_autoreleasePoolPush 調(diào)用時(shí),runtime 向當(dāng)前的 AutoreleasePoolpageadd 進(jìn)一個(gè) 哨兵對(duì)象,值為 0(也就是個(gè) nil),那么這一個(gè) page 就變成了下面的樣子:

image

objc_autoreleasePoolPush 的返回值正是這個(gè)哨兵對(duì)象的地址,被 objc_autoreleasePoolPop(哨兵對(duì)象) 作為入?yún)ⅲ裕?/p>

  1. 根據(jù)傳入的哨兵對(duì)象地址找到哨兵對(duì)象所處的 page
  2. 在當(dāng)前 page 中,將晚于哨兵對(duì)象插入的所有 autorelease 對(duì)象都發(fā)送一次 -release 消息,并向回移動(dòng) next 指針到正確位置,從最新加入的對(duì)象一直向前清理,可以向前跨越若干個(gè) page,直到哨兵對(duì)象所在的 page

剛才的 objc_autoreleasePoopPop 執(zhí)行后,最終變成了下面的樣子:

image

知道了上面的原理,嵌套的 AutoreleasePool 就非常簡單了,pop 的時(shí)候總會(huì)釋放到上次 push 的位置,多層的 pool 就是多個(gè)哨兵對(duì)象而已,就像剝洋蔥一樣,每次一層,互不影響。

在對(duì)象的引用計(jì)數(shù)歸零時(shí),會(huì)調(diào)用 dealloc 方法回收對(duì)象。

原理部分的講解來自于孫源大神的 黑幕背后的Autorelease,講的非常好,大家可以看看。

另外說一下 ARC 中對(duì) autoreleaseretain 的一些優(yōu)化:

如果 ARC 在運(yùn)行時(shí)檢測(cè)到類函數(shù)中的 autorelease 后緊跟著一個(gè) retain 操作,此時(shí)不直接調(diào)用對(duì)象的 autorelease 方法,而是改為調(diào)用 objc_autoreleaseReturnValue。objc_autoreleaseReturnValue 會(huì)檢視當(dāng)前方法返回之后將要執(zhí)行的那段代碼,若那段代碼要在返回對(duì)象上執(zhí)行 retain 操作,則設(shè)置全局?jǐn)?shù)據(jù)結(jié)構(gòu)中的一個(gè)標(biāo)志位,而不執(zhí)行 autorelease 操作,與之相似,如果方法返回了一個(gè)自動(dòng)釋放的對(duì)象,而調(diào)用方法的代碼要保留此對(duì)象,那么此時(shí)不直接執(zhí)行 retain,而是改為執(zhí)行 objc_retainAutoreleasedReturnValue 函數(shù)。此函數(shù)要檢測(cè)剛才提到的標(biāo)志位,若已經(jīng)置位,則不執(zhí)行 retain 操作,設(shè)置并檢測(cè)標(biāo)志位,要比調(diào)用 autoreleaseretain 更快。

dealloc

當(dāng)對(duì)象的引用計(jì)數(shù)為 0 時(shí),也就是對(duì)象的所有者都不持有該對(duì)象,該對(duì)象被廢棄時(shí),不管 ARC 是否有效,都會(huì)調(diào)用對(duì)象的 dealloc 方法,對(duì)對(duì)象進(jìn)行析構(gòu)。

簡單列舉一下 dealooc 的調(diào)用流程,大家可以結(jié)合 runtime 源碼來看:

  1. dealloc 調(diào)用流程

    1. 首先調(diào)用 _objc_rootDealloc()
    2. 接下來調(diào)用 rootDealloc()
    3. 這時(shí)候會(huì)判斷是否可以被釋放,判斷的依據(jù)主要有 5 個(gè):
      • NONPointer_ISA // 是否是非指針類型 isa
      • weakly_reference // 是否有若引用
      • has_assoc // 是否有關(guān)聯(lián)對(duì)象
      • has_cxx_dtor // 是否有 c++ 相關(guān)內(nèi)容
      • has_sidetable_rc // 是否使用到 sidetable
    4. 如果沒有之前 5 種情況的任意一種,則可以執(zhí)行釋放操作,C 函數(shù)的 free()
    5. 執(zhí)行完畢
  2. objc_dispose() 調(diào)用流程

    1. 直接調(diào)用 objc_destructInstance()
    2. 之后調(diào)用 C 函數(shù)的 free()
  3. objc_destructInstance() 調(diào)用流程

    1. 先判斷 hasCxxDtor,如果有 c++ 相關(guān)內(nèi)容,要調(diào)用 object_cxxDestruct(),銷毀 c++ 相關(guān)內(nèi)容
    2. 再判斷 hasAssociatedObjects,如果有關(guān)聯(lián)對(duì)象,要調(diào)用 object_remove_associations(),銷毀關(guān)聯(lián)對(duì)象的一系列操作
    3. 然后調(diào)用 clearDeallocating()
    4. 執(zhí)行完畢
  4. clearDeallocating() 調(diào)用流程

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

總結(jié)

來做一個(gè)小總結(jié)吧。

內(nèi)存分區(qū):

  • 棧區(qū)
  • 堆區(qū)
  • 全局區(qū)
    • 未初始化
    • 已初始化
  • 常量區(qū)
  • 代碼區(qū)

內(nèi)存管理方式:

  • Tagged Pointer(小對(duì)象)
  • NONPOINTER_ISA (指針中存放與該對(duì)象內(nèi)存相關(guān)的信息)
  • 散列表(引用計(jì)數(shù)表、弱引用表)

這篇文章講了內(nèi)存分區(qū)、內(nèi)存管理方式、SideTables 原理、引用計(jì)數(shù)、alloc/retain/release/autorelease/dealloc 內(nèi)存相關(guān)方法的介紹,以及自動(dòng)釋放池。

參考文章

【iOS】內(nèi)存五大區(qū)域

深入淺出-iOS內(nèi)存分配與分區(qū)

理解 iOS 的內(nèi)存管理

深入理解 GCD

iOS底層探索 - 實(shí)例對(duì)象的創(chuàng)建

神經(jīng)病院 Objective-C Runtime 入院第一天

iOS 開發(fā)筆記(七): 深入理解 Autorelease

黑幕背后的Autorelease

ARC下dealloc過程及.cxx_destruct的探究

詳解iOS內(nèi)存管理機(jī)制內(nèi)部原理

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Swift1> Swift和OC的區(qū)別1.1> Swift沒有地址/指針的概念1.2> 泛型1.3> 類型嚴(yán)謹(jǐn) 對(duì)...
    cosWriter閱讀 11,680評(píng)論 1 32
  • 1、內(nèi)存布局 stack:方法調(diào)用 heap:通過alloc等分配對(duì)象 bss:未初始化的全局變量等。 data:...
    AKyS佐毅閱讀 1,716評(píng)論 0 19
  • 29.理解引用計(jì)數(shù) Objective-C語言使用引用計(jì)數(shù)來管理內(nèi)存,也就是說,每個(gè)對(duì)象都有個(gè)可以遞增或遞減的計(jì)數(shù)...
    Code_Ninja閱讀 1,752評(píng)論 1 3
  • 前言 從我開始學(xué)習(xí)iOS的時(shí)候,身邊的朋友、網(wǎng)上的博客都告訴我iOS的內(nèi)存管理是依靠引用計(jì)數(shù)的,然后說引用計(jì)數(shù)大于...
    蓋世英雄_ix4n04閱讀 668評(píng)論 0 1
  • 《刻意練習(xí)》這本書中提到:保持動(dòng)機(jī)也許是每個(gè)投入到有目的的訓(xùn)練或者刻意練習(xí)中的人最終要面對(duì)的最大問題。 作者給出了...
    跟娟姐學(xué)蛻變閱讀 1,968評(píng)論 1 12

友情鏈接更多精彩內(nèi)容