iOS性能调优之--内存管理

群组
Flutter,iOS,Android,Python等,闲聊工作&技术&问题;
个人主页:https://waitwalker.cn
telegram群链接:https://t.me/joinchat/Ej0o0A1ntlq5ZFIMzzO5Pw

前言

        iOS内存管理无论是早期的MRC还是现在的ARC本质都是通过引用计数(Reference Counting)机制管理内存,当一个对象被创建出来时,它的引用计数从0到1,当有外部对象对它进行强引用时,它的应用计数会+1,当该对象收到一条release消息时,它的引用计数会-1;当对象的引用计数为0时,对象将被释放,对象指向的内存被回收.

1. ARC内存管理的本质

        MRC时代需要程序员手动管理对象的生命周期,也就是对象的引用计数有程序员来控制,什么时候retain,什么时候release,完全自己掌握.ARC(Automatic Reference Counting)自动引用计数是编译器的一个特性,能够自动管理OC对象内存生命周期.在ARC中你需要专注于写你的代码, retain ,release, autorelease操作交给编译器去处理就行了.
MRC_ARC_示意图_来源_Apple_Document.jpg
        ARC 下编译器如何自动管理内存,其中,能想到的是在类的 dealloc 方法中,对该类的所持有的成员变量(strong)执行 release 操作,让所有成员变量的引用计数为0。对于局部变量,更可能是的对象在出作用域之前,编译器自动给对象加上一条 release消息.这些工作都是编译器为我们处理了.

// 作用域
    {
        NSString *str = [[NSString alloc]initWithFormat:@"%@",@"str"];

        NSLog(@"%@",str);

        // 在对象出作用域时,编译器自动给对象发一条release消息
        [str release];
    }

        ARC,则无需我们自己显式持有(retain)和释放(release)对象,ARC通过对对像加上所有权修饰符(__strong等),编译器通过对象的所有权修饰符将会自动管理对象的引用计数.

2. 所有权修饰符

基础知识:指针是其实也是一个对象,它指向一个内存地址单元,内存单元里存着各种变量.这样指针就可以指向这样变量,当我们用的时候我们就可以从内存单元取出变量内容.
        Objective-C对象的ARC是通过所有权修饰符来管理对象的持有和释放。所有权修饰符一共有4种:

2.1 __strong 修饰符

        默认的修饰符,只要有一个强指针指向这个对象,这个对象就一直不会销毁,这个对象指向的指针也不会置为NULL.

//这里person_one 可以理解为一个指针 指向 Person创建的出来的对象(指针)的内存,可以读取内存上的内容
    Person * __strong person_one = [[Person alloc]init];

    Person * __strong person_two = person_one;

    person_one = nil;

    NSLog(@"person_one:%@,person_one地址:%p",person_one,person_one);
    NSLog(@"person_two:%@,person_two地址:%p",person_two,person_two);

Log:
2018-03-19 16:19:09.822168 TestARC[16592:5864784] person_one:(null),person_one地址:0x0
2018-03-19 16:19:22.443524 TestARC[16592:5864784] person_two:<Person: 0x17001e450>,person_two地址:0x17001e450

strong所有权修饰.png

        我们可以看到,person_two是person_one的浅拷贝对象,也就是指针拷贝对象,而person_two是通过__strong修饰,相当于强指针,指向的是与person_one一块内存区域.而这块内存区域被retain了两次,引用计数为2,即使person_one = nil将引用计数-1了,person_two依然可以打印出内存地址.person_one的指针已经被置为NULL,所以打印出的地址是0x0.

2.2 __weak 修饰符

        当没有强指针指向弱引用的对象时,弱引用的对象将被置为nil,对象的指针置为NULL.

//这里person_one 可以理解为一个指针 指向 Person创建的出来的对象(指针)的内存,可以读取内存上的内容
    Person * __strong person_one = [[Person alloc]init];

    Person * __weak person_two = person_one;

    person_one = nil;

    NSLog(@"person_one:%@,person_one地址:%p",person_one,person_one);
    NSLog(@"person_two:%@,person_two地址:%p",person_two,person_two);
Log:
2018-03-19 16:28:21.453255 TestARC[16599:5866487] person_one:(null),person_one地址:0x0
2018-03-19 16:28:25.521762 TestARC[16599:5866487] person_two:(null),person_two地址:0x0

weak所有权修饰.png
        我们知道__weak修饰的对象不会对对象进行retain,所以person_two指向的内存区域对象引用计数还是1.这里只有person_one强引用那块内存区域,当person_one = nil时,引用计数为0,内存区域被释放,person_two指向的内存地址为:0x0.

2.3 __unsafe_unretained 修饰符

        就像其表面意思一样:当没有强指针指向__unsafe_unretained修饰的对象时,这个对象会被置为nil,但是指向对象的指针不会被清空,苹果官方: the pointer is left dangling.

//这里person_one 可以理解为一个指针 指向 Person创建的出来的对象(指针)的内存,可以读取内存上的内容
    Person * __strong person_one = [[Person alloc]init];

    Person * __unsafe_unretained person_two = person_one;

    person_one = nil;

    NSLog(@"person_one:%@,person_one地址:%p",person_one,person_one);
    NSLog(@"person_two:%@,person_two地址:%p",person_two,person_two);
Log:
2018-03-19 16:42:52.400375 TestARC[16608:5869804] person_one:(null),person_one地址:0x0
这里已经报错:Thread 1: EXC_BAD_ACCESS (code=1, address=0xb84d2beb8)

unsafe__unretained所有权修饰.png
        这里我们在主线程中收到一条崩溃信息(EXC_BAD_ACCESS),通过unsafe_unretained官方文档解释,我们可以猜出address=0xb84d2beb8应该是person_one没被置为nil之前的内存地址,而当person_one = nil时,这块内存已经被回收,而person_two因为被unsafe_unretained修饰,其指针还没有被销毁,还想指向这块内存地址,所以造成了野指针错误.

2.4 __autoreleasing 修饰符

        autorelease 本质上就是延迟调用 release,这里不做细致的分析了,大家感兴趣的可以自己找相关资料查看.
        到这里我们对ARC的引用计数管理应该有了大概的了解.

3. 源码分析

        引用计数的实现,我们可以通过查看苹果的源码(https://opensource.apple.com/source/objc4/).我们下面主要来看看retain的实现源码,我们可以在OC的鼻祖类--NSObject中可以看到协议NSObject中定义的几个方法:

- (instancetype)retain OBJC_ARC_UNAVAILABLE;
- (oneway void)release OBJC_ARC_UNAVAILABLE;
- (instancetype)autorelease OBJC_ARC_UNAVAILABLE;
- (NSUInteger)retainCount OBJC_ARC_UNAVAILABLE;

        以上方法,就是编译器在合适的时机给对象所要发送的消息.我们点进去retain方法,我们可以在NSObject.mm文件的2138行可以看到其实现:

// Replaced by ObjectAlloc
- (id)retain {
    return ((id)self)->rootRetain();
}

        沿着调用链,我们可以在objc-object.h文件中看到id rootRetain(bool tryRetain, bool handleOverflow)方法的实现:

LWAYS_INLINE id
objc_object::rootRetain(bool tryRetain, bool handleOverflow)
{
    assert(!UseGC);
    if (isTaggedPointer()) return (id)this;

    bool sideTableLocked = false;
    bool transcribeToSideTable = false;

    isa_t oldisa;
    isa_t newisa;

    do {
        transcribeToSideTable = false;
        oldisa = LoadExclusive(&isa.bits);
        newisa = oldisa;
        if (!newisa.indexed) goto unindexed;
        // don't check newisa.fast_rr; we already called any RR overrides
        if (tryRetain && newisa.deallocating) goto tryfail;
        uintptr_t carry;
        newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc++

        if (carry) {
            // newisa.extra_rc++ overflowed
            if (!handleOverflow) return rootRetain_overflow(tryRetain);
            // Leave half of the retain counts inline and
            // prepare to copy the other half to the side table.
            if (!tryRetain && !sideTableLocked) sidetable_lock();
            sideTableLocked = true;
            transcribeToSideTable = true;
            newisa.extra_rc = RC_HALF;
            newisa.has_sidetable_rc = true;
        }
    } while (!StoreExclusive(&isa.bits, oldisa.bits, newisa.bits));

    if (transcribeToSideTable) {
        // Copy the other half of the retain counts to the side table.
        sidetable_addExtraRC_nolock(RC_HALF);
    }

    if (!tryRetain && sideTableLocked) sidetable_unlock();
    return (id)this;

 tryfail:
    if (!tryRetain && sideTableLocked) sidetable_unlock();
    return nil;

 unindexed:
    if (!tryRetain && sideTableLocked) sidetable_unlock();
    if (tryRetain) return sidetable_tryRetain() ? (id)this : nil;
    else return sidetable_retain();
}

        最后一行sidetable_retain(),这个也是retain方法的最终调用的方法.而sidetable_retain()的实现:

id
objc_object::sidetable_retain()
{
#if SUPPORT_NONPOINTER_ISA
    assert(!isa.indexed);
#endif
    SideTable& table = SideTables()[this];

    if (table.trylock()) {
        size_t& refcntStorage = table.refcnts[this];
        if (! (refcntStorage & SIDE_TABLE_RC_PINNED)) {
            refcntStorage += SIDE_TABLE_RC_ONE;
        }
        table.unlock();
        return (id)this;
    }
    return sidetable_retain_slow(table);
}

        我们可以看到这个方法中SideTable这个结构体,

struct SideTable {
    spinlock_t slock;
    RefcountMap refcnts;
    weak_table_t weak_table;

    SideTable() {
        memset(&weak_table, 0, sizeof(weak_table));
    }

    ~SideTable() {
        _objc_fatal("Do not delete SideTable.");
    }

    void lock() { slock.lock(); }
    void unlock() { slock.unlock(); }
    bool trylock() { return slock.trylock(); }

    // Address-ordered lock discipline for a pair of side tables.

    template<bool HaveOld, bool HaveNew>
    static void lockTwo(SideTable *lock1, SideTable *lock2);
    template<bool HaveOld, bool HaveNew>
    static void unlockTwo(SideTable *lock1, SideTable *lock2);
};

        其中的 RefcountMap 应该就是引用计数哈希表,而weak_table_t则是弱引用表(weak table).
        RefcountMap 则是一个简单的 map,其 key 为 object 内存地址,value 为引用计数值.通过SideTable源码,还可以得出如下结论:
        存在全局的若干个SideTable实例,它们保存在 static 成员变量table_buf中;
        程序运行过程中生成的所有对象都会通过其内存地址映射到table_buf中相应的        SideTable实例上.这里之所以会存在多个SideTable实例,object 映射到不同SideTable实例上,猜测是出于性能优化的目的,避免SideTable中的 reference table、weak table 过大.
        回到上面的sidetable_retain方法,其首先通过 object 的地址找到对应的 sidetale,然后通过 RefcountMap将该 object 的引用计数加1.简单地说,Apple 通过全局的 map 来记录Reference Counting,其key 为 object 地址,value 为引用计数值。
        release、retainCount等相关方法的代码在该开源代码中也能找到,这里不细说了.

4. ARC开发环境需要注意的管理内存:

4.1CoreFoundation,Runtime以及其他C语言库的使用

        通过malloc,create,copy等创建对象,还需要手动释放.

4.2 循环引用

        循环引用是两个或多个对象之间相互持有,形成环状,即使在没有外部对象指针指向这些对象内存区域(堆区)的时候,系统无法将每个对象的引用计数置为0,从而导致这些开辟出来的内存一直发挥着”占着茅坑不拉屎”的作用.这部分不容易检测,也容易背锅.不管新老司机遇到问题不假思索:循环引用的问题(所以遇到问题的时候,我们更多的是多思考,而不是在没有分析问题的情况下脱口而出,不仅误导别人,而且显得自己很水,多说了两句,见笑).
循环引用示意图.png

5. 内存管理检测

5.1 Analyze静态分析

        静态内存分析, 指的是在程序没运行的时候, 通过预编译对代码进行预判断分析,分析代码的基本数据结构,语法等,编译器检查是否存在潜在的内存泄露及不规范的地方.常遇到问题:
        1)The ‘viewWillDisappear:’ instance method in UIViewController subclass ‘xxxxx’ is missing a [super viewWillDisappear:] call;这个错误提示是:重写父类中的实例方法viewWillDisappear,没有在子类中调用,从下图我们可以看到确实是这样,-(void)viewWillDisappear:(BOOL)animated方法内部调用的是[super viewDidAppear:animated];这种是很低级的错误.
Analyze_1.png
        2)Value stored to ‘xxxxx’ is never read,声明的变量没有被用到
Analyze_2.png
        3) API Misuse 接口应用错误,这里主要针对的是系统提供的接口
从下图中我们可以看到,_cachedStatements是一个字典,字典是不允许出现nil对象的,所以存数据之前我们要做容错判断.
Analyze_3_1.png
        改完后就不再提示了
Analyze_3_2.png
        4)Memory error,内存错误:nil returned from a method that is expected to return a non-null value,方法返回中需要一个对象(指针),你返回了一个空指针.例如,下图在UITableView的数据源回调方法返回cell的方法中,本应返回一个UITableViewCell对象,可是这里返回了一个nil对象(空指针)
Analyze_4.png
        还存在其他潜在问题错误或者不规范的地方,大家可以照着这个自己去查找一下自己的项目.

5.2 Instruments内存泄露检测

        Instruments内存分析你应用内存的使用情况,帮助你查找定位出现问题的代码区域.详细介绍可以参考apple developer documentation(https://developer.apple.com/library/content/documentation/DeveloperTools/Conceptual/InstrumentsUserGuide/CommonMemoryProblems.html#//apple_ref/doc/uid/TP40004652-CH91-SW1)
从文档中我们大概可以看到,一个应用所使用的内存可能占三种:

Leaked memory: Memory unreferenced by your application that cannot be used again or freed (also detectable by using the Leaks instrument).
泄露的内存:应用无法再次应用或者释放的内存.
Abandoned memory: Memory still referenced by your application that has no useful purpose.
废弃的内存:你的应用还占据着这块内存,但是这块内存无法释放了,ARC中最有可能的是循环引用.
Cached memory: Memory still referenced by your application that might be used again for better performance.
缓存的内存:能够被你的应用正常释放回收利用的内存.
内存泄露:如果程序运行时一直分配内存而不及时释放无用的内存,程序占用的内存越来越大,直到把系统分配给该APP的内存消耗殚尽,程序因无内存可用导致崩溃,这样的情况我们称之为内存泄漏。可能引起的问题:
1)内存消耗殆尽的时候,程序会因没有内存被杀死,即crash。
2)当内存快要用完的时候,会非常的卡顿
3)如果是ViewController没有释放掉,引起的内存泄露,还会引起其他很多问题,尤其是和通知相关的。没有被释放掉的ViewController还能接收通知,还会执行相关的动作,所以会引起各种各样的异常情况的发生。

        以我们现在开发的项目为例:这里打个广告,我们现在开发的应用叫做爱学.横版主要有我的班级,自学,消息,设置等模块,下面我们用Instruments来检查一下:
        1)打开调试工具步骤:首先先将待检测的源码安装到你的真机设备上(Command + r 或者 直接Run运行);然后按着快捷键:Command + Control + i,打开Instruments,选择Leaks.
        2)定位内存泄露区域
        我们选择call_tree,也就是函数调用栈,顺藤摸瓜,找到内存泄露的地方
call_tree.png
memory_leak.png
        不出意外,就可以看到具体内存泄露的代码了,我们这里是由于使用Runtime了,调用了class_copyPropertyList方法.我们知道Runtime是OC的底层,是OC的幕后工作者,所写的OC代码最终都转换成Runtime的C代码执行.这里通过class_copyPropertyList方法来获取类的所有成员变量的时候,没有释放.所以在使用C语言相关库的时候,一定要做好释放工作(不然装B就装大了😄,玩笑).最终在使用遍历完类中的成员变量后,free(properties);就没问题了.

-(NSArray *)modelInfo:(Class)cls
{
    unsigned int  count = 0;
    objc_property_t  * properties= class_copyPropertyList(cls, &count);
    NSMutableArray  * infoarr = [NSMutableArray new];
    for (int i = 0; i<count; i++)
    {
        objc_property_t property = properties[i];
        NSString * name = [[NSString alloc]initWithCString:property_getName(property) encoding:NSUTF8StringEncoding ];

        [infoarr addObject:name];
    }
    free(properties);
    return infoarr;
}

        我们的学习任务中一个视频类型的任务,视频播放器估计是从网上找的别人封装好的,没有细致分析就用了.从下图中我们可以看到至少有三个环,我们需要打破这种环状,消除引用循环,这里不细说,大家可以根据需要去详细看看怎么处理引用循环.
retain_recycle_1.png
retain_recycle_2.png
retain_recycle_3.png

总结

        文中简单介绍了iOS内存管理的相关内容,主要的还是ARC相关内容,这些大都是基于实际开发中的总结和平时学习的积累,里面不乏一些错误和不规范之处,希望没有没有大家没有被误导,更希望大家多给意见和建议.其实,基础知识扎牢了,对一些问题的理解,解决可能也会更加游刃有余,而不是天天纠结于一些”界面”上的问题.

参考文献

https://blog.devtang.com/2016/07/30/ios-memory-management/
https://developer.apple.com/library/content/documentation/DeveloperTools/Conceptual/InstrumentsUserGuide/FindingLeakedMemory.html
http://clang.llvm.org/docs/AutomaticReferenceCounting.html


  目录