NSAttributedString的autorelease内存风暴

前言

  微信读书iOS客户端也有几年历史了,和人一样,项目代码老了或多或少总有一些毛病很难根治。微信读书iOS客户端就有一个这样的历史代码模块:阅读器排版引擎。想来这个引擎代码还是和 @bang哥 共事年代的代码。基于DTCoreText的HTML树排版引擎,总体思路就是把HTML文本通过libxml2解析,再用DFS遍历树结点,处理CSS后转成NSAttributedString,最终利用CoreText渲染出来,当然少不了在源码基础上,针对业务进行各种魔改。

顽疾

  回到微信读书排版引擎的问题,经常有微信读书的线上用户反馈过来,阅读的时候经常卡死闪退,但是我们的内部监控系统(bugly/matrix)一直没有抓到crash堆栈上报。这就很神奇了,根据以往经验,没有crash堆栈上报很大概率是被系统的WatchDog强杀了,一般是OOM或者wakelock太频繁。针对用户反馈的日志定位到相应的场景,发现有一个共性:HTML里<p>标签特别多,具体用户的场景里是遇到单个章节有2w+的<p>标签,大胆盲猜,这里是OOM了,果然用instruments一跑,发现了有个神奇的东西:


Allocation Summary里多了很多@autoreleasepool content,每一个占去了4k的内存,App的内存在短时间内暴涨到1个G以上,触发系统WatchDog强杀。

定位原因

  这里大小为4k@autoreleasepool content是怎么产生的?而且NSAttributedString在整个iOS开发生态里使用非常多,UIKit系统控件都会用到,为什么单单这个场景会引发这个问题? 难道又是打开方式不对?长文预警,想看结论的同学可以直接跳到后面

我们带着这两个疑问继续往下分析,我们先来看下调用链:

  1. DFS遍历树结点[DTHtmlElement attributeString]
  2. <p>标签结束处理段落appendEndOfParagraph,代码可以简化为:

    1
    2
    3
    4
    5
    6
    7
    8
    - (void)appendEndOfParagraph {
    ....
    // create a temp attributed string from the appended part
    NSAttributedString *appendString =
    [[NSAttributedString alloc] initWithString:@"\n" attributes:attributes];
    [self appendAttributedString:appendString];
    }
  3. 生成NSAttributedString对象,并设置属性attributes

  4. NSConcreteHashTable触发autoreleaseFullPage去申请内存_malloc_zone_memalign

单纯看项目代码,是很普通的函数调用,为什么会用到HashTable,HashTable又为什么会触发autoreleaseFullPageautorelease pool没有及时释放吗?
关于autorelease,网上有很多文章,大家可以自行google或参考文章最后的文章链接,基本结论可以归结为:

  1. autorelease对象什么时候产生?

    • 以alloc/new/copy/mutableCopy开头的函数调用,编译器会自动插入release语句,否则返回的对象会加到autorelease池子中
    • 函数调用会根据objc_autoreleaseReturnValueobjc_retainAutoreleasedReturnValue进行TLS优化判断,避免autorelease过多
  2. autorelease对象什么时候释放?

    • @autoreleasepool{} -> 退出作用域的时候
    • NSThread -> 线程退出调用tls_dealloc的时候
    • GCD -> 处理完队列任务后调用 _dispatch_last_resort_autorelease_pool_pop

不过GCD任务存在多线程切换的时机问题,释放时机有随机性,按照Apple文档的说法:

If your block creates more than a few Objective-C objects, you might want to enclose parts of your block’s code in an @autorelease block to handle the memory management for those objects. Although GCD dispatch queues have their own autorelease pools, they make no guarantees as to when those pools are drained. If your application is memory constrained, creating your own autorelease pool allows you to free up the memory for autoreleased objects at more regular intervals.
大概的意思就是GCD会自动添加autorelease池,但释放时机不能保证,创建大量对象时需要自行添加@autoreleasepool{}保证及时释放

回到微信读书排版引擎的代码实现本身,逻辑上讲并没有什么问题,可以简化为:

1
2
3
4
5
6
dispatch_group_async(queue, ^{
...
assembleString = [node attributeString];
[assembleString appendEndOfParagraph];
...
})

  那这里是不是加上@autoreleasepool{}就万事大吉了呢?结果一顿操作,内存是降下来了,但排版速度却严重变慢,看来还不是这么简单,这里先盗张图:

autorelease池子会不断pop对象并调用[obj release],查询共用的sizetable重新计算refCnt,refCnt为0的时候要调[obj dealloc]autorelease对象多的话这个过程还是挺耗时的。

  既然不能直接加@autoreleasepool{},那回过头来看,可不可以减少加到autorelease池里对象呢?首先我们要搞清楚这里加入aurelease池是哪些对象?为什么会产生大量的对象?从代码上看,NSSAttributeString直接调用alloc方法,返回对象经过TLS优化也不存在加入aurelease池的情况(可以在xcode里通过Debug->DebugWorkflow->Disassembly查看汇编代码,再用LLDB进去看TLS优化生效了没有)没办法只能祭出最后的武器:汇编

  通过反编译Foundation/UIFoundation/libobjc.A.dylib,我们大概理清了一下NSAttributedString的初始化过程:

  1. NSConcreteAttributedString初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    int -[NSConcreteAttributedString initWithString:attributes:]() {
    r14 = rcx;
    rbx = [rdi initWithString:rdx];
    if ((r14 != 0x0) && (rbx != 0x0)) {
    r15 = [[NSMutableRLEArray allocWithZone:[rbx zone]] init];
    r14 = [__NSAttributeDictionaryClass() newWithDictionary:r14]; //r14 是传入属性NSDictionary
    [rbx length];
    [r15 insertObject:r14 range:0x0];
    [r14 release];
    rbx->attributes = r15;//r15 是 NSMutableRLEArray
    }
    rax = rbx;
    return rax;
    }
  2. NSDictionary -> NSAttributeDictionary过程可以简化为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    + (NSAttributeDictionary*)newWithDictionary:(NSDictionary*)dict {
    if (!dict) {
    return _emptyAttributeDictionary;
    }
    os_unfair_lock_lock_with_options(_attributeDictionaryLock, 0x10000);
    NSAttributeDictionary* rax = [_attributeDictionaryTable getItem:dict];//NSConcreteHashTable
    if (!rax){
    os_unfair_lock_unlock(_attributeDictionaryLock);
    ...
    NSZoneMalloc();//找不到重新new一个
    ...
    [dict getObjects:andKeys:count:];// 遍历dict取出kv对,copy到NSAttributeDictionary
    ...
    }else{
    [rax retain];//找到直接返回对象复用
    os_unfair_lock_unlock(_attributeDictionaryLock);
    }
    return rax;
    }

_attributeDictionaryTable是全局共用的NSConcreteHashTable,操作的时候需要加上os_unfair_lockNSAttributeDictionary存放在NSConcreteHashTable,看逻辑应该是为了在初始化NSAttributedString的attributes的时候进行对象复用。

  1. NSConcreteHashTable如何查找NSDictionary

    1
    2
    3
    4
    5
    6
    - (void)getItem:(NSDictionary*)dict {
    if (dict != 0x0) {
    rax = _hashProbe(dict);//关键函数
    }
    return rax;
    }
  2. hashProbe函数简化版:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    id hashProbe(NSDictionary* dict) {
    void* location = attributeDictionaryHash(dict);// hash函数:遍历kv值进行hash累加
    do {
    ...
    location = (location + 1) & (capicity - 1);// hash值偏移0x1 线性探测
    obj = readARCWeakAtWithSentinel(location, 0);
    if (!obj){
    break; // hash桶的位置数据为空
    }
    } while (!isEqualFuntion(obj, dict));
    return obj;
    }
  3. readARCWeakAtWithSentinel从HashTable里取出NSAttributeDictionay

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    id readARCWeakAtWithSentinel(void* location, int sentinel){
    ...
    rax = objc_loadWeak(locaction);// 关键函数
    return rax;
    }
    id objc_loadWeak(void* location){
    if (!*location) return nil;
    return objc_autorelease(objc_loadWeakRetained(location));//autorelease在这里
    }

看到这里终于理清了这些autorelease对象是哪里产生: NSConcreteHashTable进行hash探测的时候,会不断读出hash值所在location的对象,放到autorelease池并进行isEqualFunction比较,如果不相等,hash值会先偏移0x1继续while查找。问题就在这里:如果NSConcreteHashTable里存了大量的对象,那这个while过程会不断产生autorelease对象,造成AutoreleasePoolPage::autoreleaseFullPage不断重新申请4k的内存

实践出真理,我们直接fishhook相应的函数来验证:

1
2
3
4
5
6
7
8
NSUInteger attributeDictionaryCount = 0;
static id (*orig_objc_loadWeak)(id *location);
id my_objc_loadWeak(id *location) {
if ([(*location) isKindOfClass:NSClassFromString(@"NSAttributeDictionary")]){
++attributeDictionaryCount;// 计数
}
return orig_objc_loadWeak(location);
}

排版一个有2w个<p>标签的HTML章节,objc_loadWeak会加载6747wNSAttributeDictionary对象

解决方案

  到此,NSAttributedString内存暴涨的原因算是找到了,解决方案其实很简单:就是如何避免hash冲突。这里有两个前提:

  1. 直接去改NSDictionaryhash函数有点困难
  2. @autoreleasepool{} 影响效率

其实从场景上分析,HTML的<p>标签是有限个(实际场景中超1w个的都是极少数),而且<p>对应的attributes里只保存的对象的pointer,内存占用大小也只是NSAttributeDictionay本身,attributes复用的意义并不是很大。这里直接在attributes里加上一个 随机因子,减少 hashProbe 的命中数,NSConcreteHashTable没命中会直接跳过objc_loadWeak的调用,也就不会产生autorelease对象了。

1
2
3
4
5
6
7
8
9
10
- (void)appendEndOfParagraph {
....
// create a temp attributed string from the appended part
NSAttributedString *appendString =
[[NSAttributedString alloc] initWithString:@"\n" attributes:attributes];
[attributes setObject:@(arc4random()) forKey:@"random"];//随机因子
[self appendAttributedString:appendString];
}

优化后解析同一个HTML章节,objc_loadWeak只加载了 27wNSAttributeDictionary对象

当然这种解决方案并不是最好的,只是衡量投入产出比后折中的方案,如果有更好的解决方案,欢迎私信交流!

最后打个广告:微信读书iOS/Android客户端大量招人,欢迎大家加入
简历直接发:jasenhuang@rdgz.org

友情链接

  1. 黑幕背后的Autorelease
  2. 自动释放池的前世今生
  3. Autorelease 之不经意间可能被影响的优化
  4. Revisit iOS Autorelease