1. Objective-C 内存管理方式
Objective-C 使用引用计数来管理内存。每个对象都有一个关联的引用计数。Objective-C 提供了以下方法来管理对象的引用计数:
-
alloc:分配内存并将引用计数设置为 1。
-
retain:增加引用计数。
-
release:减少引用计数。
-
autorelease:将对象添加到当前的 autorelease pool,稍后自动释放。
手动管理内存(MRC)的一般过程是:
-
当创建一个新的对象时,初始的引用计数为1.
-
为保证对象的存在,每当创建一个引用到该对象时,通过给对象发送 retain 消息,为引用计数加1.
-
当不再需要对象时,通过给对象发送 release 消息,为引用计数减1.
-
当对象的引用计数为0时,系统就知道这个对象不再使用了,通过给对象发送 dealloc 消息,销毁对象并回收内存。
而在自动引用计数(ARC)环境下,编译器会自动插入适当的 retain 和 release 调用,从而简化内存管理。
2. 循环引用
引用计数这种管理内存的方式虽然很简单,但是有一个比较大的瑕疵,即它不能很好的解决循环引用问题。
循环引用是指两个或多个对象相互引用,导致它们的引用计数永远不会变为 0. 这会导致内存泄漏。
如下图所示:对象 A 和对象 B,相互引用了对方作为自己的成员变量,只有当自己销毁时,才会将成员变量的引用计数减 1。因为对象 A 的销毁依赖于对象 B 销毁,而对象 B 的销毁与依赖于对象 A 的销毁,这样就造成了我们称之为循环引用(Reference Cycle)的问题。
解决循环引用问题主要有两个办法,第一个办法是我明确知道这里会存在循环引用,在合理的位置主动断开环中的一个引用,使得对象得以回收。
更常见的办法是使用弱引用。弱引用虽然持有对象,但是并不增加引用计数。
例如,在使用 block 时,如果 ViewController 有 block 成员变量,而 block 捕获了 self,就会导致循环引用。
objectivec
@property (nonatomic, copy) void (^myBlock)(void);
self.myBlock = ^{
[self updateUI];
};
解决方法是使用 __weak 修饰符创建一个弱引用:
objectivec
@property (nonatomic, copy) void (^myBlock)(void);
__weak typeof(self) weakSelf = self;
self.myBlock = ^{
[weakSelf updateUI];
};
3. 多线程操作对象的崩溃
在刚刚接触 iOS 开发的时候,我们知道属性的默认原子性是 atomic. 但是 atomic 在保障 getter、setter 操作原子性的同时,会影响性能。所以一般情况下,我们要将属性声明为 nonatomic.
而在多线程环境下,如果多个线程同时修改同一个 nonatomic 对象,可能会导致崩溃。
我们可以通过 runtime 源码的 setter 函数,来一探究竟。
ini
static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy)
{
if (offset == 0) {
object_setClass(self, newValue);
return;
}
id oldValue;
id *slot = (id*) ((char*)self + offset);
if (copy) {
newValue = [newValue copyWithZone:nil];
} else if (mutableCopy) {
newValue = [newValue mutableCopyWithZone:nil];
} else {
if (*slot == newValue) return;
newValue = objc_retain(newValue);
}
if (!atomic) {
oldValue = *slot;
*slot = newValue;
} else {
spinlock_t& slotlock = PropertyLocks[slot];
slotlock.lock();
oldValue = *slot;
*slot = newValue;
slotlock.unlock();
}
objc_release(oldValue);
}
这里我们关注20行开始的关于 nonatomic/atomic 的不同:
当属性的原子性为 atomic 时,会对属性赋值操作加入锁,以此保障多线程情况下的写操作的安全,同时也会导致性能的损失。
多次 release 原始值
现在我们结合源码来看一下,两个线程同时修改同一个 nonatomic 对象时,可能会导致崩溃的情况。
此时,如果不同时保证这两个赋值操作的原子性,就有概率导致 *slot 中的原始值被 release 两次,而这样就会导致 crash 的发生。
在这种场景下,可以使用 atomic 来修饰属性,以保证稳定性。
atomic 是万能药吗?
当然不是。我们看下以下代码。
ini
@property (atomic, strong) NSArray* array;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// thread 1
for (int i = 0; i < 10000; i ++) {
if (i % 2) {
self.array = @[@(1), @(2), @(3)];
} else {
self.array = @[@(1)];
}
}
});
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// thread 2
for (int i = 0; i < 10000; i ++) {
if (self.array.count == 3) {
NSLog(@"object at index 2: %@", [self.array objectAtIndex:2]);
}
}
});
即使我们将 array 的原子性设置为 atomic,同时在访问 objectAtIndex: 之前加上判断,thread 2 还是会 crash. 原因是由于17、18两行代码之间 array 所指向的内存区域被 thread 1 修改了。
atomic 通过加锁确保了对于属性 getter、setter 操作的原子性。getter、setter 操作的是属性的指针值,对于属性指针所指向的内存地址并不能起到保护作用。
为了避免这种情况,可以使用锁(如 `@synchronized`、`NSLock` 等)来确保同一时间只有一个线程可以访问对象。
例如,使用 `@synchronized` 保护上述数组的访问:
objectivec
@property (nonatomic, strong) NSArray* array;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// thread 1
for (int i = 0; i < 10000; i ++) {
@synchronized (self) {
if (i % 2) {
self.array = @[@(1), @(2), @(3)];
} else {
self.array = @[@(1)];
}
}
}
});
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// thread 2
for (int i = 0; i < 10000; i ++) {
@synchronized (self) {
if (self.array.count == 3) {
NSLog(@"object at index 2: %@", [self.array objectAtIndex:2]);
}
}
}
});
4. try-catch 内存泄漏
在 Objective-C 中,异常处理(try-catch)其实并不常用。原因之一是 try-catch 不能捕获 OC 中的很多异常,比如第3节说的多线程 crash。另外一个原因,是可能会导致内存泄漏。
我们看下内存泄露的例子。
先定义一个简单的类,在对象释放时,打印 dealloc.
objectivec
@implementation TestObject
- (void)dealloc {
NSLog(@"dealloc TestObject");
}
@end
在 ViewController.m 中编写 try-catch,并在 @try 中创建 TestObject 对象,并主动抛出异常。
scss
@try {
NSLog(@"@try");
TestObject* obj = [[TestObject alloc] init];
@throw [[NSException alloc] initWithName:@"TestExceptionName" reason:@"TestExceptionReason" userInfo:nil];
} @catch (NSException *exception) {
NSLog(@"@catch exception: %@", exception);
} @finally {
NSLog(@"@finally");
}
执行后打印日志如下。
scss
OcMemoryDemo[99627:1472760] @try
OcMemoryDemo[99627:1472760] @catch exception: TestExceptionReason
OcMemoryDemo[99627:1472760] @finally
日志没有打印 `dealloc TestObject`,说明 @try 中创建的 TestObject 对象没有被释放。
第1节中我们了解到,alloc 会设置对象引用计数初始值为1. 因此 TestObject 对象内存没有被释放的原因,肯定是对应的 release 没有执行。
我们把 try-catch 的例子代码还原为 MRC 的形式,就明白了。
scss
@try {
NSLog(@"@try");
TestObject* obj = [[TestObject alloc] init];
@throw [[NSException alloc] initWithName:@"TestExceptionName" reason:@"TestExceptionReason" userInfo:nil];
[obj release];
} @catch (NSException *exception) {
NSLog(@"@catch exception: %@", exception);
} @finally {
NSLog(@"@finally");
}
可见,由于 TestObject 是 @try 中的局部变量,编译器会在 @try 最下面自动添加 release 代码。由于 @try 在 release 执行前就抛出了异常,所以 TestObject 对象的引用计数没有减1.
要破解这个问题,有两个办法。
第一个方法是不在 @try 中声明局部变量。@try 中需要用到的变量,都在 @try 外部都声明好。这种方式需要注意,@try 中尽量不要调用其他函数,否则调用链一旦深入,就很难控制其他局部变量的释放。
第二个方法,我们可以通过给 ViewController.m 文件加上 -fobjc-arc-exceptions 参数来进行修复,防止出现内存泄露。但是这会导致编译器增加碎片逻辑用于释放内存,简单的情况,可能会多出一倍的汇编代码量;对于复杂情况,编译器会插入更多的无用代码,导致生成的二进制代码变得很大,所以要慎用。
5. 内存分析工具
针对 OC 内存管理,Xcode 提供了强大的内存分析工具,可以帮助我们检测和解决内存泄漏和循环引用问题,如 Instruments 和 Memory Graph Debugger.
Instruments
Instruments 是一个强大的性能分析工具箱,可以用来检测内存泄漏、循环引用等问题。
首先编写一段简单的测试代码。该代码中的 firstArray 和 secondArray 相互引用了对方,构成了循环引用。
ini
NSMutableArray* firstArray = [NSMutableArray array];
NSMutableArray* secondArray = [NSMutableArray array];
[firstArray addObject:secondArray];
[secondArray addObject:firstArray];
要使用 Instruments 来检测循环引用,首先在 Xcode 中选择 Product > Profile,然后选择 Leaks.
在 Leaks 工具中运行代码,我们看到工具检测到了我们的循环引用。
并且 Leaks 以图形方式展示了循环引用的两个对象。同时,产生循环引用的堆栈也可以在右侧展开,方便我们定位问题代码。
Memory Graph Debugger
Leaks 检测的是一个相当于打包后的 app,如果要在运行时调试,则需要借助 Memory Graph Debugger.
Memory Graph Debugger 是 Xcode 中的一个实用工具,可以帮助我们查看应用程序的内存图,找出循环引用和其他内存问题。
要使用 Memory Graph Debugger,首先在 Xcode 中运行应用程序,然后点击 Debug Navigator 中的 Memory Graph 图标。
Memory Graph Debugger 会显示应用程序的内存对象图,包括对象之间的引用关系。我们可以通过底部筛选按钮,直接筛选出内存泄露的对象。
点击列表上的某一个对象,Memory Graph Debugger 也会用图形展示两个对象循环引用的关系。
有了这些工具,当我们开发完一个功能后,可以通过他们检测一下代码,能有效避免循环引用、内存泄露等问题。
6. 代码规范
在 Xcode 工具的帮助外,良好的代码规范也可以避免一些常见的内存问题。
在编写代码时,应该遵循一定的规范和约定,如及时释放不再需要的对象、block 避免循环引用、delegate 使用 weak 修饰等。
另外,我们也可以观察苹果官方框架的写法,理解并模仿,同样能够避免很多内存问题。
7. 总结
内存管理是 Objective-C 编程中的一个重要主题。本文介绍了内存泄漏、野指针崩溃等问题,以及如何使用内存分析工具进行优化。要避免这些问题,我们需要了解 Objective-C 的内存管理方式,注意循环引用和多线程操作对象的问题,并善于使用内存分析工具。通过这些方法,我们可以编写出更高效、更稳定的 Objective-C 代码。