内存-②循环引用Timer

内存-②循环引用Timer

目录

1
2
3
4
5
6
7
8
9
1、NSTimer和NSRunLoop的关系?
2、NSTimer使用细节
3、NSTimer的创建
4、NSTimer的循环引用
5、NSTimer使用的优化
>
6、NSTimer的销毁问题
(1)、子线程中NSTimer的创建和销毁问题
>

九、NSTimer

< 返回目录

1、NSTimer的创建

NSTimer的创建通常有两种方式,尽管都是类方法,一种是timerWithXXX,另一种scheduedTimerWithXXX。

二者最大的区别就是后者除了创建一个定时器外会自动以NSDefaultRunLoopModeMode添加到当前线程RunLoop中,不添加到RunLoop中的NSTimer是无法正常工作的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@interface ViewController1 ()
@property (nonatomic, weak) NSTimer *timer1;
@property (nonatomic, weak) NSTimer *timer2;
@end


@implementation ViewController1

- (void)viewDidLoad {
[super viewDidLoad];

self.timer1 = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timeInterval1:) userInfo:nil repeats:YES];

/*
错误做法:
self.timer2 = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timeInterval2:) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.timer2 forMode:NSDefaultRunLoopMode];
*/

//正确做法:
//特别注意:timer2创建时并没直接赋值给timer2。
//原因是timer2是weak属性,如果直接赋值给timer2会被立即释放。
//因为timerWithXXX方法创建的NSTimer默认并没有加入RunLoop,只有后面加入RunLoop以后才可以将引用指向timer2。从而导致执行到addTimer:forMode的时候,访问了野指针而发生EXC_BAD_ACCESS,崩溃。
NSTimer *tempTimer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timeInterval2:) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:tempTimer forMode:NSDefaultRunLoopMode];
self.timer2 = tempTimer;
。。。。。
}


- (void)timeInterval1:(id)object {
NSLog(@"111");
}

- (void)timeInterval2:(id)object {
NSLog(@"222");
}

@end

2、NSTimer的修饰符

runloop强制持有timer(runloop->timer),timer会强制持有其target,未处理的情况下一般都是self(timer->self),导致self无法释放。
虽然设置timer为weak属性时候,self未强制持有timer,没构成循环应用,但还是导致了self无法释放的问题,dealloc无法执行。

2、NSTimer的循环引用

关于循环引用,我们先看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@interface ViewController1 ()
@property (nonatomic, strong) NSTimer *timer;//注意这里的属性不是为weak,从而很容易引起循环引用
@end


@implementation ViewController1

- (void)viewDidLoad {
// 代码标记1 (产生timer与self之前的强引用,如下图中的L3强引用线)
NSTimer *timer = [[NSTimer alloc] initWithFireDate:[NSDate date] interval:1 target:self selector:@selector(timerFire) userInfo:nil repeats:YES];
// 代码标记2 (产生RunLoop与timer之间的强引用,如下图中的L4强引用线)
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
// 代码标记3 (产生self与timer之间的强引用,,如下图中的L2强引用线)
self.timer = timer;
}

- (void)timerFire {
NSLog(@"timer fire");
}

@end

假设代码中的视图控制器由UINavigationController管理,由于self.timer是strong类型,则强引用可以表示如下:

NSTimer循环引用例子1

由于,很容易看出来,由于timer本身在创建时候已经与self发生了强引用(不管target使用weak还是strong修饰,timer都会对target强引用。)。而赋值时候又由于timer是被设为strong的,而导致self与timer之间也发生了强引用,最终这两个强引用,就形成了循环引用。

所以,

①、首先,我们先解决循环引用,为了解决timer的循环应用问题,我们上面的timer属性应该使用weak。设置成weak后,L2就消失了。

②、但是即使使用了弱引用,上面的代码中ViewController1在pop退出的时候也无法正常释放,原因是在创建NSTimer时指定了target为self,这样一来造成了timer对ViewController1有一个强引用。从而导致,timer没释放的时候,viewController也是不会被释放的。为了让timer能够释放,我们就需要调用NSTimer的invalidate方法(注意:无论是重复执行的定时器还是一次性的定时器只要调用invalidate方法则会变得无效,只是一次性的定时器执行完操作后会自动调用invalidate方法)。所以,假设在viewController pop回去前,我们通过一个按钮来让timer调用invalidate方法,那么viewController在pop回去时候,就能够被释放了。

问题是我们一般不会有这个按钮操作,那么这时候让timer调用invalidate方法的操作,应该写在哪里呢。这时候,你可能会想到那就写在viewDidDisappear中呗。但是一旦在viewWillDisappear中写[timer invalidate]; timer = nil;那么你也得把timer的创建放在viewWillAppear中。因为我们可能执行的是push跳到下一页,再返回来的操作。然而显然将timer的创建放在viewWillAppear中这样的方式,显然会是导致当timer可能需要频繁添加。所以,我们放弃此方法,还是把timer的创建放在viewDidLoad中,然后考虑其他方法。

所以,我们最后为了让ViewController1在pop退出的时候不会因为timer的强引用,而导致无法正常释放。我们选择转移timer中的target。这样就能确保,viewController在pop退出的时候能够正常释放,从而调用viewController的dealloc方法。

附:转移timer中的target的方法通常有两种:

一种是将target分离出来独立成一个对象(在这个对象中创建NSTimer并将对象本身作为NSTimer的target),控制器通过这个对象间接使用NSTimer;

另一种方式的思路仍然是转移target,只是可以直接增加NSTimer扩展(分类),让NSTimer自身做为target,同时可以将操作selector封装到block中。

NSTimer转移target方法二
图中参考NSTimer+Block

后者相对优雅,也是目前使用较多的方案。显然Apple也认识到了这个问题,如果你可以确保代码只在iOS 10下运行就可以使用iOS 10新增的系统级block方案(上面的代码中已经贴出这种方法)。

③、如果不做②中的转移timer的target的话,那么viewController就会无法释放,造成内存泄露。
但是,我们发现通过转移timer的target后,虽然解决了UIViewController1因为被timer强引用而导致的在pop回来的时候无法释放的问题。我们的计时器,却在UIViewController1 pop退出被释放后,两个定时器仍然在运行,也就是它还没被释放。所以,我们还需要解决timer的释放。那怎么让timer释放呢?答:如果要让timer释放掉的话,需要调用NSTimer的invalidate方法(注意:无论是重复执行的定时器还是一次性的定时器只要调用invalidate方法则会变得无效,只是一次性的定时器执行完操作后会自动调用invalidate方法)。
invalidate方法有2个功能:一是将timer从runloop中移除,那么图中的L4就消失,二是timer本身也会释放它持有资源,比如它的target、userinfo、block等,因为这里的target是self,所以强引用L3也就消失。
所以,最终当viewController在pop退出的时候,其正常释放并调用了dealloc放。我们只需要在dealloc方法中,添加上timer调用invalidate的方法,即可以解决viewController被释放了,但timer没被释放的问题。

1
2
3
4
- (void)dealloc {
[self.timer invalidate];
NSLog(@"ViewController1 dealloc...");
}

所以,一个完整的timer过程,代码如下:

1

3、NSTimer和NSRunLoop的关系?

只要出现NSTimer必须要有NSRunLoop,NSTimer必须依赖NSRunLoop才能执行 。NSTimer其实也是一种资源,如果看过多线程编程指引文档的话,我们会发现所有的source如果要起作用,就得加到runloop中去。同理timer这种资源要想起作用,那肯定也需要加到runloop中才会生效喽。如果一个runloop里面不包含任何资源的话,运行该runloop时会立马退出。

NSRunLoop与timer有关方法为:

1
- (void)addTimer:(NSTimer *)timer forMode:(NSString *)mode; //在run loop上注册timer

注意事项:

我们通常在主线程中使用NSTimer,有个实际遇到的问题需要注意。当滑动界面时,系统为了更好地处理UI事件和滚动显示,主线程runloop会暂时停止处理一些其它事件,这时主线程中运行的NSTimer就会被暂停。解决办法就是改变NSTimer运行的mode(mode可以看成事件类型),不使用缺省的NSDefaultRunLoopMode,而是改用NSRunLoopCommonModes,这样主线程就会继续处理NSTimer事件了。具体代码如下:

1
2
NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timer:) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

4、NSTimer使用细节:

NSTimer上的定时任务是在创建NSTimer的线程上执行的。NSTimer的销毁和创建必须在同一个线程上操作
NSTimer要被添加到当前线程的 Runloop 里面且 Runloop 被启动,定时任务(selector或者invocation)才会触发。

以下内容摘自:NSTimer定时器进阶——详细介绍,循环引用分析与解决

  1. 它需要被添加到runloop,否则不会运行,当然添加的runloop不存在也不会运行;
  2. 还要指定添加到的runloop的哪个模式,而且还可以指定添加到runloop的多个模式,模式不对也是不会运行的
  3. runloop会对timer有强引用,timer会对目标对象进行强引用(是否隐约的感觉到坑了。。。)
  4. timer的执行时间并不准确,系统繁忙的话,还会被跳过去。(具体的两种不准时,请查看原文)
  5. invalidate调用后,timer停止运行后,就一定能从runloop中消除吗,资源????invalidate方法的调用必须在timer添加到的runloop所在的线程,如果不在的话:由于调用invalidate 方法后,timer本身会释放掉它自己持有的资源比如target、userinfo、block,图中的L3会消失。但是runloop不会释放timer,即图中的L4不会消失,假设,self被pop了–>L1无效–>self引用计数为0,self释放–>L2也消失。此时就剩runloop、timer、L4,timer也就永远不会释放了,造成内存泄露。

NSTimer的强引用问题举例:

NSTimer的强引用问题举例

5、NSTimer使用的优化

问:为什么要在非主线程创建NSTimer?

  • 将 timer 添加到主线程的Runloop里面本身会增加线程负荷;
  • 如果主线程因为某些原因阻塞卡顿了,timer 定时任务触发的时间精度肯定也会受到影响;
  • 有些定时任务不是UI相关的,本来就没必要在主线程执行,给主线程增加不必要的负担。当然也可以在定时任务执行时,手动将任务指派到非主线程上,但这也是有额外开销的。

6、NSTimer的销毁问题

前面我们已经简单讲过要让NSTimer销毁释放的时候,只能通过调用其invalidate来达到销毁目的。关于invalidate的第一个作用以及它在哪个线程调用的问题,我的理解如下:

invalidate方法的第一个作用将timer从runloop中移除。这里的runLoop指的应该是当前的runLoop,而不是timer被添加到的runLoop,这个纯属个人理解,未验证,因为如果是其添加的runLoop的话,那子线程timer的销毁,就不会有人说还要和timer所在的线程一致了。所以,这里的个人理解有如下:

为了销毁timer和去除runloop与timer之间的强引用,我们调用了timer的invalidate方法。

1、对于invalidate方法的调用是写在必须在timer所添加到的runloop所在的线程(如主线程)的时候,invalidate方法会将timer从runloop中移除,并且释放它持有资源。即上面的L4和L3都消失。

2、对于invalidate方法的调用不是写在必须在timer所添加到的runloop所在的线程的时候(如子线程中添加timer,在主线程中调用该timer的invalidate),虽然timer本身会释放掉它自己持有的资源比如target、userinfo、block,图中的L3会消失。但是runloop不会释放timer,即图中的L4不会消失,假设,self被pop了–>L1无效–>self引用计数为0,self释放–>L2也消失。此时就剩runloop、timer、L4,timer也就永远不会释放了,造成内存泄露。

如果invalidate方法的调用的位置不更改的话,这时候要让L4消失的方法,

方法①手动销毁runloop。比如

1
2
3
4
    //[[NSRunLoop currentRunLoop] run]; //将原本的方法注释掉
NSDate *date = [NSDate dateWithTimeIntervalSinceNow:5.f];
[[NSRunLoop currentRunLoop] runUntilDate:date]; //让runloop在5s后销毁
>

这种方式,只适用于销毁时间确定的情况。那如果销毁时间不确定怎么办?

方法②:因为当某个线程销毁时,其runloop也随之销毁,所以方法二即为通过销毁timer所在的线程,来达到销毁runloop的目的。如果是在主线程,线程一直存在,我们没法让主线程销毁。

所以,下面我们讨论的是在子线程中添加timer的时候,如果该timer的invalidate方法的调用位置不是写在对应子线程,而是写在主线程的时候,我们该怎么通过销毁子线程,来销毁runLoop。从而接触该runloop对timerd的强引用?

6.1子线程中NSTimer的创建和销毁问题

我们按上诉2中②的讨论描述的:子线程中添加timer的时候,如果该timer的invalidate方法的调用位置不是写在对应子线程,而是写在主线程的时候,写出的对应代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@property (nonatomic, weak) NSTimer *threadTimer; //子线程timer


- (void)viewDidLoad {
[super viewDidLoad];

// 开辟子线程
NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(newThread) object:nil];
[thread start];
}

- (void)newThread {
@autoreleasepool {
NSThread *currentThread = [NSThread currentThread];
[currentThread setName:@"这是子线程"];

self.threadTimer = [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(threadTimerAction) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] run];
}
}

- (void)threadTimerAction {
static NSInteger counter = 0;

NSString *isMainThreadDescription = [NSThread isMainThread] ? @"YES" : @"NO";
NSLog(@"当前方法执行的线程:%@, 它是否是主线程:%@, counter = %@", [NSThread currentThread], isMainThreadDescription, @(counter++));
}

这时候,我们该怎么通过销毁子线程,来销毁runLoop。从而接触该runloop对timerd的强引用?即怎么销毁线程?

乍看当在子线程开启runloop后,timer会一直在子线程中运行,所以子线程不会销毁,runloop也就无法停止,runloop也就没法销毁,runloop与timer之间的强引用则还是被保留着,这似乎又是个死循环。但实际上,由于上述代码runloop的mode item只有Timer,所以只要销毁timer,runloop就会退出。所以,上述的代码是没问题的,不存在内存泄露问题。

附:NSTimer上的定时任务是在创建NSTimer的线程上执行的。

附:以上NSTimer的内容,有空的话还可参考NSTimer,NSRunLoop,autoreleasepool,多线程的爱恨情仇,它那边讲的,和这边自己理解的基本是一样的。只是对于有些点的介绍详细不一定一样而已。

其他有空可看iOS 中的 NSTimer

常见笔试/面试题

< 返回目录

END

< 返回目录