CADisplayLink、NSTimer 使用注意
CADisplayLink
和 NSTimer
会对 target 产生强引用,如果 target 又对他们产生强引用,那么会引发循环引用。
CADisplayLink 也是一个定时器,它是必须显示的添加到 RunLoop 当中才能进行,它的调用频率是和屏幕的刷帧频率(60fps)理论上是一致的,也就是 1 秒会调用 60 次。但在复杂的 UI 层级中会有误差。CADisPlayLink 的基本使用:
@interface ViewController ()
@property(strong, nonatomic) CADisplayLink* link;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.link = [CADisplayLink displayLinkWithTarget:self selector:@selector(test)];
[self.link addToRunLoop:[NSRunLoop mainRunLoop] forMode: NSDefaultRunLoopMode];
}
- (void)test {
NSLog(@"%s", __func__);
}
@end
运行打印结果:
打印的频率及其快。
在上述代码中,当前控制器对 CADisplayLink 对象有强引用,而 CADisplayLink 对象在初始化的时候设置的 target 也是对控制器的强引用,所以会造成循环引用。
并且在控制器销毁的时候,很多人会如下方式停止定时器:
- (void)dealloc {
[self.link invalidate];
}
如果当前控制器压在导航控制器(UINavigationController)的栈中的话,在从当前控制器返回的时候会发现,控制器不会销毁,定时器也不会调用 invalidate
方法。
NSTimer 也会存在这样的问题:
@interface ViewController ()
@property(strong, nonatomic) NSTimer* timer;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.timer = [NSTimer scheduledTimerWithTimeInterval:0.1 target:self selector:@selector(test) userInfo:nil repeats:YES];
}
- (void)test {
NSLog(@"%s", __func__);
}
- (void)dealloc {
[self.timer invalidate];
}
@end
处理定时器的循环引用
对于 NSTimer 产生的循环引用的问题,第一种解决办法就是,使用 scheduledTimerWithTimeInterval: repeats: block:
方法
__weak typeof(self) weakSelf = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
[weakSelf test];
}];
这种方法,躲避了传入的 target 造成的强引用问题。
另一种方法是,可以将 target 设置成另外一个对象,而该对象对控制器为弱引用,这样也就解决了循环引用的问题
image
图中就是打破了互相强持有的问题。
我们可以新建一个代理 Proxy 类:
.h
@interface Proxy : NSObject
@property(nonatomic, weak) id target;
+(instancetype)proxyWithTarget:(id)target;
@end
.m
@implementation Proxy
+ (instancetype)proxyWithTarget:(id)target {
Proxy* proxy = [[Proxy alloc] init];
proxy.target = target;
return proxy;
}
- (id)forwardingTargetForSelector:(SEL)aSelector {
return self.target;
}
@end
则外部:
self.timer = [NSTimer scheduledTimerWithTimeInterval: .1 target:[Proxy proxyWithTarget:self] selector:@selector(test) userInfo:nil repeats:YES];
就会解决问题。
Q. Proxy 为什么要实现 forwardingTargetForSelector:
方法?
A. 我们看到 Timer 的 target 是 Proxy 对象,也就是最终 test
方法是由 Proxy 对象来执行,但是显然 Proxy 中是没有 test
方法的,那么既然没有方法,我们自然而然的就想到了三个拯救程序崩溃的函数:消息发送、动态解析、消息转发,在这里消息转发即可,在该函数中返回有 test 方法的对象即可。
CADisplayLink 解决办法同理。
Foundation 的框架中,早就有了 NSProxy,它的存在,就是为了解决上述的代理问题的,详见官方文档有关 NSProxy 的介绍。而且,NSProxy 这个类很特殊,它自身和 NSObject 一样都是基类,并非继承自 NSObject 也没有 init
方法。
但是与自身实现的 Proxy 不同的是,NSProxy 解决消息处理的方法为通过
forwardInvocation: 和 methodSignatureForSelector:
来处理消息。
若 Proxy 继承自 NSProxy 来实现则:
@implementation Proxy
+ (instancetype)proxyWithTarget:(id)target {
Proxy* proxy = [Proxy alloc]; // 没有 init 方法
proxy.target = target;
return proxy;
}
// 返回方法签名
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
return [self.target methodSignatureForSelector:sel];
}
// 直接调用
- (void)forwardInvocation:(NSInvocation *)invocation {
[invocation invokeWithTarget:self.target];
}
@end
定时器的研究
CADisplayLink 和 NSTimer 其实都是基于 RunLoop 实现的,所以都会产生这样一个问题:定时器可能并不准时,如果 RunLoop 的任务太过繁重,或者模式的切换都能导致定时器的不准时。解决这样的问题除了将模式设置为 NSRunLoopCommonModes
之外,还有一个就是使用 GCD 定时器来解决这个问题:
// 创建队列
dispatch_queue_t queue = dispatch_get_main_queue();
// 创建计时器
self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
// 设置时间
// 第二个参数为:多久以后开始,DISPATCH_TIME_NOW 表示立即开始
// 第三个参数为:间隔,第四个参数为误差:一般传入 0
NSTimeInterval start = 2.0;
NSTimeInterval interval = 1.0; // 间隔
dispatch_source_set_timer(self.timer, dispatch_time(DISPATCH_TIME_NOW, start * NSEC_PER_SEC), interval * NSEC_PER_SEC, 0);
// 设置回调
dispatch_source_set_event_handler(self.timer, ^{
NSLog(@"========");
});
dispatch_resume(self.timer);
// 停止定时器
dispatch_source_cancel(self.timer);
目标秒数 * NSEC_PER_SEC
是因为,dispatch_source_set_timer
中接受的时间单位是纳秒,所以要完成秒->的转换,故要乘以 NSEC_PER_SEC。
timer 一定要被强引用才能生效。
GCD 的定时器不依赖于 RunLoop,是和内核直接挂钩的,所以准确性很高,并且若传入的队列不是主队列,回调中的内容会在子线程中执行。
iOS 程序的内存布局
iOS 中的内存布局是这样的:
image
代码段:是编译之后的代码;
数据段:字符串常量如 NSString* str = @"0305"
,已初始化的全局变量、静态数据等:int a = 5,未初始化全局变量、静态数据:int b。
栈:函数调用开销,如局部变量。分配内存是从高到低。
堆:通过 alloc
、malloc
、calloc
等动态分配的空间。分配内存是从低到高。
Tagged Pointer
从 64bit 开始,iOS 引入了 Tagged Pointer 技术,用于优化 NSNumber、NSDate、NSString 等小对象的存储。
如 NSNumber* intNumber = @10; 明明存储的是一个整形 4 个字节的数字 10,却需要至少 16 个字节(一个 Objective-C 对象至少是 16 个字节:isa 8 个字节+其他)的空间来存储,很浪费性能。
在没有使用 Tagged Pointer 之前,NSNumber 等对象需要动态分配内存、维护引用计数等。NSNumber 指针存储的是堆中 NSNumber 对象的地址值。
使用了 Tagged Pointer 之后,NSNumber 指针里面存储的数据变成了:Tag + Data,换而言之,就是将数据直接存在了指针当中。
当指针的最低有效位是 1 的时候,则该指针位 Tagged Pointer,判断是否为 Tagged Pointer 的源码逻辑:
# define _OBJC_TAG_MASK 1UL
static inline bool
_objc_isTaggedPointer(const void * _Nullable ptr)
{
return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;
}
当指针不够存储数据时,才会使用动态分配内存的方式来存储数据。
objc_msgSend
能识别 Tagged Pointer,如 NSNumber 的intValue
方法,可以直接从指针中提取数据,节省开销。
延伸
运行以下代码,可能会出现什么结果?
@interface ViewController ()
@property (copy, nonatomic) NSString* name;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
for (int i = 0; i < 1000; i ++) {
dispatch_async(queue, ^{
self.name = [NSString stringWithFormat: @"christinaaguilera"];
});
}
}
@end
答案是会引发程序的 Crash。
image.png
因为 self.name = xxx
本质是调用 name 的 setter
方法,而 setter 方法的大致实现是:
- (void)setName:(NSString *)name {
if (_name != name) {
[_name release]; // 释放旧值
_name = [name copy]; // 若 name 是由 strong 修饰,这里为 [name retain];
}
}
由于是并发队列在异步函数中执行任务,所以会导致某个时间点有两个线程同时执行 [_name release]
操作,会导致程序的崩溃。
解决办法一:
name 使用 atomic
修饰,相当于对 setter/getter 进行线程同步保护,可以避免上述坏情况发生。
解决办法二:
对 self.name = [NSString stringWithFormat: @"abc"]
进行线程同步保护,也就是在这句前面加锁,然后执行完该句解锁。
我们将上述例子的字符串由 @"christinaaguilera"
改为 @"boa"
再运行,发现什么?
程序没有崩溃!!
这是因为什么?
我们打印:
NSLog(@"%p %p", [NSString stringWithFormat:@"christinaaguilera"], [NSString stringWithFormat:@"boa"]);
得结果:
0x600003202d60 0xea348006ede94334
0x600003202d60
以 6xxxx 开头的一般都是堆内存地址,而那个 boa 是直接存储到指针当中,也就是 0xea348006ede94334
是一个 Tagged Pointer,所以不存在安全隐患。
Objective-C 对象的内存管理
在 iOS 中,使用引用计数来管理 Objective-C 对象的内存。
一个新创建的 Objective-C 对象引用计数默认是 1,当引用计数为 0 的时候,对象就会销毁,释放其所占用的内存空间。
MRC
在 MRC 时代,调用 retain
、copy
和 new
方法会让 Objective-C 对象的引用计数加 1,release
操作会让对象的引用计数减 1。
Xcode 关闭 ARC:Build Setting -> CLANG_ENABLE_OBJC_ARC -> NO。
retain 和 release
新建 Person 类继承自 NSObject,重写其 dealloc
方法:
- (void)dealloc {
[super dealloc];
NSLog(@"%s", __func__);
}
在外部运行:
Person* p = [[Person alloc] init];
NSLog(@"%lu", (unsigned long)[p retainCount]);
得打印结果为 1,dealloc 方法亦没有执行。这说明对象是没有销毁的,这种情况就是内存泄漏,我们需要执行 release 操作:
[p release];
释放该对象,也可以:
Person* p = [[[Person alloc] init] autorelease];
NSLog(@"%lu", (unsigned long)[p retainCount]);
这意味着在 @autoreleasepool { ... }
执行完毕,会给对象发送一条 release 消息释放该对象。
在属性是对象的时候,也要重写其方法对新值进行一次 retain
或者 copy
操作以免外部释放了对象导致该对象销毁,如新建 Player 类,该类有一个 play
方法,Person 有一个 Player 型的属性:
.h
@class Player;
NS_ASSUME_NONNULL_BEGIN
@interface Person : NSObject
{
Player* _player;
}
- (void)setPlayer:(Player*)player;
- (Player*)player;
@end
NS_ASSUME_NONNULL_END
.m
@implementation Person
- (void)setPlayer:(Player *)player {
if (_player != player) {
[_player release]; // 释放旧值
_player = [player retain]; // 引用新值
}
// 若两次传进来的对象一样,则不进行任何操作
}
- (Player *)player {
return _player;
}
- (void)dealloc {
[_player release];
_player = nil;
[super dealloc];
NSLog(@"%s", __func__);
}
@end
在 MRC 情况下,基本数据类型是不需要进行内存管理的。
若用 @property
声明属性,如:
@property(nonatomic, retain) Player* player;
则需要借助 @synthesize
来声明 _player
成员变量达到和上面一样的效果:
@synthesize player = _player; // 不写这句是调用不到 _player 的,当然名字也可以是 _player123 等等等
- (void)setPlayer:(Player *)player {
_player = player; // player 用 retain 修饰,则不需用判断和释放旧值的操作
}
并且 @synthesize
可以自动生成成员变量和属性的 setter/getter 实现。
在这里同样需要在 dealloc 中将对象置为 nil。
在 MRC 情况下:一般情况下类方法是不需要 release 的,通过 alloc
、new
和 copy
以及 mutableCopy
方法初始化的对象都需要 release 的。
copy
出现拷贝技术的目的就是产生一个副本,并且副本和原对象互相独立,修改两者之一另一个对象不会受任何影响。通常来修饰字符串 (NSString)、字典 (NSDictionary) 和数组 (NSArray)。
iOS 中提供了两种拷贝方法:不可变拷贝 copy 和不可变拷贝 mutableCopy。
copy 的结果是不可变副本,mutableCopy 的结果是可变副本:
NSString* str = [NSString stringWithFormat:@"valenti"];
NSString* str1 = [str copy]; // 不可变
NSMutableString* str2 = [str mutableCopy]; // 可变
[str2 appendString:@"love boa"];
NSMutableString* str = [NSMutableString stringWithFormat:@"valenti"];
NSString* str1 = [str copy]; // 不可变
NSMutableString* str2 = [str mutableCopy]; // 可变
[str2 appendString:@"love boa"];
我们在第一个例子中打印:
NSLog(@"%p %p %p", str, str1, str2);
发现:
0x1aed77c89a8821d1 0x1aed77c89a8821d1 0x100707470
说明 str 和 str1 指向的是同一个对象。而 str2 的地址为全新的,这说明 copy 操作拷贝的仅仅是指针,可以明白,因为 copy 得到的结果是不可变的,而原字符串本身也是不可变的,所以没必要产生一个全新的内容副本,仅仅复制指针即可达到效果。而 mutableCopy 不可以这样,可变拷贝的结果是可以执行可变的操作,这个操作不能影响原对象所以需要指针和内容都要重新拷贝一份。
仅仅拷贝指针称为浅拷贝,内容指针一起拷贝称为深拷贝。
同理数组:
NSArray* arr = [[NSArray alloc] initWithObjects:@"1", @"2", nil];
NSArray* arr1 = [arr copy];
NSMutableArray* arr2 = [arr mutableCopy];
NSLog(@"%p %p %p", arr, arr1, arr2);
结果为:0x10052ee80 0x10052ee80 0x10052f6e0
同理字典。
在 MRC 情况下,retain 修饰的属性生成的 setter 方法会自动释放旧值赋值新值,assign 修饰的属性生成的 setter 方法仅仅是赋值操作,那么 copy 修饰的属性会做什么?
答案是:
- (void)setXxx:(Xxxx *)xxx {
if (_xxx != xxx) {
[_xxx release];
_xxx= [xxx copy];
}
}
和 retain 不同的是最后是 copy 操作,产生一个不可变副本存储,所以即使是:
@property(nonatomic, copy) NSMutableString* name;
name 也是不能调用 appendString:
函数的。
autorelease
autorelease 相比 release 的好处是可以在适当的时机释放当前对象。并且在使用 release 操作的时候,对象所有的操作都需要放在 release 之前,否则会出现野指针错误,这样很容易出错。
那么 autorelease 释放时机是什么?
首先我们要知道自动释放池(autoreleasepool)这个东西,@autoreleasepool {...}
的底层结构是是一个结构体:
struct __AtAutoreleasePool {
__AtAutoreleasePool() { // 构造函数,在创建结构体的时候调用
atautoreleasepoolobj = objc_autoreleasePoolPush();
}
~__AtAutoreleasePool() { // 析构函数,在结构体销毁的时候调用
objc_autoreleasePoolPop(atautoreleasepoolobj);
}
void * atautoreleasepoolobj;
};
那么下段代码:
@autoreleasepool {
Person* p = [[Person alloc] init];
}
底层的形式就是:
atautoreleasepoolobj = objc_autoreleasePoolPush();
Person *p = [[[Person alloc] init] autorelease];
objc_autoreleasePoolPop(atautoreleasepoolobj); // @autoreleasepool 作用域结束时候会调用这个析构函数
我们在源码中可以找到这两个函数,其中 objc_autoreleasePoolPush()
逻辑为:
static inline void *push() {
id *dest;
if (DebugPoolAllocation) {
dest = autoreleaseNewPage(POOL_BOUNDARY); // 创建一个新的 AutoreleasePoolPage,POOL_BOUNDARY 为 nil
} else {
dest = autoreleaseFast(POOL_BOUNDARY);
}
assert(dest == EMPTY_POOL_PLACEHOLDER || *dest == POOL_BOUNDARY);
return dest;
}
objc_autoreleasePoolPop()
的逻辑为:
static inline void pop(void *token) // token 为 POOL_BOUNDARY
{
AutoreleasePoolPage *page;
id *stop;
if (token == (void*)EMPTY_POOL_PLACEHOLDER) {
if (hotPage()) {
pop(coldPage()->begin());
} else {
setHotPage(nil);
}
return;
}
page = pageForPointer(token);
stop = (id *)token;
if (*stop != POOL_BOUNDARY) {
// 跨页处理
if (stop == page->begin() && !page->parent) {
} else {
return badPop(token);
}
}
if (PrintPoolHiwat) printHiwat();
page->releaseUntil(stop); // 里面是循环,对每个对象,执行 release 操作
if (DebugPoolAllocation && page->empty()) {
AutoreleasePoolPage *parent = page->parent;
page->kill();
setHotPage(parent);
} else if (DebugMissingPools && page->empty() && !page->parent) {
page->kill();
setHotPage(nil);
}
else if (page->child) {
if (page->lessThanHalfFull()) {
page->child->kill();
}
else if (page->child->child) {
page->child->child->kill();
}
}
}
由源码得知自动释放池的主要底层数据结构有:__AtAutoreleasePool
和 AutoreleasePoolPage
,并且自动释放的对象都是由 AutoreleasePoolPage
来管理的。
AutoreleasePoolPage
的主要成员有:
class AutoreleasePoolPage
{
magic_t const magic;
id *next; // 指向下一个能存放 autorelease 对象地址的区域
pthread_t const thread;
AutoreleasePoolPage * const parent;
AutoreleasePoolPage *child;
uint32_t const depth;
uint32_t hiwat;
}
每个 AutoreleasePoolPage 对象占用 4096 个字节的内存,除了用来存放它内部的成员变量,剩下的空间用来存放 autorelease 对象的地址,也就是例子中 Person 对象的地址。
image0x1000
和 0x2000
相差 0x1000 刚好是 4069 个字节。0x1038
和 0x2000
之间存储的都是 autorelease 对象的地址。
所有的 AutoreleasePoolPage 对象通过双向链表的形式连接在一起。
AutoreleasePoolPage 中有一个 begin()
方法,调用之后返回的就是能够存储对象区域的起始地址。对应的,end()
方法会返回能够存储对象区域的结束地址,当不够存储的时候会创建新的一页。
child
和 parent
的指向关系为:
当执行 push 操作的时候,会传入 POOL_BOUNDARY
返回一个地址,如 0x1038。
那么,atautoreleasepoolobj 存储的地址就是 0x1308,接下来不管的有对象调用 autorelease,atautoreleasepoolobj 会将这些新对象的地址存在 0x1309、0x1310... 的位置。
当执行 pop 操作的时候,会传入 push 时传入 POOL_BOUNDARY
返回的那个地址,如 0x1308,拿到这个地址,会从最后一个压入 AutoreleasePoolPage 的对象开始逐个调用对象的 release 方法,直到遇到 POOL_BOUNDARY
所在的那个地址
autorelease 与 RunLoop
那么 autorelease 的对象什么时机会被调用 release?
在 @autoreleasepool{...}
代码域结束后,若对象的引用计数为 1,则会立即被调用 release 方法。那么在 viewDidLoad
等其他方法中呢?
这个答案和 RunLoop 有关,在主线程的 RunLoop 中注册了 2 个 Observer:kCFRunLoopEntry 和 KCFRunLoopBeforeWaiting|KCFRunLoopBeforeExit
监听 kCFRunLoopEntry 会调用 objc_autoreleasePoolPush()
方法。
监听 KCFRunLoopBeforeWaiting 会调用 objc_autoreleasePoolPop()
和 objc_autoreleasePoolPush()
方法。也就是在休眠之前会释放一次对象。
监听 KCFRunLoopBeforeExit 会调用 objc_autoreleasePoolPop()
方法。
所以一个对象的释放时机是由 RunLoop 管理的,当线程处于休眠的状态或者程序退出的时候会进行对象的 release 操作。
引用计数的存储
在 64bit 中,引用计数可以直接存储在优化过的 isa 指针当中,也可能存储在 SideTable 中:
struct SideTable {
spinlock_t slock;
RefcountMap refcnts;
weak_table_t weak_table;
}
足够存储的话会直接存储在 isa 中,否则存储在
SideTable
的散列表中。
- refcnts 是一个存放着对象引用计数的散列表;
- weak_table_t 是一个存放弱引用的散列表;
我们可在 objc 中看到获得引用计数的源码:
inline uintptr_t
objc_object::rootRetainCount()
{
// 如果是 Tagged Pointer 直接返回本身
if (isTaggedPointer()) return (uintptr_t)this;
sidetable_lock();
isa_t bits = LoadExclusive(&isa.bits); // isa.bits 就是 isa 本身
ClearExclusive(&isa.bits);
if (bits.nonpointer) { // 判断是否是优化过的指针
uintptr_t rc = 1 + bits.extra_rc;
if (bits.has_sidetable_rc) { // 说明引用计数不是存储在 isa 中,而是存在 SideTable 中
rc += sidetable_getExtraRC_nolock();
}
sidetable_unlock();
return rc;
}
sidetable_unlock();
return sidetable_retainCount();
}
sidetable_getExtraRC_nolock
函数为:
size_t
objc_object::sidetable_getExtraRC_nolock()
{
assert(isa.nonpointer);
SideTable& table = SideTables()[this];
RefcountMap::iterator it = table.refcnts.find(this); // 根据 key 查找,this 应该指该对象的地址
if (it == table.refcnts.end()) return 0;
else return it->second >> SIDE_TABLE_RC_SHIFT; // 进行一次位运算返回
}
release 和 retain 的操作在源码中 rootRelease
和 rootRetain
中,原理相同。
weak 指针
weak 指针表示一个弱引用,可以有效的解决循环引用的问题。在一个对象被销毁的时候,weak 指针指向的弱引用对象会自动置为 nil。
当一个对象要释放的时候,会自动调用 dealloc,接下来的调用轨迹是:
dealloc
_objc_rootDealloc
object_dispose
objc_destructInstance
、free
在源码中可看到 _obj_rootDealloc 的源码:
inline void
objc_object::rootDealloc()
{
if (isTaggedPointer()) return;
// 依次表示:是否优化、是否弱引用、是否关联对象、是否 C++ 析构函数、是否用 SideTable 存储引用计数
if (fastpath(isa.nonpointer &&
!isa.weakly_referenced &&
!isa.has_assoc &&
!isa.has_cxx_dtor &&
!isa.has_sidetable_rc))
{
assert(!sidetable_present());
free(this); // 若上不满足则直接释放
}
else {
object_dispose((id)this);
}
}
object_dispose
中调用了 objc_destructInstance
函数,objc_destructInstance 的逻辑为:
void *objc_destructInstance(id obj)
{
if (obj) {
bool cxx = obj->hasCxxDtor();
bool assoc = obj->hasAssociatedObjects();
if (cxx) object_cxxDestruct(obj); // 清除成员变量
if (assoc) _object_remove_assocations(obj); // 清除关联对象
obj->clearDeallocating(); // 将当前的弱指针对象置为 nil
}
return obj;
}
clearDeallocating
会调用 clearDeallocating_slow
函数,其逻辑为:
void
objc_object::clearDeallocating_slow()
{
assert(isa.nonpointer && (isa.weakly_referenced || isa.has_sidetable_rc));
SideTable& table = SideTables()[this]; // 取出散列表
table.lock();
if (isa.weakly_referenced) {
weak_clear_no_lock(&table.weak_table, (id)this); // 取出弱引用散列表,以对象地址为 key,取出弱引用对象进行处理
}
if (isa.has_sidetable_rc) {
table.refcnts.erase(this);
}
table.unlock();
}
在 weak_clear_no_lock()
函数中找到对应对象后,进行了一次 weak_entry_remove()
操作,也就是移除弱引用。
weak 操作是运行时的过程,它在运行时检测到对象被销毁,然后对弱指针引用的对象进行处理。这是 LLVM 编译器生成了相关内存管理的逻辑,然后配合运行时机制来管理若引用的结果。
网友评论