block沒那麼難(二):block和變數的記憶體管理

發表於2016-06-20

本系列博文總結自《Pro Multithreading and Memory Management for iOS and OS X with ARC》


瞭解了 block的實現,我們接著來聊聊 block 和變數的記憶體管理。本文將介紹可寫變數、block的記憶體段、__block變數的記憶體段等內容,看完本文會對 block 和變數的記憶體管理有更加清晰的認識。

上篇文章舉了個例子,在 block 內獲取了一個外部的區域性變數,可以讀取,但無法進行寫入的修改操作。在 C 語言中有三種型別的變數,可在 block 內進行讀寫操作

  • 全域性變數
  • 全域性靜態變數
  • 靜態變數

全域性變數全域性靜態變數 由於作用域在全域性,所以在 block 內訪問和讀寫這兩類變數和普通函式沒什麼區別,而 靜態變數 作用域在 block 之外,是怎麼對它進行讀寫呢?通過 clang 工具,我們發現原來 靜態變數 是通過指標傳遞,將變數傳遞到 block 內,所以可以修改變數值。而上篇文章中的外部變數是通過值傳遞,自然沒法對獲取到的外部變數進行修改。由此,可以給我們一個啟示,當我們需要修改外部變數時,是不是也可以像 靜態變數 這樣通過指標來修改外部變數的值呢?

Apple 早就為我們準備了這麼一個東西 —— “__block”

__block 說明符

按照慣例,重寫一小段程式碼看看 __block 的真身

在加了 __block 之後,程式碼量增加了不少,仔細檢視,其實只是比原來多了

OC原始碼中的 __block intValue 翻譯後變成了 __Block_byref_intValue_0 結構體指標變數 intValue,通過指標傳遞到 block 內,這與前面說的 靜態變數 的指標傳遞是一致的。除此之外,整體的執行流程與不加 __block 基本一致,不再贅述。但 __Block_byref_intValue_0 這個結構體需特別注意下

11

在已有結構體指標指向 __Block_byref_intValue_0 時,結構體裡面還多了個 __forwarding 指向自己的指標變數,難道不顯得多餘嗎?一點也不,本文後面會闡述。


block 的記憶體管理

在前文中,已經提到了 block 的三種型別 NSConcreteGlobalBlock_NSConcreteStackBlock_NSConcreteMallocBlock,見名知意,可以看出三種 block 在記憶體中的分佈

12

_NSConcreteGlobalBlock

除了上述描述的兩種情況,其他形式建立的 block 均為 stack block

_NSConcreteGlobalBlock 型別的 block 處於記憶體的 ROData 段,此處沒有區域性變數的騷擾,執行不依賴上下文,記憶體管理也簡單的多。

_NSConcreteStackBlock

_NSConcreteStackBlock 型別的 block 處於記憶體的棧區。global block 由於處在 data 段,可以通過指標安全訪問,但 stack block 處在記憶體棧區,如果其變數作用域結束,這個 block 就被廢棄,block 上的 __block 變數也同樣會被廢棄。

13

為了解決這個問題,block 提供了 copy 的功能,將 block 和 __block 變數從棧拷貝到堆,就是下面要說的 _NSConcreteMallocBlock

_NSConcreteMallocBlock

當 block 從棧拷貝到堆後,當棧上變數作用域結束時,仍然可以繼續使用 block

14

此時,堆上的 block 型別為 _NSConcreteMallocBlock,所以會將 _NSConcreteMallocBlock 寫入 isa

  1. impl.isa = &_NSConcreteMallocBlock;

如果你細心的觀察上面的轉換後的程式碼,會發現訪問結構體 __Block_byref_intValue_0 內部的成員變數都是通過訪問 __forwarding 指標完成的。為了保證能正確訪問棧上的 __block 變數,進行 copy 操作時,會將棧上的 __forwarding 指標指向了堆上的 block 結構體例項。


block 的自動拷貝和手動拷貝

在開啟 ARC 時,大部分情況下編譯器通常會將建立在棧上的 block 自動拷貝到堆上,只有當

block 作為方法或函式的引數傳遞時,編譯器不會自動呼叫 copy 方法;

但方法/函式在內部已經實現了一份拷貝了 block 引數的程式碼,或者如果編譯器自動拷貝,那麼呼叫者就不需再手動拷貝,比如:

  • 當 block 作為函式返回值返回時,編譯器自動將 block 作為 _Block_copy 函式,效果等同於 block 直接呼叫 copy 方法;
  • 當 block 被賦值給 __strong id 型別的物件或 block 的成員變數時,編譯器自動將 block 作為 _Block_copy 函式,效果等同於 block 直接呼叫 copy 方法;
  • 當 block 作為引數被傳入方法名帶有 usingBlock 的 Cocoa Framework 方法或 GCD 的 API 時。這些方法會在內部對傳遞進來的 block 呼叫 copy_Block_copy 進行拷貝;

讓我們看個 block 自動拷貝的例子

上面的 block 獲取了外部變數,所以是建立在棧上,當 func 函式返回給呼叫者時,脫離了區域性變數 rate 的作用範圍,如果呼叫者使用這個 block 就會出問題。那 ARC 開啟的情況呢?執行這個 block 一切正常。和我們的預期結果不一樣,ARC 到底給 block 施了什麼魔法?我們將上面的程式碼翻譯下

轉換後出現兩個新函式 objc_retainBlockobjc_autoreleaseReturnValue。如果你看過runtime 庫(點此下載) ,在 runtime/objc-arr.mm 檔案中就有這兩個函式的實現:

通過上面的程式碼和註釋,意思就很明顯了,由於 block 字面量是建立在棧記憶體,通過 objc_retainBlock() 函式拷貝到堆記憶體,讓 tmp 重新指向堆上的 block,然後將 tmp 所指的堆上的 block 作為一個 Objective-C 物件放入 autoreleasepool 裡面,從而保證了返回後的 block 仍然可以正確執行。

看完了 block 的自動拷貝,那麼看看在 ARC 下需要手動拷貝 block 的例子

一個例子就瞭然,返回的陣列裡面的 block 是不可用的,需要再手動拷貝一次才可以,這個較為簡單,就不作過多解釋。

關於 block 的拷貝操作可以用一張表總結下

16

block 拷貝的講解就到此為止,有興趣可以瞭解下 block 的多次拷貝。


__block 變數的記憶體管理

上面囉嗦一堆,這小節主要用圖說話,必要時加文字說明。

  • 當 block 從棧記憶體被拷貝到堆記憶體時,__block 變數的變化如下圖。需要說明的是,當棧上的 block 被拷貝到堆上,堆上的 block 再次被拷貝時,對 __block 變數已經沒有影響了。

    17

    18

  • 當多個 block 獲取同一個 __block 變數,block 從棧被拷貝到堆時

    19

  • 當 block 被廢棄時,__block 變數被釋放

    20

  • __forwarding
    前文已經說過,當 block 從棧被拷貝到堆時,__forwarding 指標變數也會指向堆區的結構體。但是為什麼要這麼做呢?為什麼要讓原本指向棧區的結構體的指標,去指向堆區的結構體呢?看起來匪夷所思,實則原因很簡單,要從 __forwarding 產生的緣由說起。想想起初為什麼要給 block 新增 copy 的功能,就是因為 block 獲取了區域性變數,當要在其他地方(超出區域性變數作用範圍)使用這個 block 的時候,由於訪問區域性變數異常,導致程式崩潰。為了解決這個問題,就給 block 新增了 copy 功能。在將 block 拷貝到堆上的同時,將 __forwarding 指標指向堆上結構體。後面如果要想使用 __block 變數,只要通過 __forwarding 訪問堆上變數,就不會出現程式崩潰了。

一定有很多人會猜 1,其實列印 2。原因很簡單,當棧上的 block 被拷貝到堆上時,棧上的 __forwarding 也會指向堆上的 __block 變數的結構體。

上面的程式碼中 ^{++val;}++val; 都會被轉換成 ++(val.__forwarding->val);,堆上的 val 被加了兩次,最後列印堆上的 val2

圖解

21


block 和變數的記憶體管理終於講完了,看似很長,只要瞭解本質,其實很簡單。期待下篇文章《block沒那麼難(三):block和物件的記憶體管理》

相關文章