iOS 進階—— iOS 記憶體管理 & Block

發表於2017-02-09

第一篇 iOS 記憶體管理

1 似乎每個人在學習 iOS 過程中都考慮過的問題

  1. alloc retain release delloc 做了什麼?
  2. autoreleasepool 是怎樣實現的?
  3. __unsafe_unretained 是什麼?
  4. Block 是怎樣實現的
  5. 什麼時候會引起迴圈引用,什麼時候不會引起迴圈引用?

所以我將在本篇博文中詳細的從 ARC 解釋到 iOS 的記憶體管理,以及 Block 相關的原理、原始碼。

2 從 ARC 說起

說 iOS 的記憶體管理,就不得不從 ARC(Automatic Reference Counting / 自動引用計數) 說起, ARC 是 WWDC2011 和 iOS5 引入的變化。ARC 是 LLVM 3.0 編譯器的特性,用來自動管理記憶體。

與 Java 中 GC 不同,ARC 是編譯器特性,而不是基於執行時的,所以 ARC 其實是在編譯階段自動幫開發者插入了管理記憶體的程式碼,而不是實時監控與回收記憶體。

11blog_iOS進階——iOS 記憶體管理&Block-01

ARC 的記憶體管理規則可以簡述為:

  1. 每個物件都有一個『被引用計數』
  2. 物件被持有,『被引用計數』+1
  3. 物件被放棄持有,『被引用計數』-1
  4. 『引用計數』=0,釋放物件

3 你需要知道

  1. 包含 NSObject 類的 Foundation 框架並沒有公開
  2. Core Foundation 框架原始碼,以及通過 NSObject 進行記憶體管理的部分原始碼是公開的。
  3. GNUstep 是 Foundation 框架的互換框架

GNUstep 也是 GNU 計劃之一。將 Cocoa Objective-C 軟體庫以自由軟體方式重新實現
某種意義上,GNUstep 和 Foundation 框架的實現是相似的
通過 GNUstep 的原始碼來分析 Foundation 的記憶體管理

4 alloc retain release dealloc 的實現

4.1 GNU – alloc

檢視 GNUStep 中的 alloc 函式。

GNUstep/modules/core/base/Source/NSObject.m alloc:

GNUstep/modules/core/base/Source/NSObject.m NSAllocateObject:

NSAllocateObject 函式通過呼叫 NSZoneCalloc 函式來分配存放物件所需的空間,之後將該記憶體空間置為 nil,最後返回作為物件而使用的指標。

我們將上面的程式碼做簡化整理:

GNUstep/modules/core/base/Source/NSObject.m alloc 簡化版本:

alloc 類方法用 struct obj_layout 中的 retained 整數來儲存引用計數,並將其寫入物件的記憶體頭部,該物件記憶體塊全部置為 0 後返回。

一個物件的表示便如下圖:

12blog_iOS進階——iOS 記憶體管理&Block-02

4.2 GNU – retain

GNUstep/modules/core/base/Source/NSObject.m retainCount:

GNUstep/modules/core/base/Source/NSObject.m retain:

以上程式碼中, NSIncrementExtraRefCount 方法首先寫入了當 retained 變數超出最大值時發生異常的程式碼(因為 retained 是 NSUInteger 變數),然後進行 retain ++ 程式碼。

4.3 GNU – release

和 retain 相應的,release 方法做的就是 retain --

GNUstep/modules/core/base/Source/NSObject.m release

4.4 GNU – dealloc

dealloc 將會對物件進行釋放。

GNUstep/modules/core/base/Source/NSObject.m dealloc:

4.5 Apple 實現

在 Xcode 中 設定 Debug -> Debug Workflow -> Always Show Disassenbly 開啟。這樣在打斷點後,可以看到更詳細的方法呼叫。

通過在 NSObject 類的 alloc 等方法上設定斷點追蹤可以看到幾個方法內部分別呼叫了:

retainCount

__CFdoExternRefOperation
CFBasicHashGetCountOfKey

retain

__CFdoExternRefOperation
CFBasicHashAddValue

release

__CFdoExternRefOperation
CFBasicHashRemoveValue

可以看到他們都呼叫了一個共同的 __CFdoExternRefOperation 方法。

該方法從字首可以看到是包含在 Core Foundation,在 CFRuntime.c 中可以找到,做簡化後列出原始碼:

CFRuntime.c __CFDoExternRefOperation:

所以 __CFDoExternRefOperation 是針對不同的操作,進行具體的方法呼叫,如果 op 是 OPERATION_retain,就去掉用具體實現 retain 的方法。

BasicHash 這樣的方法名可以看出,其實引用計數表就是雜湊表。

key 為 hash(物件的地址) value 為 引用計數。

下圖是 Apple 和 GNU 的實現對比:

13blog_iOS進階——iOS 記憶體管理&Block-03

5 autorelease 和 autorelaesepool

在蘋果對於 NSAutoreleasePool 的文件中表示:

每個執行緒(包括主執行緒),都維護了一個管理 NSAutoreleasePool 的棧。當創先新的 Pool 時,他們會被新增到棧頂。當 Pool 被銷燬時,他們會被從棧中移除。
autorelease 的物件會被新增到當前執行緒的棧頂的 Pool 中。當 Pool 被銷燬,其中的物件也會被釋放。
當執行緒結束時,所有的 Pool 被銷燬釋放。

對 NSAutoreleasePool 類方法和 autorelease 方法打斷點,檢視其執行過程,可以看到呼叫了以下函式:

[NSAutoreleasePool showPools] 可以看到當前執行緒所有 pool 的情況:

objc4 中可以檢視到 AutoreleasePoolPage:

AutoreleasePoolPage 以雙向連結串列的形式組合而成(分別對應結構中的 parent 指標和 child 指標)。
thread 指標指向當前執行緒。
每個 AutoreleasePoolPage 物件會開闢4096位元組記憶體(也就是虛擬記憶體一頁的大小),除了上面的例項變數所佔空間,剩下的空間全部用來儲存autorelease物件的地址。
next 指標指向下一個 add 進來的 autorelease 的物件即將存放的位置。
一個 Page 的空間被佔滿時,會新建一個 AutoreleasePoolPage 物件,連線連結串列。

14blog_iOS進階——iOS 記憶體管理&Block-04

6 __unsafe_unretained

有時候我們除了 __weak__strong 之外也會用到 __unsafe_unretained 這個修飾符,那麼我們對 __unsafe_unretained 瞭解多少?

__unsafe_unretained 是不安全的所有權修飾符,儘管 ARC 的記憶體管理是編譯器的工作,但附有 __unsafe_unretained 修飾符的變數不屬於編譯器的記憶體管理物件。賦值時即不獲得強引用也不獲得弱引用

來執行一段程式碼:

執行結果:

對程式碼進行詳細分析:

所以,最後的 NSLog 只是碰巧正常執行,如果錯誤訪問,會造成 crash
在使用 __unsafe_unretained 修飾符時,賦值給附有 __strong 修飾符變數時,要確保物件確實存在

第二篇 Block

花幾分鐘時間看下面三個小題目,寫下你的答案。

15blog_iOS進階——iOS 記憶體管理&Block-05

這個三個小題目,我在整理此片博文之前給了三位朋友去解答,最後的結果,除了一位朋友 3 題全部正確,其他兩個朋友均只答中 1 題。

說明還是有很多 iOS 的朋友對於 Block 並沒有透徹理解。本篇博文會對 Block 進行詳細的解說。

1 Block 使用的簡單規則

先了解簡單規則,再去分析原理和實現:

Block 中,Block 表示式截獲所使用的自動變數的值,即儲存該自動變數的瞬間值
修飾為 __block 的變數,在捕獲時,獲取的不再是瞬間值

至於 Why,後面將會繼續說。

2 Block 的實現

Block 是帶有自動變數(區域性變數)的匿名函式。
Block 表示式很簡單,總體可以描述為:『^ 返回值型別 引數列表 表示式』。
但是 Block 並不是 Objective-C 中才有的語法,這是怎麼一回事?

clang 編譯器提供給程式設計師瞭解 Objective-C 背後機制的方法,通過 clang 的轉換可以看到 Block 的實現原理。

通過 clang -rewrite-objc yourfile.m clang 將會把 Objective-C 的程式碼轉換成 C 語言的程式碼。

2.1 Block 基本實現剖析

用 Xcode 建立 Command Line 專案,寫如下程式碼:

用 clang 轉換:

16blog_iOS進階——iOS 記憶體管理&Block-06

以上是轉換後的程式碼,不要方,一段一段看。

可以看到,Block 內部的內容,被轉換成了一個普通的靜態函式 __main_func_0

再看其他部分:

main.cpp __block_impl:

__block_impl 結構體包括了一些標誌、今後版本升級預留的變數函式指標


main.cpp __main_block_desc_0:

__main_block_desc_0 結構體包括了今後版本升級預留的變數、block 大小。


main.cpp __main_block_impl_0:

 

__main_block_impl_0 結構體含有兩個成員變數,分別是 __block_impl__main_block_desc_0例項變數。

此外,還含有一個構造方法。該構造方法在 main 函式中被如下呼叫:

main.cpp __main_block_impl_0 建構函式的呼叫:

1
2
void (*blk)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0,
&__main_block_desc_0_DATA));

去掉各種強制轉換,做簡化:

main.cpp __main_block_impl_0 建構函式的呼叫 簡化:

以上程式碼即:將 __main_block_impl_0 結構體例項的指標,賦值給 __main_block_impl_0 結構體指標型別的變數 blk。也就是我們最初的結構體定義:

另外,main 函式中還有另外一段:

去掉各種轉換:

實際就是最初的:

本節所有程式碼在 block_implementation

2.2 Block 截獲外部變數瞬間值的實現剖析

2.1 中對最簡單的 無引數 Block 宣告、呼叫 進行了 clang 轉換。接下來再看一段『截獲自動變數』的程式碼(可以使用命令 clang -rewrite-objc -fobjc-arc -fobjc-runtime=macosx-10.7 main.m):

clang 轉換之後:

17blog_iOS進階——iOS 記憶體管理&Block-07

和 2.1 節中的轉換程式碼對比,可以發現多了一些程式碼。

首先,__main_block_impl_0 多了一個變數 val,並在建構函式的引數中加入了 val 的賦值:

main.cpp __main_block_impl_0:

而在 main 函式中,對 Block 的宣告變為此句:

main.cpp __main_block_impl_0 建構函式的呼叫:

去掉轉換:

main.cpp __main_block_impl_0 建構函式的呼叫 簡化:

_所以,在 Block 被宣告時,Block 已經將 val 作為 __main_block_impl_0 的內部變數儲存下來了。無論在在宣告之後怎樣更改 val 的值,都不會影響,Block 呼叫時訪問的內部 val 值。這就是 Block 捕獲變數瞬間值的原理。_

本節所有程式碼在 EX05

2.3 __block 變數的訪問實現剖析

我們知道,Block 中能夠讀取,但是不能更改一個區域性變數,如果去更改,Xcode 會提示你無法在 Block 內部更改變數。

Block 內部只是對區域性變數只讀,但是 Block 能讀寫以下幾種變數:

  1. 靜態變數
  2. 靜態全域性變數
  3. 全域性變數

也就是說以下程式碼是沒有問題的:

如果想在 Block 內部寫區域性變數,需要對訪問的區域性變數增加 __block 修飾。

__block 修飾符其實類似於 C 語言中 static、auto、register 修飾符。用於指定將變數值設定到哪個儲存域中。

具體 __block 之後究竟做了哪些變化我們可以寫程式碼測試:

EX07:

clang 轉換之後:

18blog_blog_iOS進階——iOS 記憶體管理&Block-08

跟 2.2 對比,似乎又加了非常程式碼。發現多了兩個結構體。

main.cpp __Block_byref_val_0:

很驚奇的發現,block 型別的 val 變成了結構體 Block_byref_val_0的例項。這個例項內,包含了isa指標、一個標誌位flags、一個記錄大小的size。最最重要的,多了一個forwarding指標和val 變數。這是怎麼回事?

在 main 函式部分,例項化了該結構體:

main.cpp main.m 部分:

我們可以看出該結構體物件初始化時:

  1. __forwarding 指向了結構體例項本身在記憶體中的地址
  2. val = 10

而在 main 函式中,val = 1 這句賦值語句變成了:

main.cpp val = 1; 對應的函式

這裡就可以看出其精髓,val = 1,實際上更改的是 __Block_byref_val_0 結構體例項 val 中的 __forwarding 指標(也就是本身)指向的 val 變數。

19blog_blog_iOS進階——iOS 記憶體管理&Block-12

而對 val 訪問也是如此。你可以理解為通過取地址改變變數的值,這和 C 語言中取地址改變變數類似。

所以,宣告 block 的變數可以被改變。至於 forwarding 的其他巨大作用,會繼續分析。

本節程式碼在 EX05

3 Block 的儲存域

Block 有三種型別,分別是:

  1. __NSConcreteStackBlock ————————棧中
  2. __NSConcreteGlobalBlock ————————資料區域中
  3. __NSConcreteMallocBlock ————————堆中

__NSConcreteGlobalBlock 出現的地方有:

  1. 設定全域性變數的地方有 Block 語法時
  2. Block 語法的表示式中不使用任何外部變數時

設定在棧上的 Block,如果所屬的變數作用域結束,Block 就會被廢棄。如果其中用到了 block,block 所屬的變數作用域結束也會被廢棄。

為了解決這個問題,Block 在必要的時候就需要從棧中移到堆中。ARC 有效時,很多情況下,編譯器會幫助完成 Block 的 copy,但很多情況下,我們需要手動 copy Block。

對不同儲存域的 Block copy 時,影響如下:

20blog_blog_iOS進階——iOS 記憶體管理&Block-09

copy 時,對訪問到的 __block 型別物件影響如下:

21blog_blog_iOS進階——iOS 記憶體管理&Block-10

此時可以看出 __forwarding 的巨大作用——無論 Block 此時在堆中還是在棧中,由於 __forwarding 指向區域性變數轉換成的結構體例項的真是地址,所以都能確保正確的訪問。

具體的來說:

  1. block 變數被一個 Block 使用時,Block 從棧複製到堆,block 變數也會被複制到,並被該 Block 持有。
  2. block 變數被多個 Block 使用時,在任何一個 Block 從棧複製到堆時, block 變數也會被複制到堆,並被該 Block 持有。但由於 __forwarding 指標的存在,無論 block 變數和 Block 在不在同一個儲存域,都可以正確的訪問 block 變數。
  3. 如果堆上的 Block 被廢棄,那麼它所使用的 __block 變數也會被釋放。

22blog_blog_iOS進階——iOS 記憶體管理&Block-11

前面說到編譯器會幫助完成一些 Block 的 copy,也有手動 copy Block。那麼 Block 被複制到堆上的情況有(此段摘自於『Objective-C高階程式設計 iOS與OS X多執行緒和記憶體管理』):

  1. 呼叫 Block 的 copy 方法時
  2. Block 作為返回值時
  3. 將 Block 賦值給附有 __strong 修飾符的成員變數時(id型別或 Block 型別)時
  4. 在方法名中含有 usingBlock 的 Cocoa 框架方法或 GCD 的 API 中傳遞 Block 時

4 Block 迴圈引用

Block 迴圈引用,是在程式設計中非常常見的問題,甚至很多時候,我們並不知道發生了迴圈引用,直到我們突然某一天發現『怎麼這個物件沒有呼叫 delloc』,才意識到有問題存在。

在『Block 儲存域』中也說明了 Block 在 copy 後對 __block 物件會 retain 一次。

那麼對於如下情況就會發生迴圈引用:

block_retain_cycle:

由於 self -> blk,blk -> self,雙方都無法釋放。

但要注意的是,對於以下情況,同樣會發生迴圈引用:

這是由於 self -> obj,self -> blk,blk -> obj。這種情況是非常容易被忽視的。

5 重審問題

我們再來看看最初的幾個小題目:

23blog_iOS進階——iOS 記憶體管理&Block-05

  1. 第一題:

    由於 Block 捕獲瞬間值,所以輸出為 in block val = 0

  2. 第二題:

    由於 val 為 __block,外部更改會影響到內部訪問,所以輸出為 in block val = 1

  3. 第三題:

    和第二題類似,val = 1 能影響到 Block 內部訪問,所以先輸出 in block val = 1,之後在 Block 內部更改 val 值,再次訪問時輸出 after block val = 2

Other

我寫這篇文章是在我閱讀了『Objective-C高階程式設計 iOS與OS X多執行緒和記憶體管理』一書之後,博文中也有很內容源於『Objective-C高階程式設計 iOS與OS X多執行緒和記憶體管理』。

非常向大家推薦此書。這本書裡記錄了關於 iOS 記憶體管理的深入內容。但要注意的是,此書中的多處知識點並不是很詳細,需要你以擴充的心態去學習。在有解釋不詳細的地方,自己主動去探索,去擴充,找更多的資料,最後,你會發現你對 iOS 記憶體管理有了更多的深入的理解。

對於文章中的測試程式碼,全部在這裡


有什麼問題都可以在博文後面留言,或者微博上私信我,或者郵件我coderfish@163.com

博主是 iOS 妹子一枚。

希望大家一起進步。

我的微博:小魚周凌宇

相關文章