当前位置: 首页 > 科技观察

理解iOS内存管理

时间:2023-03-19 20:21:40 科技观察

上古故事经历过手动内存管理(MRC)时代的人,一定对iOS开发中的内存管理记忆犹新。那时候大概是2010年,国内iOS开发刚刚兴起的时候,tinyfool大叔的名字已经家喻户晓,而我还是一个默默无闻的刚毕业的孩子。当时的iOS开发流程是这样的:我们先写了一段iOS代码,然后屏住呼吸,开始运行,果不其然,崩溃了。在MRC时代,即使是最牛逼的iOS开发者也不能保证一口气写出完美的内存管理代码。于是,我们一步步开始调试,试图打印出每个可疑对象的引用计数(RetainCount),然后小心翼翼地插入合理的retain和release代码。经过一次又一次的崩溃和调试应用程序,终于,应用程序能够正常运行一次!于是我们松了口气,露出了久违的笑容。没错,这就是那个时代的iOS开发者。通常,我们开发一个功能后,需要花几个小时来管理引用计数。Apple在2011年的WWDC大会上推出了AutomaticReferenceCounting(ARC),ARC背后的原理是依靠编译器的静态分析能力,在编译时找到引用计数管理代码的合理插入,从而彻底解放程序员。ARC刚问世时,业界对这项黑科技充满了质疑和观望。此外,现有MRC代码的迁移需要额外的成本,因此ARC并没有很快被接受。直到2013年左右,Apple认为ARC技术已经成熟到可以直接丢弃macOS(当时称为OSX)上的垃圾回收机制,才让ARC很快被接受。在2014年的WWDC大会上,Apple推出了Swift语言,它仍然使用ARC技术作为其内存管理方式。我为什么要提到这段历史?就是因为现在iOS开发者太舒服了,大部分时候,他们根本不用关心程序的内存管理行为。然而,虽然ARC已经帮助我们解决了引用计数的大部分问题,但一些年轻的iOS开发者仍然在内存管理上犯了难。他们甚至不了解导致内存泄漏的常见循环引用问题,这些问题最终会减慢应用程序速度或被系统终止进程。因此,我们每个iOS开发者都需要了解引用计数的内存管理方式。只有这样才能处理内存管理相关的问题。什么是引用计数引用计数(ReferenceCount)是一种简单有效的管理对象生命周期的方法。当我们创建一个新对象时,它的引用计数为1。当一个新的指针指向这个对象时,我们将它的引用计数加1。当一个指针不再指向这个对象时,我们将它的引用计数减1。当对象的引用计数变为0时,意味着该对象不再被任何指针所指向。这时候我们可以销毁对象,回收内存。因为引用计数简单有效,除了Objective-C和Swift语言外,微软的COM(ComponentObjectModel)、C++11(C++11提供了基于引用计数的智能指针share_prt)等语言还提供了基于引用Count的内存管理方法。为了更形象,我们来看一段Objective-C的代码。新建一个项目,因为现在默认项目启用了自动引用计数ARC(AutomaticReferenceCount),我们首先修改项目设置,在AppDelegate.m中添加-fno-objc-arc编译参数(如下图所示)),此参数启用引用计数模式的手动管理。然后,我们在里面输入如下代码,我们可以通过Log看到引用计数的相应变化。-(BOOL)application:(UIApplication*)applicationdidFinishLaunchingWithOptions:(NSDictionary*)launchOptions{NSObject*object=[[NSObjectalloc]init];NSLog(@"ReferenceCount=%u",[objectretainCount]);NSObject*另一个=[objectretain]];NSLog(@"ReferenceCount=%u",[objectretainCount]);[anotherrelease];NSLog(@"ReferenceCount=%u",[objectretainCount]);[objectrelease];//到了这里,内存的对象为ReleasereturnYES;}运行结果:ReferenceCount=1ReferenceCount=2ReferenceCount=1熟悉Linux文件系统的同学可能会发现引用计数的管理方式类似于文件系统中的硬链接。在Linux文件系统中,我们可以使用ln命令来创建硬链接(相当于我们这里的retain)。当删除文件时(相当于我们这里的release),系统调用会检查文件的链接计数值。如果大于1,文件占用的磁盘区域将不被回收。直到最后一次删除,系统发现链接计数值为1时,系统会直接执行删除操作,并将该文件占用的磁盘区域标记为未使用。为什么需要引用计数从上面这个简单的例子,我们还是看不出引用计数的真正用途。因为对象的生命只存在于一个函数中,所以在实际应用场景中,我们在函数中使用一个临时对象,通常不需要修改它的引用计数,只需要在函数返回前调用该对象就可以销毁它.引用计数真正派上用场的场景是在面向对象的编程架构中,用于在对象之间传递和共享数据。举个具体的例子:如果对象A生成一个对象M,需要调用对象B的一个方法,将对象M作为参数传入。在没有引用计数的情况下,内存管理的一般原则是“谁申请释放”,那么当对象B不再需要对象M时,对象A需要销毁对象M。但是对象B可能只是暂时使用对象M,或者它可能觉得对象M很重要,把它设置为自己的成员变量。在这种情况下,何时销毁对象M就成了一个问题。针对这种情况,有一种暴力的做法,就是对象A调用对象B后,立即销毁参数对象M,然后对象B需要再复制一份参数生成另一个对象M2,然后管理对象M2本身的生命周期。但是这种方式有一个很大的问题,就是带来了更多的内存申请、复制、释放工作。本来是一个可重用的对象,因为不方便管理它的生命周期,干脆销毁它,重建一样的,确实很影响性能。如下图所示:我们还有另外一种方法,即对象A在构造完对象M后,永远不会销毁对象M,对象B完成对象M的销毁。如果对象B需要长期使用对象M,它不会破坏它。如果只是暂时使用,使用后可立即销毁。这种方式看似很好的解决了对象复制的问题,但是它强烈依赖于AB两个对象的协作,代码维护者需要清楚地记住这个编程约定。而且由于对象M在对象A中申请,在对象B中释放,其内存管理代码分散在不同的对象中,管理起来非常费力。如果此时情况比较复杂,比如对象B需要将对象M传递给对象C,那么这个对象在对象C中是不能被对象C管理的。因此,这种方式带来的复杂度更大,并且更不可取。所以引用计数很好的解决了这个问题。在传递参数M的过程中,哪些对象需要长期使用这个对象,将其引用计数加1,使用完毕再将引用计数减1。如果所有的对象都遵守这个规则,那么对象的生命周期管理就可以完全交给引用计数了。我们也可以轻松享受共享对象带来的好处。不要向已释放的对象发送消息。有同学想测试释放对象时retainCount是否变为0。他们的测试代码如下:-(BOOL)application:(UIApplication*)applicationdidFinishLaunchingWithOptions:(NSDictionary*)launchOptions{NSObject*object=[[NSObjectalloc]init];NSLog(@"ReferenceCount=%u",[objectretainCount]);[objectrelease];NSLog(@"ReferenceCount=%u",[objectretainCount]);returnYES;}但是,如果真的这样试验的话,得到的输出可能是这样的:ReferenceCount=1ReferenceCount=1我们注意到最后的输出,引用计数并没有变成0。这是为什么呢?因为对象的内存已经被回收了,而我们给一个被回收的对象发送了一个retainCount消息,它的输出应该是不确定的。如果对象占用的内存被重用,那么可能会导致程序异常崩溃。那为什么对象被回收后这个不确定值是1而不是0呢?这是因为在最后一次执行release的时候,系统知道内存很快会被回收,所以不需要将retainCount减1。因为不管减1还是不减1,对象肯定会被回收,而对象被回收后,它所有的内存区域,包括retainCount值,都变得没有意义了。不将该值从1更改为0可以减少内存写入操作并加快对象恢复。以我们之前提到的Linux文件系统为例。Linux文件系统下删除一个文件,实际上并没有擦除该文件的磁盘区域,只是删除了该文件的inode号。这也类似于引用计数的内存回收方式,即回收时只做标记,不擦除相关数据。ARC下的内存管理问题ARC可以解决iOS开发中90%的内存管理问题,但是还有10%的内存管理问题需要开发者自己处理。这主要是与底层CoreFoundation对象交互的部分。因为ARC的CoreFoundation对象不在ARC的管理之下,所以需要自己维护这些对象的引用计数。对于一味依赖ARC的iOS新手,由于不知道引用计数,他们的问题主要体现在:过度使用block后,无法解决循环引用问题。当遇到底层的CoreFoundation对象需要手动管理它们的引用计数时,显得很无奈。循环引用(ReferenceCycle)问题虽然引用计数是一种简单的内存管理方式,但是它有一个比较大的缺陷,就是不能很好的解决循环引用问题。如下图所示:对象A和对象B作为自己的成员变量相互引用,只有销毁时,成员变量的引用计数才会减1。因为对象A的销毁依赖于对象B的销毁,而对象B的销毁依赖于对象A的销毁,这就造成了我们所说的循环引用(ReferenceCycle)。任何指针都可以访问它们,并且它们不能被释放。不仅两个对象存在循环引用问题,多个对象依次相互持有,形成一个环,同样会造成循环引用问题,而且在真实的编程环境中,环越大越难找。下图是由4个对象组成的循环引用问题。解决循环引用问题的方法主要有两种。第一种方法是知道这里会出现循环引用,在合理的位置主动断开环中的一个引用,这样就可以回收对象了。如下图所示:主动打破循环引用的方法在各种与块相关的代码逻辑中很常见。比如我们公司开源的YTKNetwork网络库中,持有网络请求的回调块,但是如果这个块中有对ViewController的引用,很容易产生循环引用,因为:Controller持有网络请求对象网络请求对象持有回调块。在回调块中使用了Self,因此持有控制器。解决方案是在网络请求结束,网络请求对象执行完区块后,主动释放持有的区块。为了打破循环引用。相关代码见://https://github.com/yuantiku/YTKNetwork/blob/master/YTKNetwork/YTKBaseRequest.m//第147行:-(void)clearCompletionBlock{//主动释放对blockself.successCompletionBlock的引用=nil;self.failureCompletionBlock=nil;}然而,主动断开循环引用的操作依赖于程序员的手动显式控制,相当于回到了“谁申请谁释放”的内存管理时代。依靠程序员自己的能力去发现循环引用,知道什么时候打破循环引用来回收内存(这通常和具体的业务逻辑有关),所以这种方案不常用,更常用的方式是使用弱引用(弱参考)方法。弱引用虽然持有对象,但是并没有增加引用计数,从而避免了循环引用的产生。在iOS开发中,弱引用通常用在委托模式中。比如有两个ViewControllerA和B,ViewControllerA需要弹出ViewControllerB让用户输入一些内容。当用户输入完成后,ViewControllerB需要将内容返回给ViewControllerA。这时候ViewController的delegate成员变量通常是弱引用,避免两个ViewController相互引用造成的循环引用问题,如下图:使用Xcode检测循环引用Xcode的Instruments工具集可以轻松检测循环引用。为了测试效果,我们在一个测试ViewController中填写如下代码,其中firstArray和secondArray相互引用,形成循环引用。-(void)viewDidLoad{[superviewDidLoad];NSMutableArray*firstArray=[NSMutableArrayarray];NSMutableArray*secondArray=[NSMutableArrayarray];[firstArrayaddObject:secondArray];[secondArrayaddObject:firstArray];}在Xcode菜单栏中选择:Product->Profile,然后选择“Leaks”,然后点击右下角的“Profile”按钮开始检测。如下图,此时会运行iOS模拟器,我们将在模拟器中进行一些界面切换操作。等待几秒钟,看看Instruments是否检测到我们的循环引用。Instruments中将使用红色条来指示内存泄漏的发生。如下图所示:我们可以切换到Leaks栏目,点击“Cycles&Roots”,就可以看到图形化显示的循环引用了。这样我们就很容易找到被循环引用的对象。CoreFoundation对象的内存管理下面简单介绍下CoreFoundation对象的内存管理。CoreFoundation底层对象多采用XxxCreateWithXxx方式创建,例如://创建一个CFStringRef对象CFStringRefstr=CFStringCreateWithCString(kCFAllocatorDefault,"helloworld",kCFStringEncodingUTF8);//创建一个CTFontRef对象CTFontReffontRef=CTFontCreateWithName((CFStringRef)@"ArialMT",fontSize,NULL);要修改这些对象的引用计数,请相应地使用CFRetain和CFRelease方法。如下://创建一个CTFontRef对象CTFontReffontRef=CTFontCreateWithName((CFStringRef)@"ArialMT",fontSize,NULL);//引用计数加1CFRetain(fontRef);//引用计数减1CFRelease(fontRef);对于CFRetain和CFRelease这两个方法,读者可以直观的认为这相当于Objective-C对象的retain和release方法。所以对于底层的CoreFoundation对象,我们只需要继续之前手动管理引用计数的方法即可。除此之外,还有一个问题需要解决。在ARC下,我们有时需要将CoreFoundation对象转换为Objective-C对象。这时候我们需要告诉编译器在转换过程中如何调整引用计数。这引入了与桥相关的关键字。以下是这些关键字的说明:__bridge:只进行类型转换,不修改相关对象的引用计数。当原来的CoreFoundation对象不用时,需要调用CFRelease方法。__bridge_retained:类型转换后,相关对象的引用计数加1。当原来的CoreFoundation对象没有被使用时,需要调用CFRelease方法。__bridge_transfer:类型转换后,将对象的引用计数交给ARC管理。当不使用CoreFoundation对象时,不再需要调用CFRelease方法。根据具体的业务逻辑,合理使用以上三个转换关键字,就可以解决CoreFoundation对象和Objective-C对象之间的相对转换问题。总结在ARC的帮助下,iOS开发者的内存管理工作大大减少,但是我们仍然需要了解引用计数这种内存管理方式的优势和常见问题,特别注意解决循环引用问题。循环引用问题的解决方案主要有两种,一种是主动打破循环引用,另一种是使用弱引用来避免循环引用。对于CoreFoundation对象,由于不在ARC管理下,我们还是需要继续之前手动管理引用计数的方法。在调试内存问题的时候,Instruments工具可以很好的辅助我们,用好Instruments可以为我们节省很多调试时间。希望每一位iOS开发者都能掌握iOS的内存管理技巧。