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

相关推荐
B.-2 小时前
Flutter 应用在真机上调试的流程
android·flutter·ios·xcode·android-studio
iFlyCai12 小时前
Xcode 16 pod init失败的解决方案
ios·xcode·swift
郝晨妤21 小时前
HarmonyOS和OpenHarmony区别是什么?鸿蒙和安卓IOS的区别是什么?
android·ios·harmonyos·鸿蒙
Hgc5588866621 小时前
iOS 18.1,未公开的新功能
ios
CocoaKier1 天前
苹果商店下载链接如何获取
ios·apple
zhlx28351 天前
【免越狱】iOS砸壳 可下载AppStore任意版本 旧版本IPA下载
macos·ios·cocoa
XZHOUMIN2 天前
网易博客旧文----编译用于IOS的zlib版本
ios
爱吃香菇的小白菜2 天前
H5跳转App 判断App是否安装
前端·ios
二流小码农2 天前
鸿蒙开发:ForEach中为什么键值生成函数很重要
android·ios·harmonyos
hxx2212 天前
iOS swift开发--- 加载PDF文件并显示内容
ios·pdf·swift