一道Block面試題的深入挖掘
0. 序言
最近看到了一道Block的面試題,還蠻有意思的,來給大家分享一下。
本文從一道Block面試題出發,層層深入到達Block原理的講解,把面試題吃得透透的。
題外話:
很多人覺得Block的定義很怪異,很難記住。但其實和C語言的函式指標的定義對比一下,你很容易就可以記住。
// Block
returnType (^blockName)(parameterTypes)
// 函式指標
returnType (*c_func)(parameterTypes)
複製程式碼
例如輸入和返回引數都是字串:
(char *) (*c_func)(const char *);
(NSString *) (^block)(NSString *);
複製程式碼
好了,下面正式開始~
1. 面試題
1.1 問題1
以下程式碼存在記憶體洩露麼?
- 不存在
- 存在
- (void)viewDidLoad {
[super viewDidLoad];
NSNotificationCenter *__weak center = [NSNotificationCenter defaultCenter];
id token = [center addObserverForName:UIApplicationDidEnterBackgroundNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification * _Nonnull note) {
[self doSomething];
[center removeObserver:token];
}];
}
- (void)doSomething {
}
複製程式碼
答案是存在!
1.1.1 分析
-
block
中,我們使用到的外部變數有self
center
,center
使用了__weak
說明符肯定沒問題。 -
center
持有token
,token
持有block
(下一題的截圖會看到),block
持有self
,也就是說token
不釋放,self
肯定沒法釋放。可能有同學對
center
持有token
有疑問,可以在lldb
中po wkCenter
。 -
我們注意到
[center removeObserver:token];
這步會把token
從center
中移除掉。按理說,center
和self
是不是就可以被釋放了呢?
我們來看看編譯器怎麼說:
編譯器告訴我們,token
在被block捕獲之前沒有初始化![center removeObserver:token];
是沒法正確移除token
的,所以self
也沒法被釋放!
為什麼沒有被初始化?
因為token在後面的方法執行完才會被返回。方法執行的時候token還沒有被返回,所以捕獲到的是一個未初始化的值!
1.2 問題2
以下程式碼存在記憶體洩露麼?
- 不存在
- 存在
- (void)viewDidLoad {
[super viewDidLoad];
NSNotificationCenter *__weak center = [NSNotificationCenter defaultCenter];
id __block token = [center addObserverForName:UIApplicationDidEnterBackgroundNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification * _Nonnull note) {
[self doSomething];
[center removeObserver:token];
}];
}
- (void)doSomething {
}
複製程式碼
這次程式碼在token
之前加入了__block
說明符。
提示:這次編譯器沒有警告說token沒有被初始化了。
答案是還是存在!
1.2.1 分析
首先,證明token
的值是正確的,同時大家也可以看到token
確實是持有block
的。
那麼,為什麼還會洩露呢?
因為,雖然center
對token
的持有已經沒有了,token
現在還被block
持有。
可能還有同學會問:
加入了__block說明符,token物件不是還是center返回之後才能拿到麼,為什麼加了之後就沒問題了呢?
原因會在Block原理部分詳細說明。
1.3 問題3
以下程式碼存在記憶體洩露麼?
- 不存在
- 存在
- (void)viewDidLoad {
[super viewDidLoad];
NSNotificationCenter *__weak center = [NSNotificationCenter defaultCenter];
id __block token = [center addObserverForName:UIApplicationDidEnterBackgroundNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification * _Nonnull note) {
[self doSomething];
[center removeObserver:token];
token = nil;
}];
}
- (void)doSomething {
}
- (void)dealloc {
NSLog(@"%s",__FUNCTION__);
}
複製程式碼
答案是不存在!
1.3.1 分析
我們可以驗證一下:
可以看到,我們新增token = nil;
之後,ViewController
被正確釋放了。這一步,解除了token
與block
之間的迴圈引用,所以正確釋放了。
有人可能會說:
使用__weak typeof(self) wkSelf = self;就可以解決self不釋放的問題。
確實這可以解決self不釋放的問題,但是這裡仍然存在記憶體洩露!
2. Block的原理
雖然面試題解決了,但是還有幾個問題沒有弄清楚:
- 為什麼沒有
__block
說明符token
未被初始化,而有這個說明符之後就沒問題了呢? -
token
和block
為什麼會形成迴圈引用呢?
2.1 Block捕獲自動變數
剛剛的面試題比較複雜,我們先來看一個簡單的:
Block轉換為C函式之後,Block中使用的自動變數會被作為成員變數追加到 __X_block_impl_Y結構體中,其中 X一般是函式名, Y是第幾個Block,比如main函式中的第0個結構體: __main_block_impl_0。
typedef void (^MyBlock)(void);
int main(int argc,const char * argv[])
{
@autoreleasepool
{
int age = 10;
MyBlock block = ^{
NSLog(@"age = %d",age);
};
age = 18;
block();
}
return 0;
}
複製程式碼
順便說一下,這個輸出:age = 10
在命令列中對這個檔案進行一下處理:
clang -w -rewrite-objc main.m
複製程式碼
或者
xcrun -sdk iphoneos clang -arch arm64 -w -rewrite-objc main.m
複製程式碼
區別是下面指定了SDK和架構程式碼會少一點。
處理完之後會生成一個main.cpp的檔案,開啟後會發現程式碼很多,不要怕。搜尋int main就能看到熟悉的程式碼了。
int main(int argc,const char * argv[])
{
/* @autoreleasepool */
{ __AtAutoreleasePool __autoreleasepool;
int age = 10;
MyBlock block = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0,&__main_block_desc_0_DATA,age));
age = 18;
((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block);
}
return 0;
}
複製程式碼
下面是main函式中涉及到的一些結構體:
struct __main_block_impl_0 {
struct __block_impl impl; //block的函式的imp結構體
struct __main_block_desc_0* Desc; // block的資訊
int age; // 值引用的age值
__main_block_impl_0(void *fp,struct __main_block_desc_0 *desc,int _age,int flags=0) : age(_age) {
impl.isa = &_NSConcreteStackBlock; // 棧型別的block
impl.Flags = flags;
impl.FuncPtr = fp; // 傳入了函式具體的imp指標
Desc = desc;
}
};
struct __block_impl {
void *isa; // block的型別:全域性、棧、堆
int Flags;
int Reserved;
void *FuncPtr; // 函式的指標!就是通過它呼叫block的!
};
static struct __main_block_desc_0 { // block的資訊
size_t reserved;
size_t Block_size; // block的大小
} __main_block_desc_0_DATA = { 0,sizeof(struct __main_block_impl_0)};
複製程式碼
有了這些資訊,我們再看看
MyBlock block = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0,age));
複製程式碼
可以看到,block
初始化的時候age
是值傳遞,所以block
結構體中age=10
,所以列印的是age = 10。
2.2 __block說明符
Block中修改捕獲的自動變數有兩種方法:
-
使用靜態變數、靜態全域性變數、全域性變數
從Block語法轉化為C語言函式中訪問靜態全域性變數、全域性變數,沒有任何不同,可以直接訪問。而靜態變數使用的是靜態變數的指標來進行訪問。
自動變數不能採用靜態變數的做法進行訪問。原因是,自動變數是在儲存在棧上的,當超出其作用域時,會被棧釋放。而靜態變數是儲存在堆上的,超出作用域時,靜態變數沒有被釋放,所以還可以訪問。
-
新增 __block修飾符
__block儲存域類說明符。儲存域說明符會指定變數儲存的域,如棧auto、堆static、全域性extern,暫存器register。
比如剛剛的程式碼加上 __block說明符:
typedef void (^MyBlock)(void);
int main(int argc,const char * argv[])
{
@autoreleasepool
{
int __block age = 10;
MyBlock block = ^{
age = 18;
};
block();
}
return 0;
}
複製程式碼
在命令列中對這個檔案進行一下處理:
xcrun -sdk iphoneos clang -arch arm64 -w -rewrite-objc main.m
複製程式碼
我們看到main函式發生了變化:
-
原來的
age
變數:int age = 10; -
現在的
age
變數:__Block_byref_age_0 age = {(void*)0,(__Block_byref_age_0 *)&age,sizeof(__Block_byref_age_0),10};。
int main(int argc,const char * argv[])
{
/* @autoreleasepool */
{ __AtAutoreleasePool __autoreleasepool;
__Block_byref_age_0 age = {(void*)0,0,sizeof(__Block_byref_age_0),10};
MyBlock block = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0,570425344));
((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block);
}
return 0;
}
複製程式碼
原來我們知道新增 __block說明符,我們就可以在block裡面修改自動變量了。
恭喜你,現在你達到了第二層!__block說明符,其實會把自動變數包含到一個結構體中。
這也就解釋了問題1為什麼加入__block說明符,token可以正確拿到值。
MyBlock block = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0,570425344));
複製程式碼
這次block初始化的過程中,把age
這個結構體傳入到了block結構體中,現在就變成了指標引用。
struct __Block_byref_age_0 {
void *__isa; //isa指標
__Block_byref_age_0 *__forwarding; // 指向自己的指標
int __flags; // 標記
int __size; // 結構體大小
int age; // 成員變數,儲存age值
};
struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
__Block_byref_age_0 *age; // 結構體指標引用
__main_block_impl_0(void *fp,__Block_byref_age_0 *_age,int flags=0) : age(_age->__forwarding) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};
複製程式碼
我們再來看看block中是如何修改age
對應的值:
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
__Block_byref_age_0 *age = __cself->age; // 通過結構體的self指標拿到age結構體的指標
(age->__forwarding->age) = 18; // 通過age結構體指標修改age值
}
複製程式碼
看到這裡可能不明白__forwarding的作用,我們之後再講。現在知道是age是指標引用修改成功的就可以了。
2.3 Block儲存域
從C程式碼中我們可以看到Block的是指是Block結構體例項,__block變數實質是棧上__block變數結構體例項。從初始化函式中我們可以看到,impl.isa = &_NSConcreteStackBlock;
,即之前我們使用的是棧Block。
其實,Block有3中型別:
- _NSConcreteGlobalBlock類物件儲存在程式的資料區(.data區)。
- _NSConcreteStackBlock類物件儲存在棧上。
- _NSConcreteMallocBlock類物件儲存在堆上。
void (^blk)(void) = ^{
NSLog(@"Global Block");
};
int main() {
blk();
NSLog(@"%@",[blk class]);//列印:__NSGlobalBlock__
}
複製程式碼
全域性Block肯定是儲存在全域性資料區的,但是在函式棧上建立的Block,如果沒有捕獲自動變數,Block的結構例項還是 _NSConcreteGlobalBlock,而不是 _NSConcreteStackBlock:
void (^blk0)(void) = ^{ // 沒有截獲自動變數的Block
NSLog(@"Stack Block");
};
blk0();
NSLog(@"%@",[blk0 class]); // 列印:__NSGlobalBlock__
int i = 1;
void (^blk1)(void) = ^{ // 截獲自動變數i的Block
NSLog(@"Capture:%d",i);
};
blk1();
NSLog(@"%@",[blk1 class]); // 列印:__NSMallocBlock__
複製程式碼
可以看到沒有捕獲自動變數的Block列印的類是NSGlobalBlock,表示儲存在全域性資料區。 但為什麼捕獲自動變數的Block列印的類卻是設定在堆上的NSMallocBlock,而非棧上的NSStackBlock?這個問題稍後解釋。
設定在棧上的Block,如果超出作用域,Block就會被釋放。若 __block變數也配置在棧上,也會有被釋放的問題。所以, copy方法呼叫時,__block變數也被複制到堆上,同時impl.isa = &_NSConcreteMallocBlock;
。複製之後,棧上 __block變數的__forwarding
指標會指向堆上的物件。因 此 __block變數無論被分配在棧上還是堆上都能夠正確訪問。
編譯器如何判斷何時需要進行copy操作呢?
在ARC開啟時,自動判斷進行 copy:
- 手動呼叫copy。
- 將Block作為函式引數返回值返回時,編譯器會自動進行 copy。
- 將Block賦值給 copy修飾的id類或者Block型別成員變數,或者
__strong
修飾的自動變數。 - 方法名含有
usingBlock
的Cocoa
框架方法或GCD
相關API傳遞Block。
如果不能自動 copy,則需要我們手動呼叫 copy方法將其複製到堆上。比如向不包括上面提到的方法或函式的引數中傳遞Block時。
ARC環境下,返回一個物件時會先將該物件複製給一個臨時例項指標,然後進行retain
操作,再返回物件指標。runtime/objc-arr.mm
提到,Block的retain
操作objc_retainBlock
函式實際上是Block_copy
函式。在實行retain
操作objc_retainBlock
後,棧上的Block會被複制到堆上,同時返回堆上的地址作為指標賦值給臨時變數。
2.4 __block變數儲存域
當Block從棧複製到堆上時候,__block變數也被複制到堆上並被Block持有。
- 若此時 __block變數已經在堆上,則被該Block持有。
- 若配置在堆上的Block被釋放,則它所持有的 __block變數也會被釋放。
__block int val = 0;
void (^block)(void) = [^{ ++val; } copy];
++val;
block();
複製程式碼
利用 copy操作,Block和 __block變數都從棧上被複制到了堆上。無論是{ ++val; }
還是++val;
都轉換成了++(val->__forwarding->val);
。
Block中的變數val
為複製到堆上的 __block變數結構體例項,而Block外的變數val
則為複製前棧上的 __block變數結構體例項,但這個結構體的__forwarding
成員變數指向堆上的 __block變數結構體例項。所以,無論是是在Block內部還是外部使用 __block變數,都可以順利訪問同一個 __block變數。
3. 面試題C程式碼
下面我們看看面試題的C程式碼。
@interface Test : NSObject
@end
@implementation Test
- (void)test_notification {
NSNotificationCenter *__weak center = [NSNotificationCenter defaultCenter];
id __block token = [center addObserverForName:@"com.demo.perform.once"
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification * _Nonnull note) {
[self doSomething];
[center removeObserver:token];
token = nil;
}];
}
- (void)doSomething {
}
@end
複製程式碼
3.1 重寫
在命令列中對這個檔案進行一下處理,因為用到了 __weak說明符,需要額外指定一些引數:
xcrun -sdk iphoneos clang -arch arm64 -w -rewrite-objc -fobjc-arc -mios-version-min=8.0.0 -fobjc-runtime=ios-8.0.0 main.m
複製程式碼
這個會更復雜一些,但我們只看重要的部分:
struct __Block_byref_token_0 {
void *__isa;
__Block_byref_token_0 *__forwarding;
int __flags;
int __size;
void (*__Block_byref_id_object_copy)(void*,void*);
void (*__Block_byref_id_object_dispose)(void*);
__strong id token; // id型別的token變數 (strong)
};
struct __Test__test_notification_block_impl_0 {
struct __block_impl impl;
struct __Test__test_notification_block_desc_0* Desc;
Test *const __strong self; // 被捕獲的self (strong)
NSNotificationCenter *__weak center; // center物件 (weak)
__Block_byref_token_0 *token; // token結構體的指標
__Test__test_notification_block_impl_0(void *fp,struct __Test__test_notification_block_desc_0 *desc,Test *const __strong _self,NSNotificationCenter *__weak _center,__Block_byref_token_0 *_token,int flags=0) : self(_self),center(_center),token(_token->__forwarding) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};
複製程式碼
現在我們看到block
結構體 __Test__test_notification_block_impl_0中持有token
,同時之前我們看到token
也是持有block
的,所以造成了迴圈引用。
這也就回答了問題2。
下面我們看看block
的IMP函式是如何解決迴圈引用問題的:
static void __Test__test_notification_block_func_0(struct __Test__test_notification_block_impl_0 *__cself,NSNotification * _Nonnull __strong note) {
__Block_byref_token_0 *token = __cself->token; // bound by ref
Test *const __strong self = __cself->self; // bound by copy
NSNotificationCenter *__weak center = __cself->center; // bound by copy
((void (*)(id,SEL))(void *)objc_msgSend)((id)self,sel_registerName("doSomething"));
((void (*)(id,SEL,id _Nonnull __strong))(void *)objc_msgSend)((id)center,sel_registerName("removeObserver:"),(id)(token->__forwarding->token));
(token->__forwarding->token) = __null;
}
複製程式碼
可以看到,token = nil;
被轉換為了(token->__forwarding->token) = __null;
,相當於block
物件對token
的持有解除了!如果你覺得看不太明白,我再轉換一下:
(__cself->token->__forwarding->token) = __null; // __cself為block結構體指標
複製程式碼
3.2 Block的型別
細心的同學可能發現:
impl.isa = &_NSConcreteStackBlock;
複製程式碼
這是一個棧型別的block
呀,宣告週期結束不是就該被系統回收釋放了麼。我們使用了ARC同時我們呼叫是方法名中含有usingBlock
,會主動觸發 copy操作,將其複製到堆上。
4. 總結
Block最常問的就是迴圈引用、記憶體洩露問題。
注意要點:
- __weak說明符的使用
- __block說明符的使用
- 誰持有誰
- 如何解除迴圈引用
另外,需要再強調一下的是:
-
面試題中的block程式碼如果一次都沒有執行也是會記憶體洩露的!
-
可能有人會說使用
__weak typeof(self) wkSelf = self;
就可以解決self
不釋放的問題。確實這可以解決
self
不釋放的問題,但是這裡 仍然存在記憶體洩露! 我們還是需要從根上解決這個問題。
補充:
上面講的時候集中在說token
和block
的迴圈引用,ViewController
的問題我簡單帶過了,可能同學們看的時候沒有注意到。
我在這裡專門拎出來說一下:
token
和block
迴圈引用,同時block
持有self
(ViewController
),導致ViewController
也沒法釋放。
如果希望優先釋放ViewController
(不管block
是否執行),最好給ViewController
加上__weak
說明符。
此外,破除token
和block
的迴圈引用,實際有兩種方法:
- 手動設定
token = nil;
。 -
token
也使用__weak
說明符id __block __weak token
。
注意:
以下說法不夠嚴謹,也可能存在問題:
最簡單粗暴的解決辦法:大家都
__weak
。
NSNotificationCenter *__weak wkCenter = [NSNotificationCenter >defaultCenter]; __weak typeof(self) wkSelf = self; id __block __weak wkToken = [wkCenter addObserverForName:UIApplicationDidEnterBackgroundNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) { [wkSelf doSomething]; [wkCenter removeObserver:wkToken]; }]; 複製程式碼
這個問題具體要看
NSNotificationCenter
具體是怎麼實現的。token
使用__weak
說明符,但是如果NSNotificationCenter
沒有持有token
,在函式作用域結束時,token
會被銷燬。雖然不會有迴圈引用問題,但是可能導致無法移除這個觀察者的問題。
如果覺得本文對你有所幫助,給我點個贊吧~