前言
微信读书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系统控件都会用到,为什么单单这个场景会引发这个问题? 难道又是打开方式不对?长文预警,想看结论的同学可以直接跳到后面
我们带着这两个疑问继续往下分析,我们先来看下调用链:
- DFS遍历树结点
[DTHtmlElement attributeString]
在
<p>
标签结束处理段落appendEndOfParagraph
,代码可以简化为:12345678- (void)appendEndOfParagraph {....// create a temp attributed string from the appended partNSAttributedString *appendString =[[NSAttributedString alloc] initWithString:@"\n" attributes:attributes];[self appendAttributedString:appendString];}生成
NSAttributedString
对象,并设置属性attributes
NSConcreteHashTable
触发autoreleaseFullPage
去申请内存_malloc_zone_memalign
单纯看项目代码,是很普通的函数调用,为什么会用到HashTable,HashTable又为什么会触发autoreleaseFullPage
?autorelease pool
没有及时释放吗?
关于autorelease
,网上有很多文章,大家可以自行google或参考文章最后的文章链接,基本结论可以归结为:
autorelease
对象什么时候产生?- 以alloc/new/copy/mutableCopy开头的函数调用,编译器会自动插入release语句,否则返回的对象会加到autorelease池子中
- 函数调用会根据
objc_autoreleaseReturnValue
和objc_retainAutoreleasedReturnValue
进行TLS优化判断,避免autorelease过多
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{}
保证及时释放
回到微信读书排版引擎的代码实现本身,逻辑上讲并没有什么问题,可以简化为:
那这里是不是加上@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
的初始化过程:
NSConcreteAttributedString
初始化1234567891011121314int -[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;}NSDictionary -> NSAttributeDictionary
过程可以简化为:1234567891011121314151617181920+ (NSAttributeDictionary*)newWithDictionary:(NSDictionary*)dict {if (!dict) {return _emptyAttributeDictionary;}os_unfair_lock_lock_with_options(_attributeDictionaryLock, 0x10000);NSAttributeDictionary* rax = [_attributeDictionaryTable getItem:dict];//NSConcreteHashTableif (!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_lock
,NSAttributeDictionary
存放在NSConcreteHashTable
,看逻辑应该是为了在初始化NSAttributedString
的attributes的时候进行对象复用。
NSConcreteHashTable
如何查找NSDictionary
123456- (void)getItem:(NSDictionary*)dict {if (dict != 0x0) {rax = _hashProbe(dict);//关键函数}return rax;}hashProbe
函数简化版:12345678910111213id 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;}readARCWeakAtWithSentinel
从HashTable里取出NSAttributeDictionay12345678910id 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相应的函数来验证:
排版一个有2w个<p>
标签的HTML章节,objc_loadWeak
会加载6747w个NSAttributeDictionary
对象
解决方案
到此,NSAttributedString
内存暴涨的原因算是找到了,解决方案其实很简单:就是如何避免hash冲突。这里有两个前提:
- 直接去改
NSDictionary
的hash
函数有点困难 - 加
@autoreleasepool{}
影响效率
其实从场景上分析,HTML的<p>
标签是有限个(实际场景中超1w个的都是极少数),而且<p>
对应的attributes
里只保存的对象的pointer,内存占用大小也只是NSAttributeDictionay
本身,attributes
复用的意义并不是很大。这里直接在attributes
里加上一个 随机因子,减少 hashProbe 的命中数,NSConcreteHashTable
没命中会直接跳过objc_loadWeak
的调用,也就不会产生autorelease
对象了。
优化后解析同一个HTML章节,objc_loadWeak只加载了 27w个NSAttributeDictionary
对象
当然这种解决方案并不是最好的,只是衡量投入产出比后折中的方案,如果有更好的解决方案,欢迎私信交流!
最后打个广告:微信读书iOS/Android客户端大量招人,欢迎大家加入
简历直接发:jasenhuang@rdgz.org