[关闭]
@MicroCai 2016-05-20T12:09:35.000000Z 字数 7359 阅读 29088

ARC 下内存泄露的那些点

Archives iOS


在网上搜了一下,发现这篇文章是第一篇、也是唯一 一篇总结 ARC 内存泄露的博客,哈哈好兴奋。

在 iOS 4.2 时,苹果推出了 ARC 的内存管理机制。这是一种编译期的内存管理方式,在编译时,编译器会判断 Cocoa 对象的使用状况,并适当的加上 retain 和 release,使得对象的内存被合理的管理。所以,ARC 和 MRC 在本质上是一样的,都是通过引用计数的内存管理方式。

然而 ARC 并不是万能的,有时为了程序能够正常运行,会隐式的持有或复制对象,如果不加以注意,便会造成内存泄露!今天就列举几个在 ARC 下容易产生内存泄露的点,和各位童鞋一起分享下。


block 系列

在 ARC 下,当 block 获取到外部变量时,由于编译器无法预测获取到的变量何时会被突然释放,为了保证程序能够正确运行,让 block 持有获取到的变量,向系统显明:我要用它,你们千万别把它回收了!然而,也正因 block 持有了变量,容易导致变量和 block 的循环引用,造成内存泄露! 关于 block 的更多内容,请移步《block 没那么难》

  1. /**
  2. * 本例取自《Effective Objective-C 2.0》
  3. *
  4. * NetworkFetecher 为自定义的网络获取器的类
  5. */
  6. //EOCNetworkFetcher.h
  7. #import <Foundation/Foundation.h>
  8. typedef void (^EOCNetworkFetcherCompletionHandler)(NSData *data);
  9. @interface EOCNetworkFetcher : NSObject
  10. @property (nonatomic, strong, readonly) NSURL *url;
  11. - (id)initWithURL:(NSURL *)url;
  12. - (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion;
  13. @end;
  1. //EOCNetworkFetcher.m
  2. #import "EOCNetworkFetcher.h"
  3. @interface EOCNetworkFetcher ()
  4. @property (nonatomic, strong, readwrite) NSURL *url;
  5. @property (nonatomic, copy) (EOCNetworkFetcherCompletionHandler)completionHandler;
  6. @property (nonatomic, strong) NetworkFetecher *networkFetecher;
  7. @end;
  8. @implementation EOCNetworkFetcher
  9. - (id)initWithURL:(NSURL *)url
  10. {
  11. if (self = [super init]) {
  12. _url = url;
  13. }
  14. return self;
  15. }
  16. - (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion
  17. {
  18. self.completionHandler = completion;
  19. /**
  20. * do something;
  21. */
  22. }
  23. - (void)p_requestCompleted
  24. {
  25. if (_completionHandler) {
  26. _completionHandler(_downloaderData);
  27. }
  28. }
  1. /**
  2. * 某个类可能会创建网络获取器,并用它从 URL 中下载数据
  3. */
  4. @implementation EOCClass {
  5. EOCNetworkFetcher *_networkFetcher;
  6. NSData *_fetcherData;
  7. }
  8. - (void)downloadData
  9. {
  10. NSURL *url = [NSURL alloc] initWithString:@"/* some url string */";
  11. _networkFetcher = [[EOCNetworkFetch alloc] initWithURL:url];
  12. [_networkFetcher startWithCompletionHandler:^(NSData *data) {
  13. NSLog(@"request url %@ finished.", _networkFetcher);
  14. _fetcherData = data;
  15. }]
  16. }
  17. @end;

这个例子的问题就在于在使用 block 的过程中形成了循环引用:self 持有 networkFetecher;networkFetecher 持有 block;block 持有 self。三者形成循环引用,内存泄露。

  1. // 例2:block 内存泄露
  2. - (void)downloadData
  3. {
  4. NSURL *url = [[NSURL alloc] initWithString:@"/* some url string */"];
  5. NetworkFetecher *networkFetecher = [[NetworkFetecher alloc] initWithURL:url];
  6. [networkFetecher startWithCompletionHandler:^(NSData *data){
  7. NSLog(@"request url: %@", networkFetcher.url);
  8. }];
  9. }

这个例子比上个例子更为隐蔽,networkFetecher 持有 block,block 持有 networkFetecher,形成内存孤岛,无法释放。

说到底原来就是循环引用搞的鬼。循环引用的对象是首尾相连,所以只要消除其中一条强引用,其他的对象都会自动释放。对于 block 中的循环引用通常有两种解决方法

  1. // 将对象置为 nil ,消除引用,打破循环引用
  2. /*
  3. 这种做法有个很明显的缺点,即开发者必须保证 _networkFetecher = nil; 运行过。若不如此,就无法打破循环引用。
  4. 但这种做法的使用场景也很明显,由于 block 的内存必须等待持有它的对象被置为 nil 后才会释放。所以如果开发者希望自己控制 block 对象的生命周期时,就可以使用这种方法。
  5. */
  6. // 代码中任意地方
  7. _networkFetecher = nil;
  8. - (void)someMethod
  9. {
  10. NSURL *url = [[NSURL alloc] initWithString:@"g.cn"];
  11. _networkFetecher = [[NetworkFetecher alloc] initWithURL:url];
  12. [_networkFetecher startWithCompletionHandler:^(NSData *data){
  13. self.data = data;
  14. }];
  15. }
  1. // 将强引用转换成弱引用,打破循环引用
  2. __weak __typeof(self) weakSelf = self;
  3. NSURL *url = [[NSURL alloc] initWithString:@"g.cn"];
  4. _networkFetecher = [[NetworkFetecher alloc] initWithURL:url];
  5. [_networkFetecher startWithCompletionHandler:^(NSData *data){
  6. //如果想防止 weakSelf 被释放,可以再次强引用
  7. __typeof(&*weakSelf) strongSelf = weakSelf;
  8. if (strongSelf)
  9. {
  10. //do something with strongSelf
  11. }
  12. }];

代码 __typeof(&*weakSelf) strongSelf 括号内为什么要加 &* 呢?主要是为了兼容早期的 LLVM,更详细的原因见:Weakself的一种写法

block 的内存泄露问题包括自定义的 block,系统框架的 block 如 GCD 等,都需要注意循环引用的问题。

有个值得一提的细节是,在种类众多的 block 当中,方法名带有 usingBlock 的 Cocoa Framework 方法或 GCD 的 API ,如

- enumerateObjectsUsingBlock:
- sortUsingComparator:

这一类 API 同样会有循环引用的隐患,但原因并非编译器做了保留,而是 API 本身会对传入的 block 做一个复制的操作。


performSelector 系列

performSelector 顾名思义即在运行时执行一个 selector,最简单的方法如下

- (id)performSelector:(SEL)selector;

这种调用 selector 的方法和直接调用 selector 基本等效,执行效果相同

[object methodName];
[object performSelector:@selector(methodName)];

但 performSelector 相比直接调用更加灵活

  1. SEL selector;
  2. if (/* some condition */) {
  3. selector = @selector(newObject);
  4. } else if (/* some other condition */) {
  5. selector = @selector(copy);
  6. } else {
  7. selector = @selector(someProperty);
  8. }
  9. id ret = [object performSelector:selector];

这段代码就相当于在动态之上再动态绑定。在 ARC 下编译这段代码,编译器会发出警告

warning: performSelector may cause a leak because its selector is unknow [-Warc-performSelector-leak]

正是由于动态,编译器不知道即将调用的 selector 是什么,不了解方法签名和返回值,甚至是否有返回值都不懂,所以编译器无法用 ARC 的内存管理规则来判断返回值是否应该释放。因此,ARC 采用了比较谨慎的做法,不添加释放操作,即在方法返回对象时就可能将其持有,从而可能导致内存泄露。

以本段代码为例,前两种情况(newObject, copy)都需要再次释放,而第三种情况不需要。这种泄露隐藏得如此之深,以至于使用 static analyzer 都很难检测到。如果把代码的最后一行改成

[object performSelector:selector];

不创建一个返回值变量测试分析,简直难以想象这里居然会出现内存问题。所以如果你使用的 selector 有返回值,一定要处理掉。

performSelector 的另一个可能造成内存泄露的地方在编译器对方法中传入的对象进行保留。据说有位苦命的兄弟曾被此问题搞得欲仙欲死,详情围观 performSelector延时调用导致的内存泄露


addObserver 系列

addObserver 即 Objective-C 中的观察者,此系列常见于 NSNotification、KVO 注册通知。注册通知时,为了防止 observer 被突然释放,造成程序异常,需要持有 observer,这是造成内存泄露的一个隐患之一。

所以为什么需要在代码的 dealloc 方法中移除通知,原因就在于此。

NSNotificationcenter 需要 removeObserver 的原因是如果不移除的话,被观察者那么还会继续发送消息。如果此时观察者已经释放,消息会转发给其他对象,有可能造成严重的问题《理解消息转发机制》


NSTimer

在使用 NSTimer addtarget 时,为了防止 target 被释放而导致的程序异常,timer 会持有 target,所以这也是一处内存泄露的隐患。

  1. // NSTimer 内存泄露
  2. /**
  3. * self 持有 timer,timer 在初始化时持有 self,造成循环引用。
  4. * 解决的方法就是使用 invalidate 方法销掉 timer。
  5. */
  6. // interface
  7. @interface SomeViewController : UIViewController
  8. @property (nonatomic, strong) NSTimer *timer;
  9. @end
  10. //implementation
  11. @implementation SomeViewController
  12. - (void)someMethod
  13. {
  14. timer = [NSTimer scheduledTimerWithTimeInterval:0.1
  15. target:self
  16. selector:@selector(handleTimer:)
  17. userInfo:nil
  18. repeats:YES];
  19. }
  20. @end

try...catch

做了一年多的 iOS 开发,一开始看到 try...catch 的第一反应是:这什么鬼?怎么从来没听过?确实,try...catch 实在太低调了,当然这也是有原因的,后面会说。

Apple 提供了 错误处理(NSError)和 异常处理(NSException)两种机制,而 try...catch 就是使用 exception 捕获异常。NSError 应用在在绝大部分的场景下,并且这也是 Apple 所推荐。那什么时候用 NSException 呢?在极其严重的直接导致程序崩溃情况下才使用,并且无需考虑恢复问题。水平和经验所限,我也没有使用过 exception,但可以举个系统使用 exception 的例子

  1. NSArray *array = @[@"a", @"b", @"c"];
  2. [array objectAtIndex:3];

这小段代码一执行,马上崩溃,有提示信息

  1. 2015-03-08 21:38:02.346 HelloWorldDemo[87324:1024731] *** Terminating app due to uncaught exception 'NSRangeException', reason: '*** -[__NSArrayI objectAtIndex:]: index 3 beyond bounds [0 .. 2]'
  2. *** First throw call stack:
  3. (
  4. /**
  5. * ...
  6. * ...
  7. * 这中间省略了的东西是栈回溯信息,如
  8. *
  9. * 0 CoreFoundation 0x045a0946 __exceptionPreprocess + 182
  10. * 1 libobjc.A.dylib 0x041fba97 objc_exception_throw + 44
  11. * 2 CoreFoundation 0x04483bd2 -[__NSArrayI objectAtIndex:] + 210
  12. * ...
  13. * ...
  14. */
  15. )
  16. libc++abi.dylib: terminating with uncaught exception of type NSException

很熟悉对吧,原来我们平时看到的各种崩溃提示信息,用的就是 exception。

Objective-C 的 try...catch 的语法格式和 C++/Java 类似,如下

  1. @try {
  2. // 可能抛出异常的代码
  3. }
  4. @catch (NSException *exception) {
  5. // 处理异常
  6. }
  7. @finally {
  8. // finally 代码块是可选的
  9. // 但如果写了 finally block,不管有没有异常,block 内的代码都会被执行
  10. }

以前面 NSArray 的越界访问为例,即可写成如下代码

  1. NSArray *array = @[@"a", @"b", @"c"];
  2. @try {
  3. // 可能抛出异常的代码
  4. [array objectAtIndex:3];
  5. }
  6. @catch (NSException *exception) {
  7. // 处理异常
  8. NSLog(@"throw an exception: %@", exception.reason);
  9. }
  10. @finally {
  11. NSLog(@"finally execution");
  12. }

使用了 try...catch 后,代码就不会崩溃,执行后打印如下信息

  1. 2015-03-08 22:36:34.729 HelloWorldDemo[87590:1066344] throw an exception: *** -[__NSArrayI objectAtIndex:]: index 3 beyond bounds [0 .. 2]
  2. 2015-03-08 22:36:34.729 HelloWorldDemo[87590:1066344] finally execution

那 try...catch 哪里会有内存泄露的隐患呢?我们先看 MRC 下的情况

  1. // MRC 下的 try...catch
  2. // 注意:在 @try @catch @finally 块内定义的变量都是局部变量
  3. @try {
  4. EOCSomeClass *object = [[EOCSomeClass alloc] init];
  5. [object doSomethingMayThrowException];
  6. [object release];
  7. }
  8. @catch (NSException *exception) {
  9. NSLog(@"throw an exception: %@", exception.reason);
  10. }

此处看似正常,但如果 doSomethingMayThrowException 方法抛出了异常,那么 object 对象就无法释放。如果 object 对象持有了重要且稀缺的资源,就可能会造成严重后果。

ARC 的情况会不会好点儿呢?其实更糟糕。我们以为 ARC 下,编译器会替我们做内存释放,其实不会,因为这样需要加入大量的样板代码来跟踪清理对象,从而在抛出异常时将其释放。即使这段代码即使不抛出异常,也会影响运行期的性能,而且增加进来的额外代码也会增加应用程序的体积,这些副作用都是很明显的。但另一方面,如果程序都崩溃了,回不回收内存又有什么意义呢?

所以可以总结下 try...catch 绝迹的原因:


总结

众观全文,ARC 下的内存泄露问题仅仅是由于编译器采用了较为谨慎的策略,为了保证程序能够正常运行,而隐式的复制或持有对象。只要代码多加注意,即可避免很多问题。

本文总结可能不够完整,如还有遗漏的地方,请到新浪微博 MicroCai 告诉我,我会尽力补全。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注