Obj-C内存管理细节和多线程

Obj-C 内存管理细节和多线程

引用计数的实现

iOS 中内存管理的部分是由 NSObject 类实现的。Foundation 框架并没有开源,但是 Cocoa 的互换框架 GNUstep 是开放源码的。互换框架是指,虽然实现的机制可能不一样,但对使用者来说,他们的行为应该是一致的。

首先来看 GNUstep 中是怎么做的:

在 alloc 时,会给对象分配一个 NSZone 作为内存空间。把对象的内存空间置为全 0,并在其头部加上引用计数字段,保存它的引用计数值。retain 方法将使这个值加一,而 release 方法将使它减一。而在 release 方法中,每次都会去判断此时的引用计数是不是 0。如果到了 0,则调用 dealloc 方法把对象的空间释放掉。

其中,NSZone 是为了防止内存碎片化而引入的结构。但是,现代运行时系统的内存管理本身效率已经很高,使用区域(Zone)来管理内存反而会引起内存使用效率低下及源代码复杂化等问题,所以区域已经单纯地被忽略了。

zone

而苹果的实现与它不太一样。苹果使用了一个哈希表(引用计数表)来管理引用计数。表的 key 为内存块地址的哈希值。这样带来的好处有:

  • 对象的内存块不用再考虑预留头部记录引用计数。
  • 引用计数表中存有对象的内存块地址,可以通过记录追溯到内存块。

在利用工具检测内存泄露时,引用计数表的记录也有助于检测各对象的持有者是否还存在。

ARC 模式下的 AutoRelease

在 ARC 下,[obj autorelease]; 不允许被调用,但是可以显示地给对象添加修饰符:

1
2
3
@autoreleasepool {
id __autoreleasing obj = [[NSObject alloc] init];
}

但是这种情况就与显示地声明 __strong 一样罕见(对象默认就是强引用)。因为编译器会判断方法名是否以 alloc / new / copy / mutableCopy 开头,如果不是,则自动地将返回值的对象注册到 autoreleasepool 中。这也是为什么 OC 中自己定义的函数一般不要以这些单词开头的原因。

所有标记了 weak 修饰符的对象也都会被注册到自动释放池中。这是因为它只持有对象的弱引用,而在访问引用对象的过程中,该对象有可能被废弃。如果添加到自动释放池中,在 @autoreleasepool 结束前,都能确保该对象存在。

C 语言的结构体

C 语言的结构体(struct / union)中的成员变量不能时 OC 的对象,否则会引起编译错误。这是因为 C 语言并没有方法来管理结构体成员的生命周期。

如果一定要用,可以把对象强制转化成 void *,或者附加 __unsafe_unretained 修饰符。正如该修饰符的名称,该变量不在编译器所管理的对象之中,因此不安全,一切都需要程序员手动管理。

__weak 修饰符

附有 __weak 修饰符的变量所引用的对象一旦被废弃,则该变量会被自动赋值为 nil,这是怎么实现的呢?

原来,系统会调用一个 objc_storeWeak 的方法,把变量存储进一个 weak 表中。weak 表与引用计数表很像,也是一个哈希表,同样以赋值对象的地址作为 key。当对象被废弃时,会从 weak 表中查找以废弃的对象地址作为 key 的记录,把包含在记录中的所有赋有 weak 关键字的对象的地址找到,并给这些变量赋值为 nil。之后,从 weak 表和引用计数表中删除对应的记录。

从上面的过程中可以看到,如果大量的 weak 对象被废弃,将会消耗更多的 CPU 资源。因此建议只有在必需的情况下使用 weak 来修饰变量。

什么是线程

一个 CPU 一次只能执行一个命令,虽然有时命令列的地址会发生迁移,但它永远不可能在某处分开而产生并列执行两条指令的情况。这条无分叉的路径就被称为线程。

但是,当这样的无分叉路经不只一条时,就被称为“多线程了”。

OS X 和 iOS 的 XNU 内核每隔一段时间会切换执行路经。例如将 CPU 的寄存器等信息保存到各个路径专用的内存块中,再复原目标路径内存块中的信息,来切换不同的路径。这被称为“上下文切换”。反复切换时,看上去就是一个 CPU 能并列地执行多个线程一样。当然,如果计算机本身就有多核 CPU,那么就可以真正的使用多个 CPU 核心并行地执行多线程程序。

两种 Dispatch Queue

种类 说明
Serial Dispatch Queue 等待现在执行中处理结束
Concurrent Dispatch Queue 不等待现在执行中处理结束

例如,当 queue 为 serial 类型时,会 1 2 3 4 5 这样等待一个任务执行完后再执行下一个任务。而如果为 concurrent 类型时,就会并行执行多个任务。不过,具体开启几个线程由操作系统根据处理数、CPU 核心数、当前 CPU 负载等状态来决定。

这里,如果创建了多个 queue,这多个 queue 则是并行执行的。虽然在 1 个 Serial Queue 中只能同时执行 1 个处理,但是如果有多个 queue,每个 queue 之间就同时执行了。你可以添加任意多个 queue,从而突破限制,发起任意多个线程。当然,开启的线程数量越多,消耗的内存也就越多。大量的上下文切换也会大幅降低系统的响应性能。

为了避免多个线程竞争数据,可以使用 Serial Dispatch Queue。

生成的 queue 需要自己释放,而不能通过 ARC 自动释放。不过一般不需要我们自己创建 queue,因为系统给我们提供了两个队列:Global Dispatch Queue 和 Main Dispatch Queue。显然,Global 时 Concurrent 的,而 Main 是 Serial 的。

dispatch_barrier_async

多个线程读取文件时是安全的,但是当写入时就会出现问题。使用 dispatch_barrier_async 函数,可以等待追加到 concurrent queue 上的并行处理都执行完之后,再来执行特定的处理。在这个函数执行完后,queue 又恢复成一般的动作。

利用这个函数可以高效的实现文件访问。

sync

dispatch_sync 函数在指定的处理执行结束前不会返回。因此下面的情况会造成死锁:

1
2
dispatch_queue_t queue = dispatch_get_main_queue();
dispatch_sync(queue, ^{NSLog(@"World");});

该段代码在主线程执行 block,并等待它结束。而主线程正在执行这段代码,又在等待它返回,就造成了死锁。ispatc

所以在用此函数时要特别小心构成死锁。

挂起和恢复

可以用以下两个函数:

1
2
dispatch_suspend(queue);
dispatch_resume(queue);

信号量

通过 Dispatch Semaphore 可以进行排它控制,或用来限制最多执行的线程数。

dispatch-semaphore_create(信号量) 用来创建信号量,如果信号量小于 0 会返回 NULL。

dispatch_semaphore_wait(信号量, 等待时间) 用来等待计数值大于或等于 1。计数值等于 0 时等待。DISPATCH_TIME_FOREVER 表示永久等待。

dispatch_semaphore_signal(信号量) 用于提高信号量。

我们一般在执行一段任务时,先用 wait 来等待(如果该函数返回 0,代表计数值大于等于 1,同时会把计数值降低 1)。在任务结束时,用 signal 来把信号量加一。

dispatch_once

该函数保证了引用程序执行中只执行一次特定的处理。我们可以利用这个函数来生成单例对象,实现单例模式。

# Obj-C
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×