構(gòu)體到內(nèi)存管理)
1. 從一道面試題說起B(yǎng)lock到底是不是對象很多人準(zhǔn)備iOS面試時看到Block的本質(zhì)這幾個字第一反應(yīng)就是背答案Block是帶有自動變量值的匿名函數(shù)是OC對象底層是結(jié)構(gòu)體。背得滾瓜爛熟面試官稍微追問一句那它為什么能捕獲外部變量立刻就卡殼了。我自己當(dāng)年也是這樣直到有一次被問Block捕獲的變量存在哪里我才意識到光背結(jié)論根本沒用。這篇內(nèi)容就是把我這些年對Block的理解重新梳理一遍。核心圍繞三件事Block在底層到底是什么結(jié)構(gòu)、它怎么截獲外部變量、__block又做了什么手腳。這三件事是連在一起的搞懂一條線面試?yán)锊还茉趺磫柖寄芙拥米?。適合已經(jīng)會寫B(tài)lock、但對底層機(jī)制一知半解的iOS開發(fā)者也適合正在準(zhǔn)備面試、想把這塊徹底吃透的人。先說結(jié)論Block本質(zhì)是一個結(jié)構(gòu)體里面存了函數(shù)指針和捕獲的變量同時它繼承了一個isa指針?biāo)运彩且粋€對象。但是對象這件事只是表象真正決定它行為的是它的內(nèi)存布局和捕獲規(guī)則。下面我按結(jié)構(gòu)體長什么樣→變量怎么被截獲→__block改了什么→內(nèi)存怎么管理這條線展開。2. Block的底層結(jié)構(gòu)一個結(jié)構(gòu)體加一個函數(shù)指針2.1 用clang把Block翻譯成C看真相想看清Block的本質(zhì)最直接的辦法是用clang -rewrite-objc把OC代碼轉(zhuǎn)成C代碼。這是我在排查Block相關(guān)崩潰時最常用的手段比看任何博客都靠譜。寫一段最簡單的代碼int main(int argc, char * argv[]) { void (^myBlock)(void) ^{ printf(hello block); }; myBlock(); return 0; }用命令clang -rewrite-objc main.m轉(zhuǎn)換后核心結(jié)構(gòu)會變成這樣省略了部分系統(tǒng)定義struct __main_block_impl_0 { struct __block_impl impl; struct __main_block_desc_0* Desc; __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int flags0) { impl.isa _NSConcreteStackBlock; impl.Flags flags; impl.FuncPtr fp; Desc desc; } }; struct __block_impl { void *isa; int Flags; int Reserved; void *FuncPtr; };看到?jīng)]一個Block變量在底層就是一個__main_block_impl_0結(jié)構(gòu)體實例。它內(nèi)部包含三樣關(guān)鍵東西isa指針、FuncPtr函數(shù)指針、Desc描述信息。isa指向_NSConcreteStackBlock這就是為什么說Block是對象——它有isa能響應(yīng)消息。FuncPtr指向的是真正執(zhí)行的那段代碼也就是Block體被編譯成的C函數(shù)。這個函數(shù)的名字通常是__main_block_func_0它接收一個參數(shù)就是Block結(jié)構(gòu)體自身的指針。所以Block調(diào)用的時候本質(zhì)是impl.FuncPtr(block自身指針)把結(jié)構(gòu)體自己傳進(jìn)去這樣函數(shù)內(nèi)部才能訪問到捕獲的變量。2.2 三種Block類型與isa的指向關(guān)系isa指向什么決定了這個Block是什么類型。這是面試高頻考點也是理解內(nèi)存管理的前提。系統(tǒng)里有三個關(guān)鍵的類Block類型isa指向存儲位置生命周期全局Block_NSConcreteGlobalBlock全局?jǐn)?shù)據(jù)區(qū)程序運行期間一直存在棧Block_NSConcreteStackBlock棧區(qū)隨棧幀銷毀堆Block_NSConcreteMallocBlock堆區(qū)引用計數(shù)管理全局Block出現(xiàn)在兩種情況Block內(nèi)部沒有捕獲任何外部變量或者只捕獲了全局變量、靜態(tài)變量。這種Block不依賴任何運行時狀態(tài)編譯器直接把它放到全局區(qū)連引用計數(shù)都不需要。棧Block是默認(rèn)狀態(tài)。只要Block捕獲了局部變量它就會被創(chuàng)建在棧上。這里有個坑棧Block一旦離開作用域就會被銷毀如果你把它賦值給一個強(qiáng)引用屬性或者放進(jìn)數(shù)組程序可能崩潰或者行為異常。這就是為什么很多老代碼里Block屬性要用copy而不是strong——copy會把棧Block復(fù)制到堆上。堆Block是copy之后的結(jié)果。一旦Block被復(fù)制到堆上它的引用計數(shù)就開始工作捕獲的變量也會跟著一起被復(fù)制到堆上管理。ARC環(huán)境下編譯器會自動幫你做很多copy操作但理解這個機(jī)制對排查循環(huán)引用、野指針問題至關(guān)重要。提示判斷一個Block是不是棧Block可以在賦值后打印它的isa或者用__bridge轉(zhuǎn)成void *看地址范圍。棧地址通常很大接近0x7開頭堆地址相對小一些。2.3 為什么Block調(diào)用要傳自己進(jìn)去這一點很多人沒想過。FuncPtr指向的C函數(shù)簽名是這樣的static void __main_block_func_0(struct __main_block_impl_0 *__cself) { // Block體代碼 }調(diào)用時是impl.FuncPtr(block指針)。為什么要傳自己因為Block體里用到的所有捕獲變量都作為結(jié)構(gòu)體的成員存在。函數(shù)內(nèi)部要訪問這些變量必須通過__cself-成員名的方式。這就像C的成員函數(shù)需要this指針一樣Block的成員函數(shù)也需要一個指向自己的指針。理解了這一點就能明白為什么Block捕獲變量是值拷貝——因為變量被復(fù)制成了結(jié)構(gòu)體的成員函數(shù)訪問的是成員不是原來的變量。這個設(shè)計直接引出了下一節(jié)要講的截獲特性。3. 變量截獲為什么改不了外面的值3.1 局部變量被值拷貝進(jìn)結(jié)構(gòu)體先看一段會讓人困惑的代碼int age 10; void (^myBlock)(void) ^{ printf(%d, age); }; age 20; myBlock(); // 輸出 10不是 20很多人第一次看到這個結(jié)果會愣一下明明調(diào)用前把a(bǔ)ge改成了20為什么Block里還是10答案就在結(jié)構(gòu)體里。轉(zhuǎn)換后的代碼大致是struct __main_block_impl_0 { struct __block_impl impl; struct __main_block_desc_0* Desc; int age; // 捕獲的變量作為成員 __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _age, int flags0) : age(_age) { // ... } };Block初始化的時候把當(dāng)時的age值10拷貝給了結(jié)構(gòu)體的age成員。之后外面怎么改改的都是原來的局部變量跟結(jié)構(gòu)體里的副本沒關(guān)系。所以Block里讀到的永遠(yuǎn)是捕獲那一刻的值。這就是截獲這個詞的準(zhǔn)確含義——不是引用是拷貝。面試?yán)锶绻粏朆lock為什么修改不了外部局部變量標(biāo)準(zhǔn)答案就是局部變量被值拷貝進(jìn)Block結(jié)構(gòu)體Block內(nèi)部操作的是副本不是原變量。3.2 靜態(tài)變量、全局變量為什么能改同樣是變量靜態(tài)變量和全局變量在Block里就能改這又是為什么static int staticValue 10; int globalValue 20; void (^myBlock)(void) ^{ staticValue 30; // 可以改 globalValue 40; // 可以改 };原因很簡單靜態(tài)變量和全局變量本來就存在全局?jǐn)?shù)據(jù)區(qū)它們的地址在程序運行期間是固定的。Block捕獲它們的時候捕獲的是地址或者說直接通過符號訪問不是值拷貝。既然訪問的是同一塊內(nèi)存當(dāng)然能改。這里有個細(xì)節(jié)值得注意靜態(tài)變量在Block結(jié)構(gòu)體里存的是指針不是值。轉(zhuǎn)換后的代碼里會看到int *staticValue這樣的成員。所以嚴(yán)格來說Block對靜態(tài)變量也是捕獲但捕獲的是指針效果上等同于引用。變量類型捕獲方式能否在Block內(nèi)修改原因局部自動變量值拷貝否操作的是副本靜態(tài)局部變量捕獲指針是訪問同一塊內(nèi)存全局變量直接訪問是地址固定成員變量捕獲self指針是通過self訪問的是對象屬性3.3 成員變量和self的關(guān)系成員變量是個特殊情況。Block里訪問_name或者self.name實際上捕獲的是self指針。轉(zhuǎn)換后結(jié)構(gòu)體里會多一個ViewController *self成員。這也是循環(huán)引用的根源——Block強(qiáng)引用了self如果self又強(qiáng)引用了Block就形成環(huán)。self.myBlock ^{ NSLog(%, self.name); // 捕獲了self };這段代碼里Block結(jié)構(gòu)體持有了self而self假設(shè)是ViewController通過屬性持有了Block兩者互相強(qiáng)引用誰都釋放不了。解決辦法是用__weak__weak typeof(self) weakSelf self; self.myBlock ^{ NSLog(%, weakSelf.name); };weakSelf本身是個局部變量被值拷貝進(jìn)Block但它是弱引用不會增加self的引用計數(shù)。這樣就打破了循環(huán)。注意用weakSelf之后如果Block是異步執(zhí)行的執(zhí)行時self可能已經(jīng)被釋放weakSelf會變成nil。需要多次訪問self時建議在Block內(nèi)部用__strong typeof(weakSelf) strongSelf weakSelf;轉(zhuǎn)成強(qiáng)引用保證執(zhí)行期間self存活。4. __block修飾符讓局部變量變得可修改4.1 __block做了什么把變量包裝成結(jié)構(gòu)體既然局部變量是值拷貝那有沒有辦法讓Block內(nèi)部修改它、并且外部也能看到修改有就是加__block。__block int age 10; void (^myBlock)(void) ^{ age 20; }; myBlock(); printf(%d, age); // 輸出 20加了__block之后age不再是普通局部變量編譯器會把它包裝成一個結(jié)構(gòu)體struct __Block_byref_age_0 { void *__forwarding; int __flags; int __size; int age; // 真正的值 };Block結(jié)構(gòu)體里存的不再是int age而是一個指向__Block_byref_age_0的指針。Block內(nèi)部修改age改的是這個結(jié)構(gòu)體里的age成員。因為Block和外部變量訪問的是同一個結(jié)構(gòu)體通過指針?biāo)孕薷哪芡?。這里的關(guān)鍵是__forwarding指針。它指向這個結(jié)構(gòu)體自己在棧上時當(dāng)Block被復(fù)制到堆上時棧上的__forwarding會指向堆上的副本。這樣無論從棧還是堆訪問最終都落到同一份數(shù)據(jù)上。這個設(shè)計非常巧妙解決了棧Block復(fù)制到堆之后變量訪問的一致性問題。4.2 __block變量的內(nèi)存管理__block變量如果是個對象類型內(nèi)存管理會復(fù)雜一些。看這段__block NSString *name hello; void (^myBlock)(void) ^{ name world; };__Block_byref_name_0結(jié)構(gòu)體里會多出copy和dispose兩個函數(shù)指針分別對應(yīng)__Block_byref_id_object_copy_0和__Block_byref_id_object_dispose_0。當(dāng)Block從棧復(fù)制到堆時會調(diào)用copy函數(shù)對name做一次retainBlock銷毀時調(diào)用dispose做一次release。這套機(jī)制保證了__block對象在Block生命周期內(nèi)的正確管理。如果__block修飾的是基本類型就沒有這兩個函數(shù)因為基本類型不需要引用計數(shù)。4.3 __block和__weak能一起用嗎經(jīng)常有人問__block和__weak能不能同時修飾一個變量答案是能但要想清楚目的。__block __weak typeof(self) weakSelf self;這種寫法在某些場景下有用比如需要在Block內(nèi)部重新賦值weakSelf。但大多數(shù)情況下單純用__weak就夠了。__block在這里的作用是讓變量可被重新賦值__weak是讓它弱引用。兩個修飾符解決的是不同問題不要混為一談。還有一個經(jīng)典誤區(qū)用__block來打破循環(huán)引用。早期MRC時代確實有人這么做因為__block變量不會被Block retain。但在ARC下__block對象會被Block強(qiáng)引用用它來打破循環(huán)引用是無效的必須用__weak。修飾符作用是否解決循環(huán)引用適用場景__weak弱引用不增加計數(shù)是打破self循環(huán)引用__strong強(qiáng)引用默認(rèn)否保證對象存活__block允許Block內(nèi)修改局部變量ARC下否需要在Block內(nèi)改變量值__unsafe_unretained不安全管理是但危險極少使用5. 從棧到堆Block復(fù)制時發(fā)生了什么5.1 什么時候會觸發(fā)copy棧Block離開作用域就沒了所以只要Block需要在當(dāng)前作用域之外存活就必須復(fù)制到堆上。觸發(fā)copy的常見場景Block作為函數(shù)返回值返回Block賦值給__strong修飾的變量或?qū)傩訠lock作為參數(shù)傳給dispatch_async等系統(tǒng)APIBlock被放進(jìn)數(shù)組、字典等集合ARC下編譯器會自動插入objc_retainBlock也就是Block_copy。所以大多數(shù)時候你不需要手動copy。但理解這個過程對排查問題很重要。5.2 復(fù)制時捕獲變量怎么處理Block從棧復(fù)制到堆時它捕獲的變量也會被復(fù)制?;绢愋椭苯涌截愐环莸蕉焉系慕Y(jié)構(gòu)體對象類型會執(zhí)行retain__block變量則通過__forwarding指針做特殊處理。這里有個容易踩的坑如果Block捕獲了一個棧上的對象指針復(fù)制到堆后這個指針指向的對象可能已經(jīng)釋放了。比如- (void)test { NSObject *obj [[NSObject alloc] init]; self.block ^{ NSLog(%, obj); }; }obj是局部變量Block捕獲它并復(fù)制到堆時會對obj做retain所以obj不會釋放。這是ARC的功勞。但在MRC下如果不手動copyBlock還在棧上obj釋放后Block里的指針就是野指針一調(diào)用就崩。5.3 __forwarding指針的妙用前面提到__forwarding這里展開說一下。當(dāng)__block變量在棧上時__forwarding指向棧上的結(jié)構(gòu)體自己。Block被復(fù)制到堆時堆上會生成一份新的__Block_byref結(jié)構(gòu)體然后棧上那份的__forwarding被改成指向堆上的新結(jié)構(gòu)體。這樣做的目的是如果Block內(nèi)部和外部的代碼都通過__forwarding訪問變量那么無論Block在棧上還是堆上訪問的都是同一份數(shù)據(jù)。復(fù)制之后外部代碼通過棧上的__forwarding找到堆上的值Block通過堆上的結(jié)構(gòu)體訪問兩邊保持一致。這個設(shè)計解決了一個核心矛盾Block復(fù)制到堆后原來的棧變量可能已經(jīng)失效但外部代碼可能還在用。__forwarding就像一根繩子把棧和堆連起來保證數(shù)據(jù)同步。6. 面試?yán)锬切┤菀追嚨淖穯?.1 Block的isa為什么是_NSConcreteStackBlock面試官問Block是對象嗎你答是他接著問那它的isa指向什么很多人就答不上來了。默認(rèn)情況下捕獲了局部變量的Blockisa指向_NSConcreteStackBlock。沒捕獲變量的指向_NSConcreteGlobalBlock。被copy過的指向_NSConcreteMallocBlock。這三個類都是系統(tǒng)私有的你沒法直接創(chuàng)建它們的實例但可以通過isa判斷Block的狀態(tài)。這在調(diào)試內(nèi)存問題時很有用。6.2 為什么Block屬性要用copy這是老生常談但面試官喜歡追問用strong會怎樣。答案用strong修飾Block屬性如果賦值的是棧Blockstrong只是讓指針指向棧上的Block不會復(fù)制到堆。棧Block隨棧幀銷毀后屬性指向的就是野指針調(diào)用必崩。copy會觸發(fā)Block_copy把Block復(fù)制到堆上安全。ARC下strong和copy對Block的效果其實一樣因為編譯器會自動copy。但為了語義清晰和兼容MRC還是推薦用copy。6.3 循環(huán)引用的三種排查思路循環(huán)引用是Block面試的必考題。我總結(jié)的排查思路第一看Block里有沒有直接用self或者成員變量。用了就大概率有循環(huán)引用。第二看Block是不是被self強(qiáng)引用。如果Block只是臨時用一下沒被self持有那即使捕獲了self也不會循環(huán)。第三用__weak打破。如果Block內(nèi)部需要多次訪問self用__strong臨時提升。__weak typeof(self) weakSelf self; self.block ^{ __strong typeof(weakSelf) strongSelf weakSelf; if (!strongSelf) return; [strongSelf doSomething]; [strongSelf doAnotherThing]; };這段代碼是標(biāo)準(zhǔn)寫法面試?yán)锬軐懗鰜砘揪头€(wěn)了。6.4 __block變量在ARC下的引用計數(shù)變化這個比較偏底層但高級面試會問。__block修飾的對象變量在Block復(fù)制到堆時會被retainBlock銷毀時被release。如果這個對象同時被外部強(qiáng)引用引用計數(shù)會加一。所以__block對象在Block生命周期內(nèi)是強(qiáng)引用的這一點和__weak完全不同。有個細(xì)節(jié)__block變量在MRC下不會被retain這是歷史遺留差異。ARC下統(tǒng)一了行為都retain。面試時如果被問到MRC和ARC的區(qū)別可以提這一點。7. 我在實際項目里踩過的Block坑說幾個真實踩過的坑都是文檔里不會寫的。第一個坑在for循環(huán)里創(chuàng)建Block并捕獲循環(huán)變量。如果循環(huán)變量是局部變量每個Block捕獲的是各自的值拷貝沒問題。但如果捕獲的是__block變量所有Block共享同一份最后執(zhí)行時拿到的都是最終值。這個坑在異步任務(wù)里特別隱蔽。第二個坑Block里用了weakSelf但沒做nil判斷。異步回調(diào)時self可能已經(jīng)釋放weakSelf是nil給nil發(fā)消息雖然不崩但邏輯會靜默失敗排查起來很費勁。所以關(guān)鍵路徑上一定要加if (!strongSelf) return;。第三個坑在Block里修改了__block數(shù)組同時又在另一個線程遍歷它。__block變量本身不是線程安全的多線程訪問需要加鎖。這個坑在并發(fā)場景下偶發(fā)很難復(fù)現(xiàn)。第四個坑把棧Block賦值給assign屬性。assign不復(fù)制棧Block銷毀后屬性變野指針。這個在MRC時代很常見ARC下編譯器會警告但還是要留意。提示調(diào)試Block問題時可以在關(guān)鍵位置打印Block的isa和捕獲變量的地址對比棧堆變化。用po命令配合__bridge轉(zhuǎn)換能快速定位問題。8. 把Block機(jī)制串起來理解回到最開始的問題Block的本質(zhì)是什么我的理解是它是一個自帶上下文的函數(shù)對象。結(jié)構(gòu)體負(fù)責(zé)存上下文捕獲的變量函數(shù)指針負(fù)責(zé)執(zhí)行邏輯isa讓它具備對象的身份__forwarding和copy機(jī)制負(fù)責(zé)生命周期管理。截獲變量的特性本質(zhì)是值拷貝和指針捕獲的區(qū)別。局部變量值拷貝所以改不了靜態(tài)和全局變量捕獲地址所以能改__block把變量包裝成結(jié)構(gòu)體通過指針共享所以既能改又能同步。__block修飾符的核心價值是讓局部變量獲得跨作用域共享的能力。它通過__Block_byref結(jié)構(gòu)體和__forwarding指針解決了棧堆復(fù)制時的數(shù)據(jù)一致性問題。理解了這個就能明白為什么__block對象在ARC下會被retain以及為什么它不能用來打破循環(huán)引用。面試?yán)镉龅紹lock的題不要急著背結(jié)論。先想清楚這個Block捕獲了什么、在棧上還是堆上、有沒有循環(huán)引用然后順著結(jié)構(gòu)體的內(nèi)存布局去推答案自然就出來了。這套思路比死記硬背管用得多也是我這些年排查Block相關(guān)問題最依賴的方法。