作者 | 收納箱,綠洲iOS研發工程師,綠洲ID:收納箱KeepFit
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 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為什麼會形成循環引用呢?
剛剛的面試題比較複雜,我們先來看一個簡單的:
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, &__main_block_desc_0_DATA, 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, 0, sizeof(__Block_byref_age_0), 10};。
int main(int argc, const char * argv[])
{
/* @autoreleasepool */
{ __AtAutoreleasePool __autoreleasepool;
__Block_byref_age_0 age = {(void*)0,(__Block_byref_age_0 *)&age, 0, sizeof(__Block_byref_age_0), 10};
MyBlock block = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, (__Block_byref_age_0 *)&age, 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, &__main_block_desc_0_DATA, (__Block_byref_age_0 *)&age, 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, struct __main_block_desc_0 *desc, __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
在命令行中對這個文件進行一下處理,因為用到了 __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結構體指針
細心的同學可能發現:
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會被銷毀。雖然不會有循環引用問題,但是可能導致無法移除這個觀察者的問題。
就差您點一下了 👇👇👇