Objective-C 内存陷阱

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 代码。

相关推荐
用户092 小时前
如何避免写垃圾代码:iOS开发篇
ios·swiftui·swift
HarderCoder16 小时前
iOS 知识积累第一弹:从 struct 到 APP 生命周期的全景复盘
ios
叽哥1 天前
Flutter Riverpod上手指南
android·flutter·ios
用户092 天前
SwiftUI Charts 函数绘图完全指南
ios·swiftui·swift
YungFan2 天前
iOS26适配指南之UIColor
ios·swift
权咚3 天前
阿权的开发经验小集
git·ios·xcode
用户093 天前
TipKit与CloudKit同步完全指南
ios·swift
法的空间3 天前
Flutter JsonToDart 支持 JsonSchema
android·flutter·ios
2501_915918413 天前
iOS 上架全流程指南 iOS 应用发布步骤、App Store 上架流程、uni-app 打包上传 ipa 与审核实战经验分享
android·ios·小程序·uni-app·cocoa·iphone·webview
00后程序员张3 天前
iOS App 混淆与加固对比 源码混淆与ipa文件混淆的区别、iOS代码保护与应用安全场景最佳实践
android·安全·ios·小程序·uni-app·iphone·webview