看完本文章你将了解到
- DisplayLink和timer的使用和原理
 - 内存分配和内存管理
 - 自动释放池原理
 - weak指针原理和释放时机
 - 引用计数原理
 
/
DisplayLink
CADisplayLink是将任务添加到runloop中,loop每次循环便会调用target的selector,使用这个也能监测卡顿问题。首先介绍下API
1  | + (CADisplayLink *)displayLinkWithTarget:(id)target selector:(SEL)sel;  | 
我们在一个需要push的VC中运行来观察声明周期
1  | @property (nonatomic,strong) CADisplayLink *link;  | 
初始化之后,对fps使用了简单版本的读写锁,可以看到fps基本稳定在60左右,点击按钮返回之后,link和VC并没有正常销毁。我们分析一下,VC(self)->link->target(self),导致了死循环,释放的时候,无法释放self和link,那么我们改动一下link->target(self)中的强引用,改成弱引用,代码改成下面的
1  | @interface FYTimerTarget : NSObject  | 
FYTimerTarget对target进行了弱引用,self对FYTimerTarget进行强引用,在销毁了的时候,先释放self,然后检查self的FYTimerTarget,FYTimerTarget只有一个参数weak属性,可以直接释放,释放完FYTimerTarget,然后释放self(VC),最终可以正常。
NSTimer
使用NSTimer的时候,timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo会对aTarget进行强引用,所以我们对这个aTarget进行一个简单的封装
1  | @interface FYProxy : NSProxy  | 
FYProxy是继承NSProxy,而NSProxy不是继承NSObject的,而是另外一种基类,不会走objc_msgSend()的三大步骤,当找不到函数的时候直接执行- (void)forwardInvocation:(NSInvocation *)invocation,和- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel直接进入消息转发阶段。或者将继承关系改成FYTimerTarget : NSObject,这样子target找不到的函数还是会走消息转发的三大步骤,我们再FYTimerTarget添加消息动态解析1
2
3-(id)forwardingTargetForSelector:(SEL)aSelector{
	return self.target;
}
这样子target的aSelector转发给了self.target处理,成功弱引用了self和函数的转发处理。
1  | FYTimerTarget *obj =[FYTimerTarget new];  | 
或者使用timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block,然后外部使用__weak self调用函数,也不会产生循环引用。
使用block的情况,释放正常。
1  | self.timer=[NSTimer timerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {  | 
由于link和timer是添加到runloop中使用的,每次一个循环则访问timer或者link,然后执行对应的函数,在时间上有相对少许误差的,每此循环,要刷新UI(在主线程),要执行其他函数,要处理系统端口事件,要处理其他的计算。。。总的来说,误差还是有的。
GCD中timer
GCD中的dispatch_source_t的定时器是基于内核的,时间误差相对较少。
1  | //timer 需要强引用 或者设置成全局变量  | 
或者使用函数dispatch_source_set_event_handler_f(timer, function_t);
1  | dispatch_source_set_event_handler_f(timer, function_t);  | 
业务经常使用定时器的话,还是封装一个简单的功能比较好,封装首先从需求开始分析,我们使用定时器常用的参数都哪些?需要哪些功能?
首先需要开始的时间,然后执行的频率,执行的任务(函数或block),是否重复执行,这些都是需要的。
先定义一个函数
1  | + (NSString *)exeTask:(dispatch_block_t)block  | 
然后将刚才写的拿过来,增加了一些判断。有任务的时候才会执行,否则直接返回nil,当循环的时候,需要间隔大于0,否则返回,同步或异步,就或者主队列或者异步队列,然后用生成的key,timer为value存储到全局变量中,在取消的时候直接用key取出timer取消,这里使用了信号量,限制单线程操作。在存储和取出(取消timer)的时候进行限制,提高其他代码执行的效率。
1  | + (NSString *)exeTask:(dispatch_block_t)block start:(NSTimeInterval)time interval:(NSTimeInterval)interval repeat:(BOOL)repeat async:(BOOL)async{  | 
用的时候很简单
1  | key = [FYTimer exeTask:^{  | 
或者
1  | key = [FYTimer exeTask:self sel:@selector(test) start:0 interval:1 repeat:YES async:YES];  | 
取消执行的时候
1  | [FYTimer exeCancelTask:key];  | 
测试封装的定时器
1  | - (void)viewDidLoad {  | 
在点击VC的时候进行取消操作,timer停止。
NSProxy实战
NSProxy其实是除了NSObject的另外一个基类,方法比较少,当找不到方法的时候执行消息转发阶段(因为没有父类),调用函数的流程更短,性能则更好。
问题:ret1和ret2分别是多少?
1  | ViewController *vc1 =[[ViewController alloc]init];  | 
我们来分析一下,-(bool)isKindOfClass:(cls)对象函数是判断该对象是否的cls的子类或者该类的实例,这点不容置疑,那么ret1应该是0,ret2应该也是0
首先看FYProxy的实现,forwardInvocation和methodSignatureForSelector,在没有该函数的时候进行消息转发,转发对象是self.target,在该例子中isKindOfClass不存在与FYProxy,所以讲该函数转发给了VC,则BOOL ret1 = [pro1 isKindOfClass:ViewController.class];相当于BOOL ret1 = [ViewController.class isKindOfClass:ViewController.class];,所以答案是1
然后ret2是0,tar是继承于NSObject的,本身有-(bool)isKindOfClass:(cls)函数,所以答案是0。
答案是:ret1是1,ret2是0。
内存分配
内存分为保留段、数据段、堆(↓)、栈(↑)、内核区。
数据段包括
- 字符串常量:比如NSString * str = @”11”
 - 已初始化数据:已初始化的全局变量、静态变量等
 - 未初始化数据:未初始化的全局变量、静态变量等
 
栈:函数调用开销、比如局部变量,分配的内存空间地址越来越小。
堆:通过alloc、malloc、calloc等动态分配的空间,分配的空间地址越来越大。
验证:
1  | int a = 10;  | 
Tagged Pointer
从64bit开始,iOS引入Tagged Pointer技术,用于优化NSNumber、NSDate、NSString等小对象的存储,在没有使用之前,他们需要动态分配内存,维护计数,使用Tagged Pointer之后,NSNumber指针里面的数据变成了Tag+Data,也就是将数值直接存储在了指针中,只有当指针不够存储数据时,才会动态分配内存的方式来存储数据,而且objc_msgSend()能够识别出Tagged Pointer,比如NSNumber的intValue方法,直接从指针提取数据,节省了以前的调用的开销。
在iOS中,最高位是1(第64bit),在Mac中,最低有效位是1。
在runtime源码中objc-internal.h 370行判断是否使用了优化技术
1  | static inline void * _Nonnull  | 
我们拿来这个可以判断对象是否使用了优化技术。
NSNumbe Tagged Pointer
我们使用几个NSNumber的大小数字来验证
1  | #if (TARGET_OS_OSX || TARGET_OS_IOSMAC) && __x86_64__ //mac开发  | 
可以看到n1 n2 n3是经过优化的,而n4是大数字,指针容不下该数值,不能优化。
NSString Tagged Pointer
看下面一道题,运行test1和test2会出现什么问题?
1  | - (void)test1{  | 
我们先不运行,先分析一下。
首先全局队列异步添加任务会出现多线程并发问题,在并发的时候进行写操作会出现资源竞争问题,另外一个小字符串会出现指针优化问题,小字符串和大字符串切换导致_name结构变化,多线程同时写入和读会导致访问坏内存问题,我们来运行一下
1  | Thread: EXC_BAD_ACCESS(code = 1)  | 
直接在子线程崩溃了,崩溃函数是objc_release。符合我们的猜想。
验证NSString Tagged Pointer
1  | - (void)test{  | 
可以看到NSString Tagged Pointer在小字符串的时候类是NSTaggedPointerString,经过优化的类,大字符串的类是__NSCFString,
copy
拷贝分为浅拷贝和深拷贝,浅拷贝只是引用计数+1,深拷贝是拷贝了一个对象,和之前的 互不影响, 引用计数互不影响。
拷贝目的:产生一个副本对象,跟源对象互不影响
 修改源对象,不会影响到副本对象
 修改副本对象,不会影响源对象
iOS提供了2中拷贝方法
- copy 拷贝出来不可变对象
 - mutableCopy 拷贝出来可变对象
 
1  | void test1(){  | 
可以看到str和str2地址一样,没有重新复制出来一份,mut1地址和str不一致,是深拷贝,重新拷贝了一份。
我们把字符串换成其他常用的数组
1  | void test2(){  | 
从上面可以总结看出来,不变数组拷贝出来不变数组,地址不改变,拷贝出来可变数组地址改变,可变数组拷贝出来不可变数组和可变数组,地址会改变。
我们再换成其他的常用的字典
1  | void test4(){  | 
从上面可以总结看出来,不变字典拷贝出来不变字典,地址不改变,拷贝出来可变字典地址改变,可变字典拷贝出来不可变字典和可变字典,地址会改变。
由这几个看出来,总结出来下表
| 类型 | copy | mutableCopy | 
|---|---|---|
| NSString | 浅拷贝 | 深拷贝 | 
| NSMutableString | 浅拷贝 | 深拷贝 | 
| NSArray | 浅拷贝 | 深拷贝 | 
| NSMutableArray | 深拷贝 | 深拷贝 | 
| NSDictionary | 浅拷贝 | 深拷贝 | 
| NSMutableDictionary | 深拷贝 | 深拷贝 | 
自定义对象实现协议NSCoping
自定义的对象使用copy呢?系统的已经实现了,我们自定义的需要自己去实现,自定义的类继承NSCopying
1  | @protocol NSCopying  | 
看到NSCopying和NSMutableCopying这两个协议,对于自定义的可变对象,其实没什么意义,本来自定义的对象的属性,基本都是可变的,所以只需要实现NSCopying协议就好了。
1  | @interface FYPerson : NSObject  | 
自己实现了NSCoping协议完成了对对象的深拷贝,成功将对象的属性复制过去了,当属性多了怎么办?我们可以利用runtime实现一个一劳永逸的方案。
然后将copyWithZone利用runtime遍历所有的成员变量,将所有的变量都赋值,当变量多的时候,这里也不用修改。
1  | @implementation NSObject (add)  | 
根据启动顺序,类别的方法在类的方法加载后边,类别中的方法会覆盖类的方法,所以
在基类NSObject在类别中重写了-(instancetype)copyWithZone:(NSZone *)zone方法,子类就不用重写了。达成了一劳永逸的方案。
引用计数原理
摘自百度百科
引用计数是计算机编程语言中的一种内存管理技术,是指将资源(可以是对象、内存或磁盘空间等等)的被引用次数保存起来,当被引用次数变为零时就将其释放的过程。使用引用计数技术可以实现自动资源管理的目的。同时引用计数还可以指使用引用计数技术回收未使用资源的垃圾回收算法
在iOS中,使用引用计数来管理OC对象内存,一个新创建的OC对象的引用计数默认是1,当引用计数减为0,OC对象就会销毁,释放其他内存空间,调用retain会让OC对象的引用计数+1,调用release会让OC对象的引用计数-1。
当调用alloc、new、copy、mutableCopy方法返回一个对象,在不需要这个对象时,要调用release或者autorelease来释放它,想拥有某个对象,就让他的引用计数+1,不再拥有某个对象,就让他引用计数-1.
在MRC中我们经常都是这样子使用的
1  | FYPerson *p=[[FYPerson alloc]init];  | 
但是在ARC中是系统帮我们做了自动引用计数,不用开发者做很多繁琐的事情了,我们就探究下引用计数是怎么实现的。
引用计数存储在isa指针中的extra_rc,存储值大于这个范围的时候,则bits.has_sidetable_rc=1然后将剩余的RetainCount存储到全局的table,key是self对应的值。
Retain的runtime源码查找函数路径objc_object::retain()->objc_object::rootRetain()->objc_object::rootRetain(bool, bool)
1  | //大概率x==1 提高读取指令的效率  | 
引用计数+1,判断了需要是指针没有优化和isa有没有使用的联合体技术,然后将判断是否溢出,溢出的话,将extra_rc的值复制到side table中,设置参数isa->has_sidetable_rc=true。
引用计数-1,在runtime源码中查找路径是objc_object::release()->objc_object::rootRelease()->objc_object::rootRelease(bool performDealloc, bool handleUnderflow),我们进入到函数内部
1  | ALWAYS_INLINE bool objc_object::rootRelease(bool performDealloc, bool handleUnderflow)  | 
看了上边了解到引用计数分两部分,extra_rc和side table,探究一下rootRetainCount()的实现
1  | inline uintptr_t objc_object::rootRetainCount()  | 
当是存储小数据的时候,指针优化,则直接返回self,大数据的话,则table加锁,class优化的之后使用联合体存储更多的数据,class没有优化则直接去sizedable读取数据。
优化了则在sidetable_getExtraRC_nolock()读取数据
1  | //使用联合体  | 
没有优化的是直接读取
1  | //未使用联合体的情况,  | 
weak指针原理
当一个对象要销毁的时候会调用dealloc,调用轨迹是dealloc->_objc_rootDealloc->object_dispose->objc_destructInstance->free
我们进入到objc_destructInstance内部
1  | void *objc_destructInstance(id obj)  | 
销毁了c++析构函数和关联函数最后进入到clearDeallocating,我们进入到函数内部
1  | //正在清除side table 和weakly referenced  | 
最终调用了sidetable_clearDeallocating和clearDeallocating_slow实现销毁weak和引用计数side table。
1  | NEVER_INLINE void  | 
其实weak修饰的对象会存储在全局的SideTable,当对象销毁的时候会在SideTable进行查找,时候有weak对象,有的话则进行销毁。
Autoreleasepool 原理
Autoreleasepool中文名自动释放池,里边装着一些变量,当池子不需要(销毁)的时候,release里边的对象(引用计数-1)。
我们将下边的代码转化成c++
1  | @autoreleasepool {  | 
使用xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc -f  main.m
转成c++
1  | /* @autoreleasepool */ {  | 
__AtAutoreleasePool是一个结构体
1  | struct __AtAutoreleasePool {  | 
然后将上边的代码和c++整合到一起就是这样子
1  | {  | 
在进入大括号生成一个释放池,离开大括号则释放释放池,我们再看一下释放函数是怎么工作的,在runtime源码中NSObject.mm 1848 行
1  | void objc_autoreleasePoolPop(void *ctxt)  | 
pop实现了AutoreleasePoolPage中的对象的释放,想了解怎么释放的可以研究下源码runtime NSObject.mm 1063行。
其实AutoreleasePool是AutoreleasePoolPage来管理的,AutoreleasePoolpage结构如下
1  | class AutoreleasePoolPage {  | 
AutoreleasePoolPage在初始化在autoreleaseNewPage申请了4096字节除了自己变量的空间,AutoreleasePoolPage是一个C++实现的类
- 内部使用
id *next指向了栈顶最新add进来的autorelease对象的下一个位置 - 一个
AutoreleasePoolPage的空间被占满时,会新建一个AutoreleasePoolPage对象,连接链表,后来的autorelease对象在新的page加入 AutoreleasePoolPage每个对象会开辟4096字节内存(也就是虚拟内存一页的大小),除了上面的实例变量所占空间,剩下的空间全部用来储存autorelease对象的地址AutoreleasePool是按线程一一对应的(结构中的thread指针指向当前线程)AutoreleasePool并没有单独的结构,而是由若干个AutoreleasePoolPage以双向链表的形式组合而成(分别对应结构中的parent指针和child指针)
其他的都是自动释放池的其他对象的指针,我们使用_objc_autoreleasePoolPrint()可以查看释放池的存储内容
1  | extern void _objc_autoreleasePoolPrint(void);  | 
可以看到存储了3 releases pending一个对象,而且大小都8字节。再看一个复杂的,自动释放池嵌套自动释放池
1  | int main(int argc, const char * argv[]) {  | 
看到了2个POOL和四个FYPerson对象,一共是6个对象,当出了释放池会执行release。
当无优化的指针调用autorelease其实是调用了AutoreleasePoolPage::autorelease((id)this)->autoreleaseFast(obj)
1  | static inline id *autoreleaseFast(id obj)  | 
在MRC中autorealease修饰的是的对象在没有外部添加到自动释放池的时候,在runloop循环的时候会销毁
1  | 
  | 
activities = 0xa0转化成二进制 0b101 0000
系统监听了mainRunloop 的 kCFRunLoopBeforeWaiting 和kCFRunLoopExit两种状态来更新autorelease的数据
回调函数是 _wrapRunLoopWithAutoreleasePoolHandler。
1  | void test(){  | 
p对象在某次循环中push,在循环到kCFRunLoopBeforeWaiting进行一次pop,则上次循环的autolease对象没有其他对象retain的进行释放。并不是出了test()立马释放。
在ARC中则执行完毕test()会马上释放。
总结
- 当重复创建对象或者代码段不容易管理生命周期使用自动释放池是不错的选择。
 - 存在在全局的
SideTable中weak修饰的对象会在dealloc函数执行过程中检测或销毁该对象。 - 可变对象拷贝一定会生成已新对象,不可变对象拷贝成不可变对象则是引用计数+1。
 - 优化的指向对象的指针,不用走
objc_msgSend()的消息流程从而提高性能。 CADisplayLink和Timer本质是加到loop循环当中,依附于循环,没有runloop,则不能正确执行,使用runloop需要注意循环引用和runloop所在的线程的释放问题。
参考资料
最怕一生碌碌无为,还安慰自己平凡可贵。
广告时间
