
野指針
當(dāng)所指向的對(duì)象被釋放或者收回,但是對(duì)該指針沒(méi)有作任何的修改,以至于該指針仍舊指向已經(jīng)回收的內(nèi)存地址,此情況下該指針便稱野指針
野指針異??胺Qcrash界的半壁江山,相比起NSException而言,野指針有這么兩個(gè)特點(diǎn):
-
隨機(jī)性強(qiáng)
盡管大公司已經(jīng)有各種單元、行為、自動(dòng)化以及人工化測(cè)試,盡量的去模擬用戶的使用場(chǎng)景,但野指針異??偸悄芮擅畹谋荛_測(cè)試,在線上大發(fā)神威。原因絕不僅僅在于測(cè)試無(wú)法覆蓋所有的使用場(chǎng)景造成野指針是多樣化的:首先內(nèi)存被釋放后不代表
內(nèi)存會(huì)立刻被覆寫或者數(shù)據(jù)受到破壞,這時(shí)候訪問(wèn)這塊內(nèi)存也不一定會(huì)出錯(cuò)。其次,多線程技術(shù)帶來(lái)了復(fù)雜的應(yīng)用運(yùn)行環(huán)境,在這個(gè)環(huán)境下,未加保護(hù)的數(shù)據(jù)可能是致命的。此外,設(shè)計(jì)不夠嚴(yán)謹(jǐn)?shù)拇a同樣也是造成野指針異常的重要原因之一 -
難以定位
NSException是高抽象層級(jí)上的封裝,這意味著它可以提供更多的錯(cuò)誤信息給我們參考。而野指針幾乎出自于C語(yǔ)言層面,往往我們能獲得的只有系統(tǒng)棧信息,單單是定位錯(cuò)誤代碼位置已經(jīng)很難了,更不要說(shuō)去重現(xiàn)修復(fù)
定位
解決野指針最大的難點(diǎn)在于定位。通常線上出現(xiàn)了crash需要修復(fù)時(shí),開發(fā)者最重要的一個(gè)步驟是重現(xiàn)crash。而上文提到了野指針的兩個(gè)特性會(huì)阻礙我們定位問(wèn)題,對(duì)于這兩個(gè)特性,確實(shí)也能做一些對(duì)應(yīng)的處理來(lái)降低它們的干擾性:
采集輔助信息
輔助信息包括設(shè)備信息、用戶行為等信息,往往可以用來(lái)重現(xiàn)問(wèn)題。比如用戶行為可以形成用戶使用路徑,從而重現(xiàn)用戶使用場(chǎng)景。而在發(fā)生crash時(shí),采集當(dāng)前頁(yè)面信息,配合用戶使用路徑可以快速的定位到問(wèn)題發(fā)生的大概位置。經(jīng)過(guò)驗(yàn)證,輔助信息確實(shí)有效的減少了系統(tǒng)棧對(duì)于問(wèn)題重現(xiàn)的干擾-
提高野指針崩潰率
由于野指針不一定會(huì)發(fā)生崩潰這一特性,即便我們通過(guò)堆棧信息和輔助信息確定了大致范圍,不代表我們能順利的重現(xiàn)crash。一個(gè)優(yōu)秀的野指針崩潰可以造成一天開發(fā),三天debug,假如野指針的崩潰不是隨機(jī)的,那么問(wèn)題就簡(jiǎn)單的多Xcode提供了Malloc Scribble對(duì)已釋放內(nèi)存進(jìn)行數(shù)據(jù)填充,從而保證野指針訪問(wèn)是必然崩潰的。另外,Bugly借鑒這一原理,通過(guò)修改free函數(shù),對(duì)已釋放對(duì)象進(jìn)行非法數(shù)據(jù)填充,也有效的提高了野指針的崩潰率 Zombie Objects
Zombie Objects是一種完全不同的野指針調(diào)試機(jī)制,將釋放的對(duì)象標(biāo)記為Zombie對(duì)象,再次給Zombie對(duì)象發(fā)送消息時(shí),發(fā)生crash并且輸出相關(guān)的調(diào)用信息。這套機(jī)制同時(shí)定位了發(fā)生crash的類對(duì)象以及有相對(duì)清晰的調(diào)用棧
解決方案
整理一下上述的內(nèi)容,可以看到目前存在輔助信息+對(duì)象內(nèi)存填充以及Zombie Objects這兩種主要的應(yīng)對(duì)方式。拿前者來(lái)說(shuō),填充已釋放對(duì)象的內(nèi)存風(fēng)險(xiǎn)高,經(jīng)過(guò)嘗試Xcode9的Malloc Scribble啟動(dòng)后已經(jīng)不會(huì)填充對(duì)象的內(nèi)存地址。其次,填充內(nèi)存需要去hook更加底層的API,這意味著對(duì)代碼能力要求更高。因此,借鑒Zombie Objects的實(shí)現(xiàn)思路去定位野指針異常是一個(gè)可行的方案
轉(zhuǎn)發(fā)
轉(zhuǎn)發(fā)是一項(xiàng)有趣的機(jī)制,它通過(guò)在通信雙方中間,插入一個(gè)中間層。發(fā)送方不再耦合接收方,它只需要將數(shù)據(jù)發(fā)送給中間層,由中間層來(lái)派發(fā)給具體的接收方?;谵D(zhuǎn)發(fā)的思想,可以做許多有趣的東西:
-
消息轉(zhuǎn)發(fā)
iOS的消息機(jī)制讓我們可以給對(duì)象發(fā)送一個(gè)未注冊(cè)的消息,通常這會(huì)引發(fā)unrecognized selector異常。但是在拋出異常之前,存在一個(gè)消息轉(zhuǎn)發(fā)機(jī)制,允許我們重新指定消息的接收方來(lái)處理這個(gè)消息。正是這一機(jī)制實(shí)現(xiàn)了防unrecognized selector crash的可行化 -
打破引用環(huán)
循環(huán)引用是ARC環(huán)境下最容易出現(xiàn)的內(nèi)存問(wèn)題,當(dāng)多個(gè)對(duì)象之間的引用形成了引用環(huán)時(shí),極有可能會(huì)導(dǎo)致環(huán)中的對(duì)象都無(wú)法被釋放。借鑒Proxy的方式,可以實(shí)現(xiàn)破壞引用環(huán)的作用。XXShield以插入WeakProxy層的方式實(shí)現(xiàn)了防crash -
路由轉(zhuǎn)發(fā)
組件化是項(xiàng)目體量達(dá)到一定程度時(shí)必須考慮的架構(gòu)方案,將項(xiàng)目拆分基礎(chǔ)組件和業(yè)務(wù)組件,加入中間層實(shí)現(xiàn)組件間解耦的效果。由于業(yè)務(wù)組件之間互不依賴,因此需要合適的方案實(shí)現(xiàn)組件通信,路由設(shè)計(jì)是一種常用的通信方式。各個(gè)模塊實(shí)現(xiàn)canOpenURL:接口來(lái)判斷是否處理對(duì)應(yīng)的跳轉(zhuǎn)邏輯,模塊將參數(shù)信息拼接在url中傳遞:
消息發(fā)送
都說(shuō)消息發(fā)送是Objective-C的核心機(jī)制,任何一個(gè)對(duì)象方法調(diào)用都會(huì)被轉(zhuǎn)換成objc_msgSend的方式執(zhí)行。這一過(guò)程中涉及到一個(gè)重要的變量:isa指針。多數(shù)開發(fā)者對(duì)isa指針停留在它指向了類的類結(jié)構(gòu)本身的地址,用來(lái)表示對(duì)象的類型。但是實(shí)際上isa指針要比我們想想的復(fù)雜的多,比如objc_msgSend依賴于isa來(lái)完成消息的查找,通過(guò)閱讀通過(guò)匯編解讀 objc_msgSend可以了解更詳細(xì)的匹配過(guò)程:
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;
};
};
由于方法調(diào)用與isa指針相關(guān),因此如果我們修改一個(gè)類的isa指針使其指向一個(gè)目標(biāo)類,那么可以實(shí)現(xiàn)對(duì)象方法調(diào)用的攔截,也可以稱作對(duì)象方法轉(zhuǎn)發(fā)。我們并不能直接修改isa指針,但runtime提供了一個(gè)object_setclass接口允許我們動(dòng)態(tài)的對(duì)某個(gè)類進(jìn)行重定位
ClassA被重定位成ClassB需要保證兩個(gè)類的內(nèi)存結(jié)構(gòu)是對(duì)齊的,否則可能會(huì)發(fā)生超出意外的問(wèn)題
一般來(lái)說(shuō)我們都不應(yīng)該違背重定位類的內(nèi)存結(jié)構(gòu)對(duì)齊原則。但在野指針問(wèn)題中,對(duì)象擁有的內(nèi)存被釋放后是不確定狀態(tài),因此做適當(dāng)?shù)钠茐?/code>并不一定是壞事,只是記住在最終釋放對(duì)象內(nèi)存時(shí),應(yīng)當(dāng)再次重定位回來(lái),防止內(nèi)存泄漏的風(fēng)險(xiǎn)
代碼實(shí)現(xiàn)
借鑒于Zombie Objects的機(jī)制,我們可以實(shí)現(xiàn)一套類Zombie Proxy機(jī)制。通過(guò)重定位類型的做法,在對(duì)象dealloc之前將其isa指針指向一個(gè)目標(biāo)類,實(shí)現(xiàn)后續(xù)調(diào)用的轉(zhuǎn)發(fā)。而目標(biāo)類中所有的方法調(diào)用都采用NSException的機(jī)制拋出異常,并且輸出調(diào)用對(duì)象的實(shí)際類型和調(diào)用方法幫助定位:

重定位后的類由于其實(shí)際用于轉(zhuǎn)發(fā)的用途,更符合Proxy的屬性,因此我將其設(shè)置為NSProxy的子類,多數(shù)人可能不知道iOS一共有NSProxy跟NSObject兩個(gè)根類。另外,為了實(shí)現(xiàn)對(duì)retain等內(nèi)存管理相關(guān)方法的重寫,目標(biāo)類應(yīng)該設(shè)置為不支持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的方法實(shí)際上是以向上調(diào)用的鏈?zhǔn)綑C(jī)制實(shí)現(xiàn)的,因此只需要hook掉兩個(gè)根類的dealloc方法就能保證對(duì)對(duì)象類型的重定位。在hook掉dealloc之后有幾個(gè)需要注意的點(diǎn):
對(duì)象的釋放
由于我們需要實(shí)現(xiàn)轉(zhuǎn)發(fā)機(jī)制,這代表著本該釋放的對(duì)象在類型重定位后不能被釋放。隨著時(shí)候時(shí)間的推移,重定位類對(duì)象的數(shù)量會(huì)越來(lái)越多。根據(jù)經(jīng)驗(yàn)來(lái)說(shuō),一般的野指針在30s內(nèi)被再次訪問(wèn)的概率很大,因此我們可以在類型重定位完成后延后30s釋放對(duì)象?;蛘呖梢詷?gòu)建一個(gè)Zombie Pool,當(dāng)內(nèi)存占用達(dá)到一定大小時(shí),使用恰當(dāng)?shù)乃惴ㄌ蕴?/p>白名單機(jī)制
并不是所有的類對(duì)象都被監(jiān)控,比如系統(tǒng)私有類、監(jiān)控相關(guān)工具類、明確不存在野指針的類等。我們需要一個(gè)全局的白名單系統(tǒng),來(lái)確保這些類的dealloc是正常執(zhí)行的,無(wú)需被轉(zhuǎn)發(fā)潛在的crash
通過(guò)method_setImplementation替換dealloc的代碼實(shí)現(xiàn),由于我采用block轉(zhuǎn)IMP的方式來(lái)實(shí)現(xiàn)的方式,會(huì)對(duì)捕獲的外界對(duì)象進(jìn)行引用。而對(duì)象在重定位后,任何調(diào)用都會(huì)引發(fā)crash,因此需要針對(duì)這種情況做對(duì)應(yīng)的處理
為了滿足保證對(duì)象能夠在達(dá)成釋放條件完成內(nèi)存的回收,需要存儲(chǔ)根類的dealloc原實(shí)現(xiàn),以根類類名作為key存儲(chǔ)在全局字典中。并且提供接口__lxd_dealloc來(lái)完成對(duì)象的釋放工作:
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)];
}
在對(duì)象的dealloc被調(diào)起之后,檢測(cè)對(duì)象類型是否存在白名單中。如果存在,直接繼續(xù)完成對(duì)對(duì)象的釋放工作。否則的話,延后30s進(jìn)行釋放工作。為了解除block引用造成的crash,使用NSValue存儲(chǔ)對(duì)象信息以及使用__unsafe_unretained來(lái)防止臨時(shí)變量的引用:
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秒釋放對(duì)象,避免造成內(nèi)存的浪費(fèi)
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];
具體的實(shí)現(xiàn)代碼可以下載LXDZombieSniffer
疑難問(wèn)題
野指針問(wèn)題是訪問(wèn)了非法內(nèi)存導(dǎo)致的crash,也就是說(shuō)要符合兩個(gè)條件:內(nèi)存非法以及指針地址不為NULL。在iOS中存在三種不同修飾的指針:
__strong
默認(rèn)修飾符。修飾的指針在賦值之后,會(huì)對(duì)指向的對(duì)象執(zhí)行一次retain操作,指針不因?qū)ο蟮纳芷谧兓淖?/p>__unsafed_unretained
非安全對(duì)象指針修飾符。修飾的指針不會(huì)持有指向?qū)ο螅膊灰驅(qū)ο蟮纳芷诎l(fā)生變化而改變,等同于assign__weak
弱對(duì)象指針修飾符。修飾的指針不會(huì)持有指向?qū)ο螅趯?duì)象的生命周期結(jié)束并且內(nèi)存被回收時(shí),修飾的指針內(nèi)容會(huì)被重置為nil
根據(jù)野指針異常的引發(fā)條件來(lái)說(shuō),三種修飾指針只有__strong和__unsafed_unretained可以導(dǎo)致野指針訪問(wèn)異常。但是在使用類別重定位之后,本該釋放的對(duì)象會(huì)被延時(shí)或者不釋放,也就是本該被重置的弱指針也不會(huì)發(fā)生重置,這時(shí)使用弱指針訪問(wèn)對(duì)象應(yīng)該會(huì)被轉(zhuǎn)發(fā)到ZombieProxy當(dāng)中發(fā)生crash:
__weak id weakObj = nil;
@autoreleasepool {
NSObject *obj = [NSObject new];
weakObj = obj;
}
/// The operate should be crashed
NSLog(@"%@", weakObj);
然而在上面的測(cè)試中,發(fā)現(xiàn)即便對(duì)象被重定位為Zombie并且被阻止釋放之后,weakObj依舊被成功的設(shè)置成了nil。然后經(jīng)過(guò)objc_runtime源碼運(yùn)行和添加斷點(diǎn)測(cè)試之后,也沒(méi)有weak指針被重置的調(diào)用。甚至使用了LLVM的watch set var weakObj監(jiān)控弱指針,依舊無(wú)法找到調(diào)用。但weakObj在dealloc調(diào)用之后,不管對(duì)象有沒(méi)有被釋放,都被重置成了nil。這也是截止文章出來(lái)為止,匪夷所思的疑難雜癥
參考
如何定位Obj-C野指針隨機(jī)Crash(一)
如何定位Obj-C野指針隨機(jī)Crash(二)
如何定位Obj-C野指針隨機(jī)Crash(三)





