MENU

Objective-C 的自动引用计数(ARC)

• March 6, 2016 • Read: 9802 • Codes

初学 Objective-C,果然最难理解的还是这货:自动引用计数(以下简称 ARC )。如有错误,跪求指正~

ARC 作为自动内存管理工具,和 Java 等语言的 GC 不同的是,GC 是运行时进行垃圾回收,而 ARC 是编译器在编译时自动将内存管理代码在合适的位置插入到我们的代码中,所以 ARC 并不会有性能损失,相反的,在多数情况下,ARC 的速度相较于 MRC 反而更快。

我们可以通过编译器选项 -fobjc-arc 开启 ARC ,在 Xcode 项目中可以将 项目配置->Targets->your target->Build Settings->Apple LLVM XX - Language -Objective-C 下的 Objective-C Automatic Reference Counting 设为 Yes 开启,另外 Xcode 支持文件级关闭 ARC ,可以使用 -fno-objc-arc 关闭不支持 ARC 的文件或关闭第三方库的 ARC 支持。

1. ARC 的管理范围

哪些对象受 ARC 管理1
  • Block 指针
  • Objective-C 对象指针 (id, Class, NSFoo*, 等等)
  • 使用 __attribute__((NSObject)) 标记的 typedefs
哪些对象不受 ARC 管理
  • 值类型(简单值类型,C 语言 struct 等)
  • 使用其他方式分配的堆对象(如使用 malloc 分配)
  • 非内存资源

2. ARC 都做了什么

我们应该知道,每个对象都有一个引用计数,新创建(使用 alloc, new, copy 等)的引用类型对象其引用计数为 1 ,当我们执行一些操作时,引用计数 +1 或 -1 ,当引用计数变为 0 时,内存会被自动释放。访问已经被释放的对象会出现 EXC_BAD_ACCESS 错误。

MRC 是如何工作的

我们先看一下在 MRC 中经常使用的方法:

retain 方法: retain 方法会使引用计数 +1 ,并返回对象本身(的指针)。
release 方法: release 方法会使引用计数 -1 ,没有返回值。

通过 setter 方法了解一下:

- (void)setBook: (Book *)book {
    if (book != _book) {
        //首先release _book对象,因为上一次setter方法或初始化等赋值时已经使retainCount + 1了,所以这里 -1 则刚好抵消。
        //如果_book对象为空,在Objective-C中也并没有影响。
        //如果release之后_book指向的对象的retainCount为0
        //那么_book指向的对象内存就会被自动释放
        [_book release];
        //这里使新对象的retainCount + 1
        _book = [book retain];
    }
}

autorelease 方法:
release 会导致对象立即释放,而 autorelease 则为延时释放,即将 release 的调用(即向对象发送 release 消息)延时了。每当一个对象调用 autorelease 方法时,实际上是将该对象放入当前 Autorelease Pool 中,在当前 Autorelease Pool 释放时,会对添加进该 Autorelease Pool 中的对象逐一调用 release 方法。

为什么需要 autorelease 呢?

我们假设有如下方法:

//MRC模式下
+ (instancetype)bookWithName: (NSString *)name {
    return [[Book alloc] initWithName:name];
}

Objective-C 的内存管理机制中有一条比较重要的规则:谁负责申请,谁负责释放。那么,bookWithName: 方法就应该负责释放新创建的对象。但显然这里不能释放这个对象,否则一切就没有意义了,而根据「谁负责申请,谁负责释放」规则,调用者也不会去释放这个对象,那这样下去就没人去释放这个对象了,于是就造成了内存泄露。

显然这种事情不应该发生,而 autorelease 方法(机制)解决了这个问题。所以上面那个方法应该写成这样:

//MRC模式下
+ (instancetype)bookWithName: (NSString *)name {
    return [[[Book alloc] initWithName:name] autorelease];
}

这样,在当前 Autorelease Pool 被释放时,该方法创建的对象就会被释放掉了。

什么时候返回的对象是调用过 autorelease 方法的呢?

根据 Cocoa 命名规范,alloc, copy, init, mutableCopynew families 方法2,例如:

- (instancetype)init;
- (instancetype)initWithString:(NSString *)aString;
- (id)copy;
- (id)mutableCopy;
+ (instancetype)new;
+ (instancetype)alloc;
+ (instancetype)allocWithZone:(struct _NSZone *)zone;
...

这些方法会被默认标记为 __attribute((ns_returns_retained)) ,即:

- (instancetype)init __attribute((ns_returns_retained));

于是,这些方法会返回 retained object ,相当于它们(实际上是 alloc family method )内部已经对返回的对象调用了 retain 方法,即引用计数为 1 。而调用这些方法的我们,则为返回对象的「申请者」,或者说所有者,在使用完毕后有义务去释放这些对象

而不是这些 family 方法成员的工厂方法,方法名以移除前缀的类名开头,其后的部分通常以 With 或 From 开头3。例如:

//class: NSString
+ (instancetype)stringWithString:(NSString *)string;
+ (instancetype)stringWithCharacters:(const unichar *)characters length:(NSUInteger)length;

这些方法会返回 autoreleased instance.

ARC 都帮我们做了什么

之前提到,ARC 是编译器在编译时自动将内存管理代码在合适的位置插入到我们的代码中,那么 ARC 会在什么时候帮我们插入代码呢?

插入 retain 方法:

  • 将对象引用赋值给其他变量或常量
  • 将对象引用赋值给其他属性或实例变量
  • 将对象传递给函数参数,或者返回值
  • 将对象加入集合中

插入 release 方法:

  • 将局部变量或全局变量赋值为 nil 或其他值
  • 将属性赋值为 nil 或其他值
  • 实例属性所在的对象被释放
  • 参数或局部变量离开函数
  • 将对象从集合中删除

下面通过代码来辅助理解一下:

//ARC

void arcDemo() {
    Point *p1 = [[Point alloc] init];
    Rectangle *rect = [[Rectangle alloc] init];

    Point *p2 = p1;
    rect.center = p1; //center属性均为默认值
    NSMutableArray *array = [[NSMutableArray alloc] initWithCapacity:10];
    [array addObject:p1];

    p1 = nil;
    p2 = nil;
    rect.center = nil;
    [array removeObjectAtIndex:0];
}

那么上面这段代码编译器帮我们做了哪些工作呢?

//非实际代码,仅供理解

void arcDemo() {
    Point *p1 = [[Point alloc] init];//retainCount = 1
    Rectangle *rect = [[Rectangle alloc] init];

    Point *p2 = [p1 retain];//retainCount = 2

    rect.center = p1;//这里实际上是调用了setCenter: 方法
    /*
    - (void)setCenter: (Point *)aPoint {
        if (aPoint == _center) {
            [_center release];
            _center = [aPoint retain];//retainCount = 3
        }
    }
    */


    NSMutableArray *array = [[NSMutableArray alloc] initWithCapacity:10];
    [array addObject:p1];
    /*
    - (void)addObject:(ObjectType)anObject {
        //...

        ...[anObject retain];//retainCount = 4

        //...
    }
    */

    p1 = nil;
    [p1 release];//retainCount = 3
    p2 = nil;
    [p2 release];//retainCount = 2 (p1和p2指向的对象是同一个)
    rect.center = nil;
    /*
    - (void)setCenter: (Point *)aPoint {
        if (aPoint == _center) {
            [_center release];//retainCount = 1
            _center = [aPoint retain];
        }
    }
    */
    [array removeObjectAtIndex:0];
    /*
    - (void)removeObjectAtIndex:(NSUInteger)index {
        //...
        [object release];//retainCount = 0 (将被自动释放)
        //...
    }
    */

    //离开其作用域时
    [rect release];
    [array release];
}

可以看到,编译器帮我们做了很多事情。在使用 MRC 的时候,总难免会出现一些失误,导致内存泄漏的情况发生,但编译器是不会忘得。

在现在已经很少见到 MRC 了,但了解这些知识,可以帮助我们更好地了解 Objective-C 的内存管理机制。

在使用 ARC 的时候有哪些规则4

  • 无法手动调用 dealloc,无法实现或调用 retainreleaseretainCountautorelease 方法,即使用 @selector 也不行
  • 不能使用 NSAllocateObjectNSDeallocateObject
  • C 语言的 struct 中成员不能是对象指针
  • 必须显示转换 idvoid *
  • 不能使用 NSAutoreleasePool 对象,但可以使用 @autoreleasepool{...},后者比前者的性能更高。
  • 不能使用 memory zones
  • 不能以 new 为开头命名一个属性,除非你另外定义一个 getter

    // Won't work:
    @property NSString *newTitle;
    
    // Works:
    @property (getter=theNewTitle) NSString *newTitle;

循环引用

之前我们提到,只有当一个对象的引用计数变为 0 的时候这个对象才会被释放,那么,如果两个对象互相引用,这两个对象的引用计数不就不可能小于 1 了嘛。。

看一下下面这段代码:

//class Employee
@interface Employee : NSObject

@property NSString *name;
@Property WorkItem *workItem;//这里引用了WorkItem对象

@end


//class WorkItem
@interface WorkItem : NSObject

@property NSString *content;
@property Employee *employee;//这里引用了Employee对象

@end

然后,他们现在互相引用了

void func() {
    Employee *employee = [[Employee alloc] init];//employee.retainCount = 1
    WorkItem *workItem = [[WorkItem alloc] init];//workItem.retainCount = 1

    employee.workItem = workItem;//workItem.retainCount = 2
    workItem.employee = employee;//employee.retainCount = 2

      //-----------函数结束---------------
    //编译器插入(伪代码)
    [employee release];//employee.retainCount = 1
    [workItem release];//workItem.retainCount = 1
}

现在,函数结束,栈上已经没有指针指向这两个堆上的对象了,而现在这两个对象却在互相引用,我们拿他们没有任何办法,只能眼睁睁的看着内存泄漏。

要解决这个问题,我们需要了解 Objective-C 中的两种引用方式:强引用和弱引用。

Objective-C 中默认为强引用,即不加修饰符的话,均为强引用。

所以上面定义的 property 都是强引用。定义方式如下:

//属性
@property          NSString *name1;//强引用
@property (strong) NSString *name2;//强引用
@property (weak)   NSString *name3;//弱引用

//
NSString *str1 = [NSString stringWithFormat:@"str%d", 1];//强引用
__strong NSString *str2 = [NSString stringWithFormat:@"str%d", 1];//强引用
__weak   NSString *str3 = [NSString stringWithFormat:@"str%d", 1];//弱引用
__weak   NSString *str4 = str2;//弱引用

强引用会使引用计数 +1 ,而弱引用不会,所以我们可以把 WorkItem 类修改一下:

//class WorkItem
@interface WorkItem : NSObject

@property NSString *content;
@property (weak) Employee *employee;//这里改为弱引用

@end

那么他们再互相引用的时候就不会这么可怕了。。

void func() {
    Employee *employee = [[Employee alloc] init];//employee.retainCount = 1
    WorkItem *workItem = [[WorkItem alloc] init];//workItem.retainCount = 1

    employee.workItem = workItem;//workItem.retainCount = 2
    workItem.employee = employee;//employee.retainCount = 1 弱引用不会使引用计数+1


      //-----------函数结束---------------
    //编译器插入(伪代码)
    [employee release];//employee.retainCount = 0
    //这时employee对象会被释放掉了,所以,其对workItem的引用也就没了
    //所以这时            workItem.retainCount = 1

    //这样,workItem也可以被释放掉了,释放顺序并不影响结果
    [workItem release];//workItem.retainCount = 0
}

3. Autorelease Pool

前面我们讲 autorelease 的时候提到了 Autorelease Pool ,Autorelease Pool 的创建方法有两种:

  • 第一种:使用 NSAutoreleasePool ,这种方法只能在 MRC 模式下使用

    NSAutoreleasePool *autoreleasePool = [[NSAutoreleasePool alloc] init];
    
    // Code that creates autoreleased objects.
    
    [autoreleasePool release];
  • 第二种:使用 @autoreleasepool ,ARC 和 MRC 下都可以使用

    @autoreleasepool {
        // Code that creates autoreleased objects.
    }

所以,在 ARC 模式下,我们只能用 @autoreleasepool{...}

@autoreleasepool 是很常见的,例如我们用 Xcode 创建的项目 main 函数中通常以此开头:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        //...
    }
}

Autorelease Pool 的作用前面有提到过,每当一个对象调用 autorelease 方法时,实际上是将该对象放入当前 Autorelease Pool 中,当当前 Autorelease Pool 释放时,会对添加进该 Autorelease Pool 中的对象逐一调用 release 方法。

除了我们手动创建的 Autorelease Pool 之外,AppKit 和 UIKit 框架在处理每一次事件循环迭代时,都会将其放入一个 Autorelease Pool 中。

显然,Autorelease Pool 是可以嵌套使用的。

通常情况下我们是不需要手动创建 Autorelease Pool ,但以下三种情况是例外的5

  • 编写的程序不基于 UI 框架,如命令行程序
  • 在循环中创建大量临时对象
  • 在主线程之外创建新的线程,在新线程开始执行处,创建自己的 Autorelease Pool ,否则将导致内存泄漏。

使用 Autorelease Pool 降低内存占用峰值

如果一个循环中创建了大量的临时对象,那么可以在循环内部创建一个 Autorelease Pool ,以在执行下次循环之前销毁这些对象,这样可以降低内存占用峰值。

NSArray *urls = <# An array of file URLs #>;
for (NSURL *url in urls) {

    @autoreleasepool {
        NSError *error;
        NSString *fileContents = [NSString stringWithContentsOfURL:url
                                         encoding:NSUTF8StringEncoding error:&error];
        /* Process the string, creating and autoreleasing more objects. */
    }
}

我们可以写一个简单地 Demo 来看一下用 Autorelease Pool 和不用 Autorelease Pool 的区别:

void loopWithPool {
    for (int i = 0; i < 5000000; i++) {
        @autoreleasepool {
            NSNumber *num = [NSNumber numberWithInt:i];
            NSString *str = [NSString stringWithFormat:@"%d ", i];

            __unused NSArray* array = [NSArray arrayWithObjects:num, str, nil];
        }
    }
}//在这行设置一个断点

void loopWithoutPool {
    for (int i = 0; i < 5000000; i++) {
        NSNumber *num = [NSNumber numberWithInt:i];
        NSString *str = [NSString stringWithFormat:@"%d ", i];

        __unused NSArray* array = [NSArray arrayWithObjects:num, str, nil];
    }
}//在这行设置一个断点

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        loopWithoutPool();
        //loopWithPool();
    }
    return 0;
}

我们可以在 main 函数中分别执行 loopWithoutPool()loopWithPool() 看一下各自的内存占用情况。在 Xcode 左栏的 Debug Navigator 中可以很方便的看到 CPU 、内存占用等情况。

使用 loopWithoutPool() 的情况下:你应该会看到在程序开始运行后,内存占用一直在上涨,到达断点时内存占用已经达到了 233.9MB(根据系统、编译器、硬件环境等的不同可能内存占用会有部分差别)

使用 loopWithPool() 的情况下:你应该会看到在程序开始运行后,内存占用基本一直维持在 1.3MB 左右(根据系统、编译器、硬件环境等的不同可能内存占用会有部分差别)

Last Modified: January 31, 2023
Archives QR Code Tip
QR Code for this page
Tipping QR Code
Leave a Comment

3 Comments
  1. haha

    1. @Dante蛤蛤?