當所指向的物件被釋放或者收回,但是對該指標沒有作任何的修改,以至於該指標仍舊指向已經回收的記憶體地址,此情況下該指標便稱野指標
野指標異常堪稱crash界
的半壁江山,相比起NSException
而言,野指標有這麼兩個特點:
-
隨機性強
儘管大公司已經有各種單元、行為、自動化以及人工化測試,儘量的去模擬使用者的使用場景,但野指標異常總是能巧妙的避開測試,線上上大發神威。原因絕不僅僅在於測試無法覆蓋所有的使用場景
造成野指標是多樣化的:首先記憶體被釋放後不代表
記憶體會立刻被覆寫
或者資料受到破壞
,這時候訪問這塊記憶體也不一定會出錯。其次,多執行緒技術
帶來了複雜的應用執行環境,在這個環境下,未加保護的資料可能是致命的。此外,設計不夠嚴謹的程式碼
同樣也是造成野指標異常的重要原因之一 -
難以定位
NSException
是高抽象層級上的封裝,這意味著它可以提供更多的錯誤資訊給我們參考。而野指標幾乎出自於C語言
層面,往往我們能獲得的只有系統棧資訊,單單是定位錯誤程式碼位置已經很難了,更不要說去重現修復
定位
解決野指標最大的難點在於定位。通常線上出現了crash
需要修復時,開發者最重要的一個步驟是重現crash
。而上文提到了野指標的兩個特性會阻礙我們定位問題,對於這兩個特性,確實也能做一些對應的處理來降低它們的干擾性:
-
採集輔助資訊
輔助資訊包括裝置資訊、使用者行為等資訊,往往可以用來重現問題。比如使用者行為可以形成
使用者使用路徑
,從而重現使用者使用場景。而在發生crash
時,採集當前頁面資訊,配合使用者使用路徑
可以快速的定位到問題發生的大概位置。經過驗證,輔助資訊
確實有效的減少了系統棧
對於問題重現的干擾 -
提高野指標崩潰率
由於野指標不一定會發生崩潰這一特性,即便我們通過
堆疊資訊
和輔助資訊
確定了大致範圍,不代表我們能順利的重現crash
。一個優秀的野指標崩潰可以造成一天開發,三天debug
,假如野指標的崩潰不是隨機的,那麼問題就簡單的多Xcode
提供了Malloc Scribble
對已釋放記憶體進行資料填充,從而保證野指標訪問是必然崩潰的。另外,Bugly
借鑑這一原理,通過修改free
函式,對已釋放物件進行非法資料填充,也有效的提高了野指標的崩潰率 -
Zombie Objects
Zombie Objects
是一種完全不同的野指標除錯機制,將釋放的物件標記為Zombie
物件,再次給Zombie
物件傳送訊息時,發生crash
並且輸出相關的呼叫資訊。這套機制同時定位了發生crash
的類物件以及有相對清晰的呼叫棧
解決方案
整理一下上述的內容,可以看到目前存在輔助資訊+物件記憶體填充
以及Zombie Objects
這兩種主要的應對方式。拿前者來說,填充已釋放物件的記憶體風險高,經過嘗試Xcode9
的Malloc Scribble
啟動後已經不會填充物件的記憶體地址。其次,填充記憶體需要去hook
更加底層的API
,這意味著對程式碼能力要求更高。因此,借鑑Zombie Objects
的實現思路去定位野指標異常是一個可行的方案
轉發
轉發是一項有趣的機制,它通過在通訊雙方中間,插入一箇中間層。傳送方不再耦合接收方,它只需要將資料傳送給中間層,由中間層來派發給具體的接收方。基於轉發的思想,可以做許多有趣的東西:
-
訊息轉發
iOS
的訊息機制讓我們可以給物件傳送一個未註冊的訊息,通常這會引發unrecognized selector
異常。但是在丟擲異常之前,存在一個訊息轉發
機制,允許我們重新指定訊息的接收方來處理這個訊息。正是這一機制實現了防unrecognized selector crash
的可行化 -
打破引用環
迴圈引用是
ARC
環境下最容易出現的記憶體問題,當多個物件之間的引用形成了引用環時,極有可能會導致環中的物件都無法被釋放。借鑑Proxy
的方式,可以實現破壞引用環的作用。XXShield以插入WeakProxy
層的方式實現了防crash
-
路由轉發
元件化是專案體量達到一定程度時必須考慮的架構方案,將專案拆分基礎元件和業務元件,加入中間層實現元件間解耦的效果。由於業務元件之間互不依賴,因此需要合適的方案實現元件通訊,路由設計是一種常用的通訊方式。各個模組實現
canOpenURL:
介面來判斷是否處理對應的跳轉邏輯,模組將引數資訊拼接在url
中傳遞:
訊息傳送
都說訊息傳送
是Objective-C
的核心機制,任何一個物件方法呼叫都會被轉換成objc_msgSend
的方式執行。這一過程中涉及到一個重要的變數:isa
指標。多數開發者對isa
指標停留在它指向了類的類結構本身的地址,用來表示物件的型別。但是實際上isa
指標要比我們想想的複雜的多,比如objc_msgSend
依賴於isa
來完成訊息的查詢,通過閱讀通過彙編解讀 objc_msgSend可以瞭解更詳細的匹配過程:
union isa_t {
isa_t() { }
isa_t(uintptr_t value) : bits(value) { }
Class cls;
uintptr_t bits;
struct {
uintptr_t indexed : 1;
uintptr_t has_assoc : 1;
uintptr_t has_cxx_dtor : 1;
uintptr_t shiftcls : 33;
uintptr_t magic : 6;
uintptr_t weakly_referenced : 1;
uintptr_t deallocating : 1;
uintptr_t has_sidetable_rc : 1;
uintptr_t extra_rc : 19;
};
};
複製程式碼
由於方法呼叫與isa
指標相關,因此如果我們修改一個類的isa
指標使其指向一個目標類,那麼可以實現物件方法呼叫的攔截,也可以稱作物件方法轉發。我們並不能直接修改isa
指標,但runtime
提供了一個object_setclass
介面允許我們動態的對某個類進行重定位
ClassA
被重定位成ClassB
需要保證兩個類的記憶體結構是對齊的,否則可能會發生超出意外的問題
一般來說我們都不應該違背重定位類的記憶體結構對齊原則。但在野指標問題中,物件擁有的記憶體被釋放後是不確定狀態,因此做適當的破壞
並不一定是壞事,只是記住在最終釋放物件記憶體時,應當再次重定位回來,防止記憶體洩漏的風險
程式碼實現
借鑑於Zombie Objects
的機制,我們可以實現一套類Zombie Proxy
機制。通過重定位型別
的做法,在物件dealloc
之前將其isa
指標指向一個目標類,實現後續呼叫的轉發。而目標類中所有的方法呼叫都採用NSException
的機制丟擲異常,並且輸出呼叫物件的實際型別和呼叫方法幫助定位:
重定位後的類由於其實際用於轉發的用途,更符合Proxy
的屬性,因此我將其設定為NSProxy
的子類,多數人可能不知道iOS
一共有NSProxy
跟NSObject
兩個根類。另外,為了實現對retain
等記憶體管理相關方法的重寫,目標類應該設定為不支援ARC
:
@interface LXDZombieProxy : NSProxy
@property (nonatomic, assign) Class originClass;
@end
@implementation LXDZombieProxy
- (void)_throwMessageSentExceptionWithSelector: (SEL)selector
{
@throw [NSException exceptionWithName:NSInternalInconsistencyException
reason:[NSString stringWithFormat:@"(-[%@ %@]) was sent to a zombie object at address: %p", NSStringFromClass(self.originClass), NSStringFromSelector(selector), self]
userInfo:nil];
}
#define LXDZombieThrowMesssageSentException() [self _throwMessageSentExceptionWithSelector: _cmd]
- (id)retain
{
LXDZombieThrowMesssageSentException();
return nil;
}
- (oneway void)release
{
LXDZombieThrowMesssageSentException();
}
- (id)autorelease
{
LXDZombieThrowMesssageSentException();
return nil;
}
- (void)dealloc
{
LXDZombieThrowMesssageSentException();
[super dealloc];
}
- (NSUInteger)retainCount
{
LXDZombieThrowMesssageSentException();
return 0;
}
@end
複製程式碼
由於iOS
的方法實際上是以向上呼叫
的鏈式機制實現的,因此只需要hook
掉兩個根類的dealloc
方法就能保證對物件型別的重定位。在hook
掉dealloc
之後有幾個需要注意的點:
-
物件的釋放
由於我們需要實現轉發機制,這代表著本該釋放的物件在型別重定位後不能被釋放。隨著時候時間的推移,重定位類物件的數量會越來越多。根據經驗來說,一般的野指標在
30s
內被再次訪問的概率很大,因此我們可以在型別重定位完成後延後30s
釋放物件。或者可以構建一個Zombie Pool
,當記憶體佔用達到一定大小時,使用恰當的演算法淘汰 -
白名單機制
並不是所有的類物件都被監控,比如
系統私有類
、監控相關工具類
、明確不存在野指標的類
等。我們需要一個全域性的白名單系統,來確保這些類的dealloc
是正常執行的,無需被轉發 -
潛在的crash
通過
method_setImplementation
替換dealloc
的程式碼實現,由於我採用block
轉IMP
的方式來實現的方式,會對捕獲的外界物件進行引用。而物件在重定位後,任何呼叫都會引發crash
,因此需要針對這種情況做對應的處理
為了滿足保證物件能夠在達成釋放條件完成記憶體的回收,需要儲存根類的dealloc
原實現,以根類類名作為key
儲存在全域性字典中。並且提供介面__lxd_dealloc
來完成物件的釋放工作:
static inline void __lxd_dealloc(__unsafe_unretained id obj) {
Class currentCls = [obj class];
Class rootCls = currentCls;
while (rootCls != [NSObject class] && rootCls != [NSProxy class]) {
rootCls = class_getSuperclass(rootCls);
}
NSString *clsName = NSStringFromClass(rootCls);
LXDDeallocPointer deallocImp = NULL;
[[_rootClassDeallocImps objectForKey: clsName] getValue: &deallocImp];
if (deallocImp != NULL) {
deallocImp(obj);
}
}
NSMutableDictionary *deallocImps = [NSMutableDictionary dictionary];
for (Class rootClass in _rootClasses) {
IMP originalDeallocImp = __lxd_swizzleMethodWithBlock(class_getInstanceMethod(rootClass, @selector(dealloc)), swizzledDeallocBlock);
[deallocImps setObject: [NSValue valueWithBytes: &originalDeallocImp objCType: @encode(typeof(IMP))] forKey: NSStringFromClass(rootClass)];
}
複製程式碼
在物件的dealloc
被調起之後,檢測物件型別是否存在白名單中。如果存在,直接繼續完成對物件的釋放工作。否則的話,延後30s
進行釋放工作。為了解除block
引用造成的crash
,使用NSValue
儲存物件資訊以及使用__unsafe_unretained
來防止臨時變數的引用:
swizzledDeallocBlock = [^void(id obj) {
Class currentClass = [obj class];
NSString *clsName = NSStringFromClass(currentClass);
/// 如果為白名單,則不重定位類的型別
if ([__lxd_sniff_white_list() containsObject: clsName]) {
__lxd_dealloc(obj);
} else {
NSValue *objVal = [NSValue valueWithBytes: &obj objCType: @encode(typeof(obj))];
object_setClass(obj, [LXDZombieProxy class]);
((LXDZombieProxy *)obj).originClass = currentClass;
/// 延後30秒釋放物件,避免造成記憶體的浪費
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
__unsafe_unretained id deallocObj = nil;
[objVal getValue: &deallocObj];
object_setClass(deallocObj, currentClass);
__lxd_dealloc(deallocObj);
});
}
} copy];
複製程式碼
具體的實現程式碼可以下載LXDZombieSniffer
疑難問題
野指標問題是訪問了非法記憶體導致的crash
,也就是說要符合兩個條件:記憶體非法
以及指標地址不為NULL
。在iOS
中存在三種不同修飾的指標:
-
__strong
預設修飾符。修飾的指標在賦值之後,會對指向的物件執行一次
retain
操作,指標不因物件的生命週期變化而改變 -
__unsafed_unretained
非安全物件指標修飾符。修飾的指標不會持有指向物件,也不因物件的生命週期發生變化而改變,等同於
assign
-
__weak
弱物件指標修飾符。修飾的指標不會持有指向物件,在物件的生命週期結束並且記憶體被回收時,修飾的指標內容會被重置為
nil
根據野指標異常的引發條件來說,三種修飾指標只有__strong
和__unsafed_unretained
可以導致野指標訪問異常。但是在使用類別重定位
之後,本該釋放的物件會被延時或者不釋放,也就是本該被重置的弱指標也不會發生重置,這時使用弱指標訪問物件應該會被轉發到ZombieProxy
當中發生crash
:
__weak id weakObj = nil;
@autoreleasepool {
NSObject *obj = [NSObject new];
weakObj = obj;
}
/// The operate should be crashed
NSLog(@"%@", weakObj);
複製程式碼
然而在上面的測試中,發現即便物件被重定位為Zombie
並且被阻止釋放之後,weakObj
依舊被成功的設定成了nil
。然後經過objc_runtime原始碼執行和新增斷點測試之後,也沒有weak
指標被重置的呼叫。甚至使用了LLVM
的watch set var weakObj
監控弱指標,依舊無法找到呼叫。但weakObj
在dealloc
呼叫之後,不管物件有沒有被釋放,都被重置成了nil
。這也是截止文章出來為止,匪夷所思的疑難雜症