一、背景
K歌App在8月31号晚上收到了线上大量的用户反馈,应用使用中出现闪退。
收到反馈后,开发同学在TME的火眼APM平台上根据用户id进行搜索判断,是否有共性的Crash堆栈。将所用的用户都检索了后发现,并没有相关的堆栈信息。
没有堆栈又出现闪退,大概率是watchdog或者oom导致。考虑到K歌线上全量开启了MetricKit进行稳定性收集,如果是Watchdog,应该也能够在APM平台上看到。因此将线索定位在了OOM上。
捞取了反馈用户的客户端日志查看,果然能够在用户的日志中看到Memorywarning的打印。
因此,可以确定这次的闪退问题是内存OOM导致的。
在灯塔上报与K歌的APM的内存模块上查看内存模块的曲线,也可以看到相关的内存水位与内存警告上报有明显的上涨。
K歌的内存水位上报曲线
分页面内存警告次数曲线
可以注意到,按照页面上报的数据,几乎所有的页面都出现了内存警告次数上涨的情况。
二、问题排查
反馈用户的版本集中在K歌的8.12.38版本上。该版本在线上运行了一个月时间。
现在出现有大量反馈,优先考虑引起的原因是配置下发变更或者前后端代码发布,影响了线上代码分支逻辑。
因此,在细化了内存水位与内存警告的数据后,对比前一天同时段数据进行查看,将怀疑时间集中在了30号的10-12点左右。
拉通各个团队的同学一起排查对应时间点的相关发布,发现在前一天的10点钟,有一个配置发布,在外网开启了针对数组越界的防护代码。
出于控制变量的角度,将相关配置进行回滚。
在回滚配置后观察,在上报报表上对比两天同时段的数据,发现内存水位和内存警告数都出现了回落与下降。
回滚当天的表现如下
因此确定是这个配置修改打开外网的防护开关引起的OOM问题。
三、问题定位
虽然通过回滚配置并对比外网数据,发现问题被解决了,但是具体原因还需要进行分析才能明确。
3.1防护源码
K歌在启动后,会读取后台配置,通过OC的Runtime,将Array相关接口进行hook,进行常见的防护判断。
主要是以下相关方法:
对不可变数组NSArray,将以下3个方法进行swizzle进行保护。
initWithObjects:count:
objectAtIndex:
objectAtIndexedSubscript:
对可变的NSMutableArray,将这5个方法进行了swizzle替换。
objectAtIndex:
addObject:
removeObjectAtIndex:
replaceObjectAtIndex:withObject:
insertObject:atIndex:
以下是替换至业务层的防护代码实现:
上述的相关防护代码,主要是对业务层增加了参数合法性的前置校验判断,避免出现数组越界访问等会导致应用Crash的情况。
那么为什么开启这段防护代码后,会出现内存问题呢?
3.2问题复现
首先在本地直接开启相关配置后进行调试,利用Xcode和Instrument查看实时内存水位查看能否复现问题。
启动App并打开相关配置后,将App挂在K歌的直播歌房等场景中,运行一段时间后能观察到应用内存出现了上涨。
尤其是在动画&前后台切换等场景下,内存使用出现了上涨,且在退出相关场景后,内存没有出现对应的下降。
对比正常情况下的表现不一致。因此判断此时已经出现了内存泄露。
通过MemoryGraph,导出了两个对应时间下的内存快照对比。
左侧是使用App约40分钟后,App使用内存在1.55G,右侧则是使用60分钟,App内存增长到了1.83G的。
对比发现,主要是@autoreleasepool content对象,出现了大量增长,由左侧的13w个对象增长到20w个对象。
相关的内存占用从522M增长到了786M,增长了250M的内存空间。总共增长300M,占总增长的80%。
因此可以确定是@autoreleasepool content这个对象在某些场景下出现了内存泄露,不断累积,导致了OOM。
那么,为什么对数组的相关方法Hook后,会导致大量的@autoreleasepool content对象创建呢?且为什么这些对象都不会被释放?
通过Xcode的Malloc Stack Logging选项,可以查看这些@autoreleasepool content对象的创建堆栈。
我们发现@autoreleasepool content对象都集中在NSMutableArray 的 kscrash_objectAtIndex: 方法中被创建。
而这个方法正好是我们进行Hook防护的代码之一。因此基本可以确定是这个方法引入的问题。
四、原因分析
4.1 现象分析
首先关注调用堆栈。
@autoreleasepool content 对象就是我们常说的自动释放池,在系统底层映射的应该就是AutoreleasePoolPage 对象。
4.1.1调用堆栈观察
1. 先关注堆栈的倒数第4层调用,调用创建AutoreleasePoolPage的方法入口就是kscrash_objectAtIndex: 方法。我们可以逆向查看这个方法的二进制实现
可以看到由于对应的源码文件在业务层默认采用的是ARC方式编译。因此编译过程中,编译器会自动插入 autorelease 的调用。所以这个逻辑的最终实现,映射MRC模式下的实现应该是
代码语言:javascript复制- (id)kscrash_objectAtIndex:(NSUInteger)index {
if (index < self.count) {
return [[self kscrash_objectAtIndex:index] autorelease];
} else {
return nil;
}
}
这里可以解释为什么这个方法会与AutoreleasePool有所关联。
2. 再看上一层的调用堆栈。@autoreleasepool content的创建堆栈集中在__CFRunLoopDoObservers函数。少量在其他堆栈中。
可以认为,主要是在__CFRunLoopDoObservers 这个场景下出现了不符合预期的情况。
4.1.2 引用关系观察
通过内存快照选中查看单个的@autoreleasepool content对象的内存引用关系,发现有两个现象:
1. 单个@autoreleasepool content中存在对某些对象的多次重复持有。以下是单个引用关系,其中 32 bytes 和 40 bytes 分别是当前对象的父节点和子节点。
其余的则是被添加到pool中进行内存管理的对象们。
可以看到该对象主要持有了大量的CFRunloopObserver对象,且会对C-FRunloopObserver对象产生多次持有。
如图中,可以看到这个@autoreleasepool content对象存在了对多个CFRunloopObserver的63和62次引用。
2.@autoreleasepool content之间存在非常深的链式持有关系。
每个@autoreleasepool content在运行过程中其实是一个双向链表的结构@autoreleasepool content是固定4KB的内存大小。
在运行过程中,如果当前pool满了,则会创建下一个pool,并会互相持有,分别作为对方的父节点与子节点。
如下图,可以看到我们选中的这个@autoreleasepool content 被大量的父节点持有,同时这个对象也持有了大量的子节点。
4.1.3 正常情况下的表现
此时我们可以对比正常没有进行防护的应用表现,可以发现内存不再增长,观察此时的MemoryGraph。
一,此时内存快照中的@autoreleasepool content的数量没有随着对应用的操作而增长,而是维持在一个相对稳定的数量上。
二,没有发现基于__CFRunLoopDoObservers链路创建的@autorelease-pool content对象。
三,没有发现大量链式持有的@autoreleasepool content集合。
通过对比可以得出结论,是由于这段针对MutableArray 的 objectAtIndex方法进行防护逻辑,使得@autoreleasepool content 对象被大量创建且不释放,不断积累,导致了应用出现OOM。
如果我们能将上述现象解释明白,这个问题的原因也就解决了。
4.2 Runloop的执行
先分析__CFRunLoopDoObservers这个函数。
通过函数名即可知这是在iOS Runloop体系中的一个函数。因为iOS 的Runloop是开源的,省了人工逆向的逻辑,我们直接去appleopensource上下载源码来查看。
Runloop的代码实现在CoreFoundation中,这里是源码地址:
https://opensource.apple.com/source/CF/
其中有不同版本的CoreFoundation代码,我们直接查看最新的那一套。
https://opensource.apple.com/source/CF/CF-1153.18/
4.2.1 __CFRunLoopDoObservers的函数逻辑
下载后,可以在CFRunLoop.c 这个文件中找到Runloop的实现。直接搜索__CFRunLoopDoObservers,即可找到对应实现。
简单解释下__CFRunLoopDoObservers的代码逻辑,函数参数为当前Runloop对象 rl,当前的RunloopMode 对象 rlm,和当前的状态activity。
函数中,绿色框部分是通过CFArrayGetValueAtIndex 函数将 rlm(Runloop Mode)所持有的observers进行一次遍历,取出所有合法且注册监听状态与activity一致的observer,添加进入collectedObservers 这个临时数组中。
在蓝色框部分中,依次遍历collectedObservers这个数组中的observer对象,并通知他们当前runloop的状态发生了变化。即将进入对应的某个activity状态。
此时,我们可以注意到关键函数CFArrayGetValueAtIndex。我们可知iOS的体系中,CoreFoundation 与 Foundation中的对象其实是Full-bridge的,两个框架之间的对象可以进行强制的类型互转。
也就是,我们对NSMutableArray的objcAtIndex进行swizzle,映射下来也会影响到CFArrayGetValueAtIndex这个函数逻辑。
也就解释了为什么在我们的防护逻辑下,__CFRunLoopDoObservers这个函数会调用到我们的kscrash_objectAtIndex 这个方法中来。
且可知此时的数组中的对象全部都是 CFRunloopObserver 类型,也就是我们上面看到的大量反复持有的CFRunloopObserver对象。
4.2.2 __CFRunLoopDoObservers什么时候被调用
iOS的同学都看过下面这张关于Runloop执行的流程的图。
可以看到iOS中Runloop的执行,就是驱动自身的Observer通知状态变更,处理Source0和Source1事件。
核心函数就是__CFRunLoopRun 这个函数。这块相关的文章太多了,我们直接贴一张相关的图片。
可以看到,__CFRunLoopDoObservers 的作用,就是将当前状态的变更对注册进来的observer进行通知。
实际会在上图中所有通知Observer的时候调用__CFRunLoopDoObservers这个函数。
到这里我们可以解释为什么__CFRunLoopDoObservers这个函数会调用到我们业务层的防护逻辑。以及为什么MemoryGraph中可以看到@autoreleasepool content对象中会引用CFRunloopObserver。
这是由于__CFRunLoopDoObservers中会调用CFGetObjectAtIndex,将持有CFRunloopObserver的数组进行遍历。
导致业务层会调用CFRunloopObserver对象的autorelease方法,将CFRunloopObserver对象加入到自动释放池中。
4.3 @autoreleasepool content
@autoreleasepool content 其实就是AutoreleasePoolPage。相关实现定义在objc4这套代码里面,一样是全开源的,源码可看:
https://opensource.apple.com/source/objc4/
4.3.1 结构定义
Autoreleasepool的结构定义如下,每个AutoreleasepoolPage 的大小固定为4Kb,除了头结点所持有的一些必要信息,其他内存空间都用作持有添加进入释放池的obj对象。
在实际的使用过程中,AutoreleasepoolPage是一个双向链表,有成员变量同时指向父节点和子节点。整体结构定义如下:
(ps:翻objc4源码的时候发现在最新版的AutoreleasepoolPage与这块有些不同,新加入了AutoreleasePoolPageData的相关定义。但是总逻辑是一致的。)
4.3.2 add函数
在应用运行过程中,如果有某个对象的autorelease方法被调用,则会最终调用到AutoreleasePoolPage的函数中。
调用栈为
这个函数的逻辑就是将obj引用压入自己当前的栈顶中,并修改next指针至下一个可控空间。注意此时obj可以为任何对象,也可以为nil指针。
代码语言:javascript复制 id *add(id obj)
{
assert(!full());
unprotect();
id *ret = next; // faster than `return next-1` because of aliasing
*next = obj;
protect();
return ret;
}
4.3.3 AutoreleasePoolPage的使用
我们看下在MRC环境下是如何使用创建autoreleasePool的。
代码语言:javascript复制int main(int argc, const char * argv[]) {
{
void * atautoreleasepoolobj = objc_autoreleasePoolPush();
// do whatever you want
objc_autoreleasePoolPop(atautoreleasepoolobj);
}
return 0;
}
可以看到实际上就是通过调用autoreleasepoolPush函数启用,其实会有一个返回指针。在需要结束,将池子中的对象都释放的话,调用autoreleasepoolPop函数,将指针传入即可。
4.3.4 objc_autoreleasePoolPush
可以看到,push函数,就是将POOL_SENTINEL作为参数,调用autoreleaseFast函数。
可知POOL_SENTINEL是一个宏定义,其本质就是一个 nil 指针。
代码语言:javascript复制#define POOL_SENTINEL nil
static inline void *push()
{
id *dest;
{
dest = autoreleaseFast(POOL_SENTINEL);
}
assert(*dest == POOL_SENTINEL);
return dest;
}
看下autoreleaseFast函数的实现。
我们在上面的结构可知,每个AutoreleasepoolPage 其实只有4k的大小。按照每个指针为8个字节计算,实际每个AutoreleasepoolPage能引用的对象就是为500多个。
因此在autoreleaseFast函数中,先通过hotPage获取当前线程当前可用的poolPage对象,并判断了下这个page对象是不是full的。如果没有满,则直接调用add函数,也就是直接将一个nil指针压入了当前poolPage的栈顶中。
代码语言:javascript复制 static inline id *autoreleaseFast(id obj)
{
AutoreleasePoolPage *page = hotPage();
if (page && !page->full()) {
return page->add(obj);
} else if (page) {
return autoreleaseFullPage(obj, page);
} else {
return autoreleaseNoPage(obj);
}
}
如果page已经满了,则会调用autoreleaseFullPage函数。创建一个新的PoolPage对象,并设置为当前的hotPage。
同时也会设置两个page之间的父子关系,也就是构建page之间的双向链表引用关系。
代码语言:javascript复制 static __attribute__((noinline))
id *autoreleaseFullPage(id obj, AutoreleasePoolPage *page)
{
// The hot page is full.
// Step to the next non-full page, adding a new page if necessary.
// Then add the object to that page.
assert(page == hotPage());
assert(page->full() || DebugPoolAllocation);
do {
if (page->child) page = page->child;
else page = new AutoreleasePoolPage(page);
} while (page->full());
setHotPage(page);
return page->add(obj);
}
这里能够解释上面MemoryGraph中出现@autoreleasepool content对象出现链式引用的原理。
我们可以得出结论,如果@autoreleasepool content满了,就会创建下一个@autoreleasepool content,如果这个又满了,则会继续创建下一个,无限创建下去。
那么这些@autoreleasepool content到底什么时候会被释放呢?
4.3.5 objc_autoreleasePoolPop
我们看Push函数对称的Pop函数实现。
在实际中调用,我们会调用objc_autoreleasePoolPop (atautoreleasepoolobj),同时我们也知道了,atautoreleasepoolobj 这个指针,其实就是POOL_SENTINEL,也就是一个nil指针。作为哨兵对象。
看下Pop函数的实现。(有删减一些与本次分析无关分支,大家可以自己读下相关完整源码)
代码语言:javascript复制
static inline void pop(void *token) // nil
{
AutoreleasePoolPage *page;
id *stop;
page = pageForPointer(token);
// this
stop = (id *)token;
if (PrintPoolHiwat) printHiwat();
// 遍历清空自己的值
page->releaseUntil(nil);
if (page->child) {
// hysteresis: keep one empty child if page is more than half full
if (page->lessThanHalfFull()) {
page->child->kill();
}
// 删掉孙子节点
else if (page->child->child) {
page->child->child->kill();
}
}
}
可知在当前,token指针就是一个nil指针,那么此处的stop的指针,就是nil,实际上这时候又调用到了page的releaseUntil函数,将POOL_SENTINEL(nil,哨兵对象)作为参数传入。
我们看下releaseUntil的源码实现
代码语言:javascript复制void releaseUntil(id *stop)
{
// Not recursive: we don't want to blow out the stack
// if a thread accumulates a stupendous amount of garbage
while (this->next != stop) {
// Restart from hotPage() every time, in case -release
// autoreleased more objects
AutoreleasePoolPage *page = hotPage();
// fixme I think this `while` can be `if`, but I can't prove it
while (page->empty()) {
page = page->parent;
setHotPage(page);
}
page->unprotect();
id obj = *--page->next;
memset((void*)page->next, SCRIBBLE, sizeof(*page->next));
page->protect();
if (obj != POOL_BOUNDARY) {
objc_release(obj);
}
}
setHotPage(this);
}
这里的逻辑就是,利用next指针,从栈顶开始判断,如果当前的栈顶元素不为传入的stop指针相等,那么就会将栈顶对象取出,next指针--即清理这个指针空间,然后调用一次这个取出来对象的release方法。
如果调用release后这个对象的retainCount为0,则会触发这块内存的回收。
如果当前的page全都遍历完,但是依旧没有找到这个stop指针呢?
则代表这个page已经全部释放完了,同时将这个page的父节点设置为hotPage,并进行下一次的遍历查找stop指针。
结合Pop函数,当查找到stop指针后,会再遍历一次page的所有子节点,如果这些节点空了,则会调用page的kill函数,回收page本身的内存占用。
4.4 总结
那么,结合上下逻辑,我们可知poolpage对象的内存创建与回收时机,
在push函数中传入POOL_SENTINEL作为哨兵对象,然后再后续的add函数中,如果满了则会继续创建下一个poolpage。
但是在pop函数中,则会从最子节点开始往回回溯,查找POOL_SENTINEL,并释放这个过程中通过add函数加入所持有的各种业务对象。
在遍历结束中,并释放已经empty的子节点们,完成内存的回收。
因此可知AutoreleasepoolPage的push和pop函数必须对称使用,才能实现合理高效的内存管理逻辑。
4.5 Runloop与Autoreleasepool的结合
这里自然而然可以联想到一个很经典的iOS面试题:iOS中Runloop与Autoreleasepool的关系是什么?
那么我们来回答下这个问题!
我们可以直接获取Runloop中打印一下CFRunLoopObserver的对象,可以观察到两个关键的Observer,他们注册的回调函数都是_wrapRunLoopWithAutoreleasePoolHandler。
代码语言:javascript复制"<CFRunLoopObserver {valid = Yes, activities = 0x1, repeats = Yes, order = -2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler , context = {type = mutable-small, count = 0, values = ()}}",
"<CFRunLoopObserver {valid = Yes, activities = 0xa0, repeats = Yes, order = 2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler , context = {type = mutable-small, count = 0, values = ()}}"
他们的优先级分别为-2147483647和2147483647,转成16进制为0x7FFFFFFF。是一个极大值和极小值。代表两个Observer分别需要在状态通知中,被最早调用和最晚调用。
一个Observer会监听Entry(即将进入Loop),并且优先级最高,会第一个调用PoolPage的Push函数,保证创建释放池发生在其他所有回调之前。
另一个Observer会监听BeforeWaiting(准备进入休眠)和Exit(即将退出Loop) ,且优先级最低,确保其他回调都执行结束了,才会回溯自动释放池,并回收内存。
_wrapRunLoopWithAutoreleasePoolHandler这个函数中,就是判断传入的activities类型,分别调用_objc_autoreleasePoolPush 和 _objc_autoreleasePoolPop函数,来进行自动释放池的管理。
因此我们写的业务代码,在Runloop的运行驱动之中,这些调用都在被Runloop创建的AutoreleasePool所环绕,从而实现了内存管理,也帮助我们确保不会出现内存泄露。
4.6 OOM的原因分析
结合上面的背景知识,再来看我们的业务场景
我们swizzle了NSMutableArray的方法,将objectAtIndex转移至业务层的kscrash_objectAtIndex: ,
则会主动调用一次对应obj的autorelease 方法。而autorelease的实现,就是调用当前Autoreleasepool的add函数,将obj加入自动释放池中。
结合上述的Autoreleasepool 和 Runloop的源码可知,在应用运行过程中,
1. 由于业务方的代码Hook,会将通过objectAtIndex来遍历数组对象的对象,添加进入Autoreleasepool之中。也就是CFRunloopObserver对象。那么
2. 系统会在Runloop启动的时候,通过遍历Runloop Observer,触发Autoreleasepool调用Push函数,向栈内加入一个哨兵对象作为标志位。
3. 在Runloop结束后,通过遍历Runloop Observer调用Autoreleasepool的Pop函数,逐一释放对象,直到遇见哨兵对象为止。
4. 最终的状态如下,对比这次Runloop启动前,可以发现当前poolpage中已经多加了observer对象在池子中。
那么就会导致
1. Runloop Observer对象,在哨兵对象之前被添加进入了Autoreleasepool的栈顶。
2. Observer没有对应被移出Autoreleasepool的调用时机。
3. 循环执行的的Runloop函数,导致RunloopObserver被不断地压入Autoreleasepool池子中。
4. Autoreleasepool只有4k空间用于存储数据,当池子满了,则会自动创建下一个Autoreleasepool对象。
5. 大量的Autoreleasepool随着Runloop的在内存中被创建且不释放。
6. 随着App的运行时间变长,已申请内存空间不断增加,可用空间越来越少,最终导致应用OOM