之前一直关注业务的实现,几乎没有研究一些基础的较为底层的知识,趁着春招面试的机会来学习一下。首先学习的是 iOS 中内存管理的内容。

堆和栈

什么是堆?什么是栈?

栈(stack)大家接触的应该比较多,是一种先进后出的数据结构,但是堆(heap)可能就没有那么了解了。
堆,又称为优先队列(priority queue)。队列是先进先出(FIFO),堆也一样,堆底插入元素,堆顶取出元素,但不是按入堆顺序排列,而是按一定的优先顺序排列。

内存分配中的栈和堆

堆栈空间分配

栈由操作系统自动分配、释放,存放函数的参数值,局部变量等,其操作类似数据结构中的栈;
堆一般由程序员分配,若程序员不释放,程序结束时可能由 OS 回收,分配
方式类似链表;

堆栈缓存方式

栈使用一级缓存,通常都是被调用时处于存储空间,调用完毕立即释放;
堆存放在二级缓存中,生命周期由虚拟机的垃圾回收算法决定(但不是一旦成为故而对象就能被回收)

所以,在iOS中,值类型存放在栈空间中,内存分配和回收不需要我们关心;对象这种引用类型存放在堆空间中,由程序员自己分配,自己释放。

引用计数(Reference Count)

引用计数是指将资源(对象、内存或磁盘空间等等)的被引用次数保存起来,当引用次数变为零时释放的过程。使用引用计数可以实现自动资源管理的目的。—— 维基百科
NSObject中提供的有关引用计数的方法:
  • retain:引用计数器 +1
  • release:引用计数器 -1
  • autorelease:不改变引用计数器的值,只是将对象添加到自动释放池中
  • retainCount:返回引用计数值

Objective-C的内存管理方法中:

  • 生成并持有对象 alloc/new/copy/mutableCopy等方法
  • 持有对象 retain方法
  • 释放对象 release方法
  • 废弃对象 dealloc方法

OC中的这项方法其实指代了NSObject类中相应的方法。

MRC(手动管理引用计数)

alloc

MyClass *obj = [[MyClass alloc] init];

NSObject类的alloc方法能生成并持有对象,系统为该对象分配内存空间,并将它的引用计数设为 1.

release

[obj release];

引用计数减 1。当对象引用计数减为 0 时,系统将该对象从内存移除,并释放内存。

retain

[obj retain];

引用计数加 1。调用retain意味着其他对象想持有obj.

copy

MyClass *objcopy = [obj copy];

copyretain原理相似,复制一份原来的对象,但引用计数不同。如果复制obj时它的引用计数为 4,复制得到的objcopy的引用计数为 1.

autorelease

[obj autorelease];

当一个对象的作用域超出了它所声明的范围,就需要对其调用autorelease。它会告诉系统,我们并不希望立即销毁这个对象,而是在autoreleasepool被清空时再去销毁对象。
这和 C 语言中的局部变量超出作用域时自动废弃差不多。

dealloc

- (void)dealloc {
	[obj release];
	[objcopy release];
	[super dealloc];
}

MRC 的缺点

手动管理内存很容易出现一些问题

悬空指针

MyClass *foo = [[MyClass alloc] init];
[foo release];
[foo doSomething];

当引用计数变为 0 时,对象从内存移除。但 foo 的指针仍然指向这块内存。所以当 doSomething 时,常常会引起 crash。

内存泄漏

MyClass *foo = [[MyClass alloc] init];
[foo retain];
[foo retain];
[foo release];

release的次数比retain少,就会发生内存泄漏。
当这样永远不会被释放空间的对象太多时,就会导致应用的内存被耗光,产生奇怪的问题,最终导致 crash。这也是我们在dealloc中执行清空操作的原因。

ARC(自动引用计数)

ARC 能够解决 iOS 开发中 90% 的内存管理问题,剩下的如与底层 Core Foundation 对象交互还是需要自己维护这些对象的引用计数。
当然,自动引用计数的使用也是会出现一些问题的:

循环引用(Reference Cycle)问题

如下图,对象 A 和对象 B,相互引用,只有当自己销毁时,才能将成员变量的引用计数减 1。这里 A 和 B 的销毁都依赖彼此,这时候就引发了循环引用问题。

如何解决循环引用呢?

这种循环引用是因为强引用造成的,一种方式是主动断开循环引用,在引用文章第二篇中,巧神给出了例子:

在我开源的  YTKNetwork  网络库中,网络请求的回调 block 是被持有的,但是如果这个 block 中又存在对于 View Controller 的引用,就很容易产循环引用,因为:1. Controller 持有网络请求对象;2. 网络请求对象持有了回调的 block;3. 回调的 block 里面使用了 self,所以持有了 Controller。
解决方案就是,在网络请求结束后,网络请求对象执行完 block 之后,主动释放对于 block 的持有,以打破引用循环。
// https://github.com/yuantiku/YTKNetwork/blob/master/YTKNetwork/YTKBaseRequest.m
// 第 147 行:
- (void)clearCompletionBlock {
    // 主动释放掉对于 block 的引用
    self.successCompletionBlock = nil;
    self.failureCompletionBlock = nil;
}

第二种方式是使用弱引用,弱引用虽然持有对象,但并不增加引用计数,即可避免了循环引用的产生。声明属性或者变量时,在前面加上weak关键词表明是一个弱引用。


在 iOS 开发中,弱引用常在 delegate 模式中使用。

弱引用的实现原理

系统对于每一个有弱引用的对象,都维护一个表来记录所有弱引用的指针地址。当一个对象的引用计数为 0 时,系统就通过这张表,找到所有的弱引用指针,继而把它们都置成 nil。
所以弱引用的使用需要额外开销。

检测循环引用

我们使用 Xcode 的 Instruments 中的 Leaks 工具。
使用 Swift 官方文档中的例子来测试一下。

class Person {
	let name: String
	init(name: String) { self.name = name }
	var apartment: Apartment?
	deinit { print("\(name) is being deinitialized") }
}
 
class Apartment {
	let unit: String
	init(unit: String) { self.unit = unit }
	var tenant: Person?
	deinit { print("Apartment \(unit) is being deinitialized") }
}

var john: Person?
var unit4A: Apartment?
john = Person(name: "John Appleseed")
unit4A = Apartment(unit: "4A")
john!.apartment = unit4A
unit4A!.tenant = john

这里的 Person 和 Apartment 为相互强引用,会造成循环强引用,运行测试程序,会在 Leaks 中看到

会看到有 2 new leaks,同时通过 Cycles & Roots 可以看到循环引用。

这时候我们使用弱引用来解决这个循环强引用的问题。

// 其他不变
class Apartment {
    let unit: String
    init(unit: String) { self.unit = unit }
    weak var tenant: Person?
    deinit { print("Apartment \(unit) is being deinitialized") }
}

这时就没有问题了:

Reference

  1. iOS内存管理详解
  2. 理解 iOS 的内存管理
  3. 了解堆和栈
  4. 自动引用计数