【iOS】一段防护代码引发的内存风暴

2023-10-23 17:59:00 浏览数 (1)

一、背景

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

0 人点赞