阅读 870

iOS底层学习 - 内存管理之内存管理方案

不同的系统版本对 App 运行时占用内存的限制不同,超过限制时,App就会被强制杀死,所以对于内存的要求也就越来越高,所以本章来探索一下iOS中的内存管理方案

移动端的内存管理技术,主要有 GC(Garbage Collection,垃圾回收)的标记清除算法和苹果公司使用的引用计数方法

相比较于 GC 标记清除算法,引用计数法可以及时地回收引用计数为 0 的对象,减少查找次数。但是,引用计数会带来循环引用的问题,比如当外部的变量强引用 Block 时,Block 也会强引用外部的变量,就会出现循环引用。我们需要通过弱引用,来解除循环引用的问题。

另外,在 ARC(自动引用计数)之前,一直都是通过 MRC(手动引用计数)这种手写大量内存管理代码的方式来管理内存,因此苹果公司开发了 ARC 技术,由编译器来完成这部分代码管理工作。但是,ARC 依然需要注意循环引用的问题。当 ARC 的内存管理代码交由编译器自动添加后,有些情况下会比手动管理内存效率低,所以对于一些内存要求较高的场景,我们还是要通过 MRC 的方式来管理、优化内存的使用。

内存布局

首先我们再来回顾一下,OS/iOS系统的内存布局

在iOS程序的内存中,从底地址开始,到高地址一次分为:程序区域、数据区域、堆区、栈区。其中程序区域主要是代码段,数据区域包括数据段和BSS段。我们具体分析一下各个区域所代表的含义

  • 栈区(stack):编译器自动分配,由系统管理,在不需要的时候自动清除。局部变量、函数参数存储在这里。栈区的内存地址一般是0x7开头,从高地址到底地址分配内存空间
  • 堆区 (heap): 那些由newallocblockcopy创建的对象存储在这里,是由开发者管理的,需要告诉系统什么时候释放内存。ARC下编译器会自动在合适的时候释放内存,而在MRC下需要开发者手动释放。堆区的内存地址一般是0x6开头,从底地址到高地址分配内存空间
  • _DATA区:主要分为BSS(静态区)和数据段(常量区)
    • BSS(静态区):BSS段又称静态区,未初始化的全局变量,静态变量存放在这里。一旦初始化就会被回收,并且将数据转存到数据段中。
    • 数据段(常量区):数据段又称常量区,专门存放常量,直到程序结束的时候才会被回收。
  • 代码段:用于存放程序运行时的代码,代码会被编译成二进制存进内存的程序代码区。程序结束时系统会自动回收存储在代码段中的数据。

小问题:static静态变量的作用域

首先先查看下面代码,age的打印结果是多少呢?

static int age = 10;

@interface Person : NSObject
-(void)add;
+(void)reduce;
@end

@implementation Person

- (void)add {
    age++;
    NSLog(@"Person内部:%@-%p--%d", self, &age, age);
}

+ (void)reduce {
    age--;
    NSLog(@"Person内部:%@-%p--%d", self, &age, age);
}
@end


@implementation Person (WY)

- (void)wy_add {
    age++;
    NSLog(@"Person (wy)内部:%@-%p--%d", self, &age, age);
}

@end

- (void)viewDidLoad {
    [super viewDidLoad];

    NSLog(@"vc:%p--%d", &age, age);
    age = 40;
    NSLog(@"vc:%p--%d", &age, age);
    [[Person new] add];
    NSLog(@"vc:%p--%d", &age, age);
    [Person reduce];
    NSLog(@"vc:%p--%d", &age, age);
    [[Person new] wy_add];
}
复制代码

通过打印结果,我们可以得出这么一个结论:

静态变量的作用域与对象、类、分类没关系,只与文件有关系。

内存优化方案

iOS除了使用ARC来进行自动引用计数以外,还有一些其他的内存优化方案,主要有Tagged Ponter,NONPOINTER_ISA,SideTable3种

Tagged Ponter

概述

在 2013 年 9 月,苹果推出了 iPhone5s,与此同时,iPhone5s 配备了首个采用 64 位架构的 A7 双核处理器,为了节省内存和提高执行效率,苹果提出了Tagged Pointer的概念。

使用Tagged Pointer之前,如果声明一个NSNumber *number = @10;变量,需要一个占8字节的指针变量number,和一个占16字节的NSNumber对象,指针变量number指向NSNumber对象的地址。这样需要耗费24个字节内存空间。

而使用Tagged Pointer之后,NSNumber指针里面存储的数据变成了:Tag + Data,也就是将数据直接存储在了指针中。直接将数据10保存在指针变量number中,这样仅占用8个字节。

但是当指针不够存储数据时,就会使用动态分配内存的方式来存储数据。

通过下面面试题的打印结果可以很好的反映出来

    //第1段代码
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    for (int i = 0; i < 1000; i++) {
        dispatch_async(queue, ^{
            self.name = [NSString stringWithFormat:@"asdasdefafdfa"];
        });
    }
    NSLog(@"end");
复制代码
     //第2段代码
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    for (int i = 0; i < 1000; i++) {
        dispatch_async(queue, ^{
            self.name = [NSString stringWithFormat:@"abc"];
        });
    }
    NSLog(@"end");
复制代码

上面两段代码的打印结果过是: 第1段会发生崩溃,而第2段不会。

按道理讲,创建多个线程来对name进行操作时,name的值会不断的retainrelease,此时就会出现资源竞争而崩溃,但是第二段却不会崩溃,说明在Tagged Pointer下,较小的值,不会调用set和get等方法,由于其值是直接存储在指针变量中的,所以可以直接修改。

通过源码,我们也可以比较直观的看出,Tagged Pointer类型对象是直接返回的。

总结一下使用Tagged Pointer的好处

  • Tagged Pointer是专⻔⽤来存储⼩的对象,例如NSNumberNSDate等。
  • Tagged Pointer指针的值不再是地址了,⽽是真正的值。所以,实际上它不再是⼀个对象了,它只是⼀个披着对象⽪的普通变量⽽已。所以,它的内存并不存储在堆中,也不需要mallocfree
  • 当指针不够存储数据时,就会使用动态分配内存的方式来存储数据。
  • 在内存读取上有着3倍的效率,创建时⽐以前快106倍。

源码

static inline void * _Nonnull
_objc_encodeTaggedPointer(uintptr_t ptr)
{
    return (void *)(objc_debug_taggedpointer_obfuscator ^ ptr);
}

static inline uintptr_t
_objc_decodeTaggedPointer(const void * _Nullable ptr)
{
    return (uintptr_t)ptr ^ objc_debug_taggedpointer_obfuscator;
}

static inline void * _Nonnull
_objc_makeTaggedPointer(objc_tag_index_t tag, uintptr_t value)
{
    if (tag <= OBJC_TAG_Last60BitPayload) {
        uintptr_t result =
            (_OBJC_TAG_MASK | 
             ((uintptr_t)tag << _OBJC_TAG_INDEX_SHIFT) | 
             ((value << _OBJC_TAG_PAYLOAD_RSHIFT) >> _OBJC_TAG_PAYLOAD_LSHIFT));
        return _objc_encodeTaggedPointer(result);
    } else {
        uintptr_t result =
            (_OBJC_TAG_EXT_MASK |
             ((uintptr_t)(tag - OBJC_TAG_First52BitPayload) << _OBJC_TAG_EXT_INDEX_SHIFT) |
             ((value << _OBJC_TAG_EXT_PAYLOAD_RSHIFT) >> _OBJC_TAG_EXT_PAYLOAD_LSHIFT));
        return _objc_encodeTaggedPointer(result);
    }
}

复制代码

从上面的这代码可以看出来,系统调用了_objc_decodeTaggedPointer_objc_taggedPointersEnabled这两个方法对于taggedPointer对象的指针进行了编码和解编码,这两个方法都是将指针地址和objc_debug_taggedpointer_obfuscator进行异或操作

我们都知道将a和b异或操作得到c再和a进行异或操作便可以重新得到a的值,通常可以使用这个方式来实现不用中间变量实现两个值的交换。Tagged Pointer正是使用了这种原理。通过这种解码的方法,我们可以得到对象真正的指针地址

下面是系统定义的各种Tagged Pointer标志位。

enum objc_tag_index_t : uint16_t
{
    // 60-bit payloads
    OBJC_TAG_NSAtom            = 0, 
    OBJC_TAG_1                 = 1, 
    OBJC_TAG_NSString          = 2, 
    OBJC_TAG_NSNumber          = 3, 
    OBJC_TAG_NSIndexPath       = 4, 
    OBJC_TAG_NSManagedObjectID = 5, 
    OBJC_TAG_NSDate            = 6,

    // 60-bit reserved
    OBJC_TAG_RESERVED_7        = 7, 

    // 52-bit payloads
    OBJC_TAG_Photos_1          = 8,
    OBJC_TAG_Photos_2          = 9,
    OBJC_TAG_Photos_3          = 10,
    OBJC_TAG_Photos_4          = 11,
    OBJC_TAG_XPC_1             = 12,
    OBJC_TAG_XPC_2             = 13,
    OBJC_TAG_XPC_3             = 14,
    OBJC_TAG_XPC_4             = 15,

    OBJC_TAG_First60BitPayload = 0, 
    OBJC_TAG_Last60BitPayload  = 6, 
    OBJC_TAG_First52BitPayload = 8, 
    OBJC_TAG_Last52BitPayload  = 263, 

    OBJC_TAG_RESERVED_264      = 264
};

复制代码

NONPOINTER_ISA

关于NONPOINTER_ISA我们在之前的博客中有讲过,这也是苹果的一种内存优化的方案。用 64 bit 存储一个内存地址显然是种浪费,毕竟很少有那么大内存的设备。于是可以优化存储方案,用一部分额外空间存储其他内容。isa 指针第一位为 1 即表示使用优化的 isa 指针。可以阅读☞iOS底层学习 - OC对象前世今生

union isa_t {
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }

    Class cls;
    uintptr_t bits;
#if defined(ISA_BITFIELD)
    struct {
      uintptr_t nonpointer        : 1;                                         
      uintptr_t has_assoc         : 1;                                         
      uintptr_t has_cxx_dtor      : 1;                                         
      uintptr_t shiftcls          : 44; /*MACH_VM_MAX_ADDRESS 0x7fffffe00000*/ 
      uintptr_t magic             : 6;                                         
      uintptr_t weakly_referenced : 1;                                         
      uintptr_t deallocating      : 1;                                         
      uintptr_t has_sidetable_rc  : 1;                                         
      uintptr_t extra_rc          : 8
    };
#endif
};

复制代码

SideTable

NONPOINTER_ISA中有两个成员变量has_sidetable_rcextra_rc,当extra_rc的19位内存不够存储引用计数时has_sidetable_rc的值就会变为1,那么此时引用计数会存储在SideTable中。

SideTables可以理解为一个全局的hash数组,里面存储了SideTable类型的数据,其长度为64,就是里面有64个SideTable。

struct SideTable {
    spinlock_t slock;
    RefcountMap refcnts;
    weak_table_t weak_table;
}
复制代码

查看SideTable的源码,其3个成员变量,代表的意义如下:

  • spinlock_t:自旋锁,用于上锁/解锁 SideTable。
  • RefcountMap:用来存储OC对象的引用计数的 hash表(仅在未开启isa优化或在isa优化情况下isa_t的引用计数溢出时才会用到)。
  • weak_table_t:存储对象弱引用指针的hash表。是OC中weak功能实现的核心数据结构。

有关于弱引用表weak_table_t,可以阅读☞iOS底层学习 - 内存管理之weak原理探究

// RefcountMap disguises its pointers because we 
// do not want the table to act as a root for `leaks`.
typedef objc::DenseMap<DisguisedPtr<objc_object>,size_t,RefcountMapValuePurgeable> RefcountMap;
复制代码

实质上是模板类型objc::DenseMap。模板的三个类型参数DisguisedPtr<objc_object>size_ttrue 分别表示DenseMap的 key类型value类型、是否需要在value == 0 的时候自动释放掉响应的hash节点,这里是true。

MRC与ARC

MRC与ARC概念

MRC

在MRC时代,程序员需要手动的去管理内存,创建一个对象时,需要在set方法和get方法内部添加释放对象的代码。并且在对象的dealloc里面添加释放的代码。

@property (nonatomic, strong) Person *person;

- (void)setPerson:(Person *)person {
    if (_person != person) {
        [_person release];
        _person = [person retain];
    }
}

- (Person *) person {
    return _person;
}
复制代码

ARC

在ARC环境中,我们不再像以前一样自己手动管理内存,系统帮助我们做了release或者autorelease等事情。 ARC是LLVM编译器RunTime协作的结果。其中LLVM编译器自动生成releasereatinautorelease的代码,像weak弱引用这些则靠RunTime在运行时释放。

引用计数

引用计数是一种内存管理技术,是指将资源(可以是对象、内存或磁盘空间等等)的被引用次数保存起来,当被引用次数变为零时就将其释放的过程。使用引用计数技术可以实现自动资源管理的目的。同时引用计数还可以指使用引用计数技术回收未使用资源的垃圾回收算法。

在iOS中,对象执行reatin等操作后,该对象的引用计数就会+1,调用release等操作后,改对象的引用计数就会-1

引用计数规则

关于引用计数的规则,可以总结如下:

  1. 自己生成的对象,自己持有。(alloc,new,copy,mutableCopy等)
  2. 非自己生成的对象,自己也能持有。(retain 等)
  3. 不再需要自己持有的对象时释放。(release,dealloc 等)
  4. 非自己持有的对象无法释放。

原理探索

alloc与retainCount原理

有关alloc的流程,可以阅读☞iOS底层学习 - OC对象前世今生来进行查看,就不做过多的赘述。

但是有一个细节需要注意,alloc本身是只申请内存空间,不增加引用计数的。此时isaextra_rc为0。

但是为什么我们打印retainCount时,显示的是1呢,我们通过查看源码可以发现uintptr_t rc = 1 + bits.extra_rc;其本身前面是会加一个常量1的,用来标记自己生成的对象的引用计数。

inline uintptr_t 
objc_object::rootRetainCount()
{
    if (isTaggedPointer()) return (uintptr_t)this;

    sidetable_lock();
    isa_t bits = LoadExclusive(&isa.bits);
    ClearExclusive(&isa.bits);
    if (bits.nonpointer) {
        uintptr_t rc = 1 + bits.extra_rc;
        if (bits.has_sidetable_rc) {
            rc += sidetable_getExtraRC_nolock();
        }
        sidetable_unlock();
        return rc;
    }

    sidetable_unlock();
    return sidetable_retainCount();
}
复制代码

reatin原理

通过以下源码,我们可以知道:

  • TaggedPointer对象是不参与引用计数的
  • 小概率如有hasCustomRR,会走消息发送
inline id objc_object::retain()
{
    assert(!isTaggedPointer());

    if (fastpath(!ISA()->hasCustomRR())) {
        return rootRetain();
    }

    return ((id(*)(objc_object *, SEL))objc_msgSend)(this, SEL_retain);
}

复制代码

接着我们查看rootRetain方法,其主要处理如下:

  1. 判断当前对象是否一个TaggedPointer,如果是则返回。
  2. 判断isa是否经过NONPOINTER_ISA优化,如果未经过优化,则将引用计数存储在SideTable中。64位的设备不会进入到这个分支。
  3. 判断当前的设备是否正在析构。
  4. 将isa的bits中的extra_rc进行加1操作。
  5. 如果在extra_rc中已经存储满了,则调用sidetable_addExtraRC_nolock方法将一半的引用计数移存到SideTable中。
ALWAYS_INLINE id 
objc_object::rootRetain(bool tryRetain, bool handleOverflow)
{
    ✅//如果是TaggedPointer 直接返回
    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;
        ✅// 如果isa未经过NONPOINTER_ISA优化
        if (slowpath(!newisa.nonpointer)) {
            ClearExclusive(&isa.bits);
            if (!tryRetain && sideTableLocked) sidetable_unlock();
            if (tryRetain) return sidetable_tryRetain() ? (id)this : nil;
            else return sidetable_retain();//引用计数存储于SideTable中
        }
        // donot check newisa.fast_rr; we already called any RR overrides
        ✅//检查对象是都正在析构
        if (slowpath(tryRetain && newisa.deallocating)) {
            ClearExclusive(&isa.bits);
            if (!tryRetain && sideTableLocked) sidetable_unlock();
            return nil;
        }
        uintptr_t carry;
        ✅//isa的bits中的extra_rc进行加1
        newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc++
        ✅//如果bits的extra_rc已经存满了,则将其中的一半存储到sidetable中
        if (slowpath(carry)) {
            // newisa.extra_rc++ overflowed
            if (!handleOverflow) {
                ClearExclusive(&isa.bits);
                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;//extra_rc置空一半的数值
            newisa.has_sidetable_rc = true;
        }
    } while (slowpath(!StoreExclusive(&isa.bits, oldisa.bits, newisa.bits)));

    if (slowpath(transcribeToSideTable)) {
        // Copy the other half of the retain counts to the side table.
        ✅//将另外的一半引用计数存储到sidetable中
        sidetable_addExtraRC_nolock(RC_HALF);
    }

    if (slowpath(!tryRetain && sideTableLocked)) sidetable_unlock();
    return (id)this;
}
复制代码

其他

关于release的相关原理,和retain的原理相反,大家可以自己去探究一下;

关于dealloc的相关原理,在iOS底层学习 - 内存管理之weak原理探究中也有解析

总结

  • 内存布局:
    • 内存地址由高到低分为栈区(自动处理内存)、堆区(开发者管理,ARC下会自动释放)、静态区(全局变量,静态变量)、数据段(常量)、代码段(编写的二进制代码区)
    • static静态变量的作用域与对象、类、分类没关系,只与文件有关系。
  • 内存优化方案:
    • Tagged Pointer
      • Tagged Pointer是专⻔⽤来存储⼩的对象,例如NSNumber,NSDate等。
      • Tagged Pointer指针的值不再是地址了,⽽是真正的值。所以,实际上它不再是⼀个对象了,它只是⼀个披着对象⽪的普通变量⽽已。所以,它的内存并不存储在堆中,也不需要malloc和free。
      • 当指针不够存储数据时,就会使用动态分配内存的方式来存储数据。
      • 在内存读取上有着3倍的效率,创建时⽐以前快106倍。
    • NONPOINTER_ISA
      • 通过nonpointer标志是否开启指针优化
      • 通过extra_rc来存储引用计数,根据系统架构不同,长度不同
      • 通过has_sidetable_rc来判断超出extra_rc后,是否有全局SideTable存储引用计数
    • SideTable
      • SideTables是一个全局的哈希数组,里面存储了SideTable类型数据
      • SideTablespinlock_t(自旋锁,用于上/解锁)、RefcountMap(存储extra_rc溢出或者未开启优化的引用计数)、weak_table_t(存储弱引用表)
  • 引用计数规则:
    • 自己生成的对象,自己持有。(alloc,new,copy,mutableCopy等)
    • 非自己生成的对象,自己也能持有。(retain 等)
    • 不再需要自己持有的对象时释放。(release,dealloc 等)
    • 非自己持有的对象无法释放。
  • alloc本身是只申请内存空间,不增加引用计数的。此时isa中extra_rc为0。只是调用rootRetainCount其本身前面是会加一个常量1的,用来标记自己生成的对象的引用计数。
  • reatin原理:
    • 判断当前对象是否一个TaggedPointer,如果是则返回。
    • 判断isa是否经过NONPOINTER_ISA优化,如果未经过优化,则将引用计数存储在SideTable中。64位的设备不会进入到这个分支。
    • 判断当前的设备是否正在析构。
    • 将isa的bits中的extra_rc进行加1操作。
    • 如果在extra_rc中已经存储满了,则调用sidetable_addExtraRC_nolock方法将一半的引用计数移存到SideTable中。

参考

内存管理(一)

iOS内存管理一:Tagged Pointer&引用计数

关注下面的标签,发现更多相似文章
评论