一. 前言
最近被指派去解決一些線上的崩潰問題
婉刀,經(jīng)常遇到野指針
導(dǎo)致的崩潰
芭概。相對(duì)于其他的原因
引起的崩潰
來說玲昧,野指針
導(dǎo)致崩潰
是最難定位
的耙饰,這里主要總結(jié)了兩種思路
來定位野指針
導(dǎo)致的崩潰。
二. 野指針
1.定義
當(dāng)所指向的對(duì)象被釋放或者收回拴签,但是對(duì)該指針沒有作任何的修改孝常,以至于該指針仍舊指向已經(jīng)回收的內(nèi)存地址,此情況下該指針便稱野指針.
2. 為什么Obj-C
野指針的Crash
那么多篓吁?
一般app
版本發(fā)布之前都會(huì)經(jīng)過多輪研發(fā)自測(cè)
茫因、測(cè)試內(nèi)測(cè)
、灰度測(cè)試
杖剪、開放部分客戶公測(cè)
等,按理說很多Crash
的場(chǎng)景都應(yīng)該覆蓋到了驰贷,但由于野指針
的隨機(jī)性
盛嘿,很經(jīng)常會(huì)使得測(cè)試
的時(shí)候,它是沒有問題括袒,等到真正用戶
使用的時(shí)候才有問題次兆,
隨機(jī)性
問題可以大概分為兩類:
- 跑不進(jìn)出錯(cuò)的邏輯,執(zhí)行不到出錯(cuò)的代碼锹锰,這種可以提高測(cè)試
場(chǎng)景覆蓋度
來解決芥炭。 - 跑進(jìn)了有問題的邏輯漓库,但是
野指針
指向的地址并不一定會(huì)導(dǎo)致Crash
,這就有點(diǎn)看人品了园蝠?
為什么跑進(jìn)了有問題
的邏輯
渺蒿,但還是不一定會(huì)導(dǎo)致Crash
呢?
3.分析
野指針
是指指向一個(gè)已刪除
的對(duì)象
或未申請(qǐng)
訪問受限內(nèi)存區(qū)域
的指針彪薛。本文說的Obj-C野指針
茂装,說的是Obj-C對(duì)象
釋放之后指針未置空,導(dǎo)致的野指針
(Obj-C
里面一般不會(huì)出現(xiàn)為初始化對(duì)象
的常識(shí)性錯(cuò)誤)善延。
既然是訪問已經(jīng)釋放的對(duì)象為什么不是必現(xiàn)Crash
呢少态?
因?yàn)?code>dealloc執(zhí)行后只是告訴系統(tǒng),這片內(nèi)存我不用了易遣,而系統(tǒng)并沒有就讓這片內(nèi)存
不能訪問彼妻。
現(xiàn)實(shí)大概是下面幾種
可能的情況:
對(duì)象釋放
后內(nèi)存
沒被改動(dòng)過,原來的內(nèi)存保存完好豆茫,可能不Crash
或者出現(xiàn)邏輯錯(cuò)誤(隨機(jī)Crash
)侨歉。對(duì)象釋放
后內(nèi)存
沒被改動(dòng)過,但是它自己析構(gòu)的時(shí)候已經(jīng)刪掉某些必要的東西澜薄,可能不Crash
为肮、Crash
在訪問依賴的對(duì)象
比如類成員上
、出現(xiàn)邏輯錯(cuò)誤(隨機(jī)Crash)
肤京。對(duì)象釋放
后內(nèi)存
被改動(dòng)過颊艳,寫上了不可訪問
的數(shù)據(jù)
,直接就出錯(cuò)了很可能Crash
在objc_msgSend
上面(必現(xiàn)Crash
忘分,常見)棋枕。對(duì)象釋放后
內(nèi)存被改動(dòng)過,寫上了可以訪問的數(shù)據(jù)妒峦,可能不Crash
重斑、出現(xiàn)邏輯錯(cuò)誤
、間接訪問到不可訪問的數(shù)據(jù)(隨機(jī)Crash)
肯骇。對(duì)象釋放后
內(nèi)存被改動(dòng)過窥浪,寫上了可以訪問的數(shù)據(jù),但是再次訪問的時(shí)候執(zhí)行的代碼把別的數(shù)據(jù)寫壞了笛丙,遇到這種Crash
只能哭了(隨機(jī)Crash漾脂,難度大,概率低)
E哐臁骨稿!對(duì)象釋放
后再次release
(幾乎是必現(xiàn)Crash
,但也有例外,很常見)坦冠。
如圖所示:
正是因?yàn)橐爸羔樣腥缟隙喾N情況形耗,所以導(dǎo)致crash率一直降不下去。
三. 解決思路
1. 方案一
主要是依據(jù)騰訊Bugly工程師:陳其鋒
的分享得來辙浑。
Demo: FJFZombieSnifferDemo
A. 主要思路
- 通過
fishhook
替換C函數(shù)
的free
方法為自身方法safe_free
激涤,就類似runtime
的方法交換
。
bool init_safe_free() {
_unfreeQueue = ds_queue_create(MAX_STEAL_MEM_NUM);
orig_free = (void(*)(void*))dlsym(RTLD_DEFAULT, "free");
rebind_symbols((struct rebinding[]){{"free", (void*)safe_free}}, 1);
return true;
}
- 然后在
safe_free
方法中對(duì)已經(jīng)釋放變量
的內(nèi)存
例衍,填充0x55
昔期,使已經(jīng)釋放變量
不能訪問,從而使某些野指針
從不必現(xiàn)Crash
變成了必現(xiàn)
佛玄。
這里之所以填充為0x55
是因?yàn)?code>Xcode的僵尸對(duì)象
填充的就是0x55
硼一。
如果填充為像0x22
這樣的數(shù)據(jù)也是可以,因?yàn)橹斑@里是存儲(chǔ)
的是一個(gè)對(duì)象
梦抢,這個(gè)對(duì)象被數(shù)據(jù)覆蓋了般贼,當(dāng)你調(diào)用方法的時(shí)候,數(shù)據(jù)
無法響應(yīng)對(duì)應(yīng)的方法
奥吩,因此也會(huì)導(dǎo)致崩潰
哼蛆。
void safe_free(void* p){
size_tmemSiziee=malloc_size(p);
memset(p,0x55, memSiziee);
orig_free(p);
return;
- 但是由于填充了
0x55
的內(nèi)存地址很可能被新的數(shù)據(jù)內(nèi)容填充,使得野指針
的crash
又變得不必現(xiàn)霞赫。
例如下面這種情況:
UIView *testObj = [[UIView alloc] init];
[testObj release];
for (int i = 0; i < 10; i++) {
UIView* testView = [[UIView alloc] initWithFrame:CGRectMake(0,200,CGRectGetWidth(self.view.bounds), 60)];
[self.view addSubview:testView];
}
[testObj setNeedsLayout];
這里的testObj
指向的內(nèi)存空間的
內(nèi)容被填充為
0x55腮介,然后調(diào)用
free真正釋放了,這塊
內(nèi)存空間,被系統(tǒng)回收利用端衰,但testObj
仍然指向這塊內(nèi)存空間叠洗,
緊接著新生成的UIView
很快的就會(huì)覆蓋了testObj
指向的內(nèi)存空間
,這時(shí)候testObj
指向的仍然還是一個(gè)UIView對(duì)象
旅东,這時(shí)候調(diào)用UIView
的實(shí)例方法setNeedsLayout
方法完全不會(huì)發(fā)生Crash
.
沒有發(fā)生Crash
可不是好事灭抑,因?yàn)檫@種情況如果后續(xù)再Crash
,問題就非常難查抵代,因?yàn)槟憧吹降?code>Crash棧很可能和出錯(cuò)的代碼
完全沒有關(guān)聯(lián)腾节。既然這個(gè)問題這么棘手,最好還是和之前一樣荤牍,讓這個(gè)Crash提前暴露
案腺。
- 為了防止上面這種情況,我們干脆就不釋放這片內(nèi)存了康吵。也就是當(dāng)
free被調(diào)用
的時(shí)候我們不真的調(diào)用free
救湖,而是自己保留著內(nèi)存
,這樣系統(tǒng)不知道這片內(nèi)存已經(jīng)不需要用了涎才,自然就不會(huì)被再次寫上別的數(shù)據(jù).
struct DSQueue* _unfreeQueue = NULL;//用來保存自己偷偷保留的內(nèi)存:1這個(gè)隊(duì)列要線程安全或者自己加鎖;2這個(gè)隊(duì)列內(nèi)部應(yīng)該盡量少申請(qǐng)和釋放堆內(nèi)存。
int unfreeSize = 0;//用來記錄我們偷偷保存的內(nèi)存的大小
#define MAX_STEAL_MEM_SIZE 1024*1024*100//最多存這么多內(nèi)存,大于這個(gè)值就釋放一部分
#define MAX_STEAL_MEM_NUM 1024*1024*10//最多保留這么多個(gè)指針耍铜,再多就釋放一部分
#define BATCH_FREE_NUM 100//每次釋放的時(shí)候釋放指針數(shù)量
- 為了防止
系統(tǒng)內(nèi)存
過快耗盡,我們需要在自己保留的內(nèi)存
大于一定值
的時(shí)候就釋放一部分
邑闺,防止被系統(tǒng)殺死
。同時(shí)在系統(tǒng)內(nèi)存警告
的時(shí)候棕兼,也要釋放一部分內(nèi)存
陡舅。
//系統(tǒng)內(nèi)存警告的時(shí)候調(diào)用這個(gè)函數(shù)釋放一些內(nèi)存
void free_some_mem(size_t freeNum){
#ifdef DEBUG
size_t count = ds_queue_length(_unfreeQueue);
freeNum= freeNum > count ? count:freeNum;
for (int i=0; i<freeNum; i++) {
void *unfreePoint = ds_queue_get(_unfreeQueue);
size_t memSiziee = malloc_size(unfreePoint);
__sync_fetch_and_sub(&unfreeSize, memSiziee);
orig_free(unfreePoint);
}
#endif
}
但是如果只是對(duì)已經(jīng)釋放的對(duì)象
內(nèi)存空間
填充為0x55
,這樣發(fā)生Crash
的時(shí)候,我們得到的崩潰信息
非常有限伴挚,但對(duì)于崩潰信息
靶衍,我們肯定希望知道更具體
一點(diǎn):比如是哪個(gè)類
,調(diào)了什么方法
茎芋,對(duì)象的地址
之類颅眶。為了解決上述的問題,我們引入了一個(gè)代理類
MOACatcher
繼承自NSProxy
田弥,同時(shí)MOACatcher
持有一個(gè)originClass
涛酗,重寫消息轉(zhuǎn)發(fā)
的三個(gè)方法以及NSObject
的實(shí)例方法,來進(jìn)行異常信息
的打印偷厦。
為什么選擇
NSProxy
做代理: 使用NSProxy和NSObject設(shè)計(jì)代理類的差異
- (BOOL)respondsToSelector: (SEL)aSelector
{
return [self.originClass instancesRespondToSelector:aSelector];
}
- (NSMethodSignature *)methodSignatureForSelector: (SEL)sel
{
return [self.originClass instanceMethodSignatureForSelector:sel];
}
- (void)forwardInvocation: (NSInvocation *)invocation
{
[self _throwMessageSentExceptionWithSelector: invocation.selector];
}
#pragma mark - Private
- (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];
}
- 因?yàn)?code>NSProxy只能作為
Objc
對(duì)象的代理商叹,所以safe_free
函數(shù)需要添加判斷。
void safe_free(void* p){
int unFreeCount = ds_queue_length(_unfreeQueue);
// 保留的內(nèi)存大于一定值的時(shí)候就釋放一部分
if (unFreeCount > MAX_STEAL_MEM_NUM*0.9 || unfreeSize>MAX_STEAL_MEM_SIZE) {
free_some_mem(BATCH_FREE_NUM);
}
else{
size_t memSiziee = malloc_size(p);
if (memSiziee > sYHCatchSize) {//有足夠的空間才覆蓋
id obj=(id)p;
Class origClass= object_getClass(obj);
// 判斷是不是objc對(duì)象
char *type = @encode(typeof(obj));
if (strcmp("@", type) == 0) {
memset(obj, 0x55, memSiziee);
memcpy(obj, &sYHCatchIsa, sizeof(void*));//把我們自己的類的isa復(fù)制過去
object_setClass(obj, [MOACatcher class]);
((MOACatcher *)obj).originClass = origClass;
__sync_fetch_and_add(&unfreeSize,(int)memSiziee);//多線程下int的原子加操作,多線程對(duì)全局變量進(jìn)行自加只泼,不用理線程鎖了
ds_queue_put(_unfreeQueue, p);
}else{
orig_free(p);
}
}else{
orig_free(p);
}
}
}
這里騰訊Bugly
分享的有點(diǎn)不同:
object_setClass
可以替換一個(gè)類
的isa
剖笙,但是如果直接替換會(huì)發(fā)生死鎖。這里先對(duì)obj對(duì)象
進(jìn)行0x55
填充请唱,然后將自己類的isa
復(fù)制過去弥咪,之后調(diào)用object_setClass
將原有類替換為代理類MOACatcher
,而Bugly
的分享也是先對(duì)obj對(duì)象
進(jìn)行0x55
填充籍滴,然后將自己類的isa
復(fù)制過去,之后強(qiáng)轉(zhuǎn)為MOACatcher
.同樣這里使用了
編碼類型
來判斷是不是objc
對(duì)象酪夷,Bugly
的分享是通過先獲取所有的objc的類
并存儲(chǔ)
在數(shù)組中,通過判斷數(shù)組中是否含有當(dāng)前類來進(jìn)行判斷孽惰。
2. 方案二
方案二是騎神提出的一種思路:
Demo地址: LXDZombieSniffer
主要思路:
- 通過
objc
的runtime
方法進(jìn)行方法交換
晚岭,交換了根類的NSObject
和NSProxy
的dealloc
方法為originalDeallocImp
执赡。
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)];
}
_rootClassDeallocImps = [deallocImps copy];
- 為了避免
內(nèi)存空間
釋放之后被復(fù)寫
造成野指針
問題麦轰,通過字典_rootClassDeallocImps
存儲(chǔ)被釋放的對(duì)象,同時(shí)設(shè)置在30秒
之后調(diào)用dealloc
方法將存儲(chǔ)的對(duì)象
釋放休玩,避免內(nèi)存空間
的增大
狂鞋。
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
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;
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];
});
也同樣為了獲取更多的崩潰信息采用了繼承自
NSProxy
類的LXDZombieProxy
的來進(jìn)行消息轉(zhuǎn)發(fā)
片择,重寫消息轉(zhuǎn)發(fā)方法
以及內(nèi)存管理
相關(guān)的方法。因?yàn)?code>objc內(nèi)部還有一些
底層
的類骚揍,這些類我們項(xiàng)目中一般不涉及字管,因此不會(huì)是這些類造成野指針
啰挪,就可以通過白名單
的機(jī)制
,放棄對(duì)這些類的dealloc方法
的捕獲嘲叔。
static inline NSMutableSet *__lxd_sniff_white_list() {
static NSMutableSet *lxd_sniff_white_list;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
lxd_sniff_white_list = [[NSMutableSet alloc] init];
});
return lxd_sniff_white_list;
}
四. 方法對(duì)比
第一種方案:
通過free函數(shù)
來進(jìn)行野指針定位
-
優(yōu)點(diǎn): 覆蓋范圍廣亡呵,覆蓋了
OC、C++硫戈、C
函數(shù)锰什,對(duì)于iOS
項(xiàng)目適用于混編的工程。 -
缺點(diǎn): 想要獲得具體的
崩潰信息
丁逝,還是需要進(jìn)行Objc對(duì)象
的判斷汁胆,同時(shí)free函數(shù)
的覆蓋范圍廣,也會(huì)造成一定性能的損耗霜幼,畢竟我們?cè)?code>safe_free中添加了一些判斷嫩码。
第二種方案:
通過dealloc
函數(shù)來進(jìn)行野指針
定位
優(yōu)點(diǎn): 針對(duì)OC語言
,利用OC的方法交換
辛掠、消息轉(zhuǎn)發(fā)
等特性谢谦,對(duì)于iOS項(xiàng)目
來說更具有針對(duì)性
和可擴(kuò)展性
。
缺點(diǎn): 相對(duì)作用范圍較小
五. 詳見:
iOS監(jiān)控-野指針定位
如何定位Obj-C野指針隨機(jī)Crash(一):先提高野指針Crash率
如何定位Obj-C野指針隨機(jī)Crash(二):讓非必現(xiàn)Crash變成必現(xiàn)
如何定位Obj-C野指針隨機(jī)Crash(三):加點(diǎn)黑科技讓Crash自報(bào)家門