面试遇到内存管理的第二天-Tagged Pointer和MRC

536 阅读6分钟

继续上篇文章,通过定时器,引出常见的内存泄漏问题,可见内存管理的重要性。本文我们继续深入的分析内存管理的原理,看看曾经的MRC年代,开发时需要做哪些内存管理相关的工作。

其实MRC年代已经过去很久了,也许你觉得并没有必要去学习研究,很多工作都有编译器替开发者完成了,我们开发中并不需要去过多的关心,但是学习还是要抱着知其然也知其所以然的态度。编译器替开发者做了很多工作,可是他做的到底是什么,又是在什么时刻(编译时or运行时)去做的呢?

希望你是带着上面的问题,和你在内存管理方便的一些疑惑开始阅读下面的内容。

先补充一个内存的基础知识:

内存布局

说了半天的内存管理,那内存到底是怎么分布的,我们开发中写的代码最终会存在内存的什么位置呢?这个问题面试中也会被问到,我们至少可以说出从低到高的内存布局。

根据上面这幅内存分布的图,可以看到,从低地址开始,首先是保留区域,保留区域暂时先不用关心,从保留区域往下开始,就是我们要了解掌握的东西了。

  1. 代码段:编译之后的代码就保存在这个区域,就是010101这些二进制
  2. 数据段:字符串常量、已初始化的数据、未初始化的数据从低到高排布
  3. 堆:通过alloc、malloc、calloc等动态分配的空间,地址有低到高排布
  4. 栈:函数调用的开销,比如函数中的局部变量,栈中的地址由高到低排布

再简单通过代码,验证一下上面图中数据段的布局顺序是否正确:

NSString *str1 = @"123";
NSString *str2 = @"123";
NSLog(@"%p %p",str1, str2);//0x1049a0040 0x1049a0040
static int c = 20;
static int d;
int e;
int f = 20;
NSString *str = @"123";
NSObject *obj = [[NSObject alloc] init];
NSObject *obj2 = [[NSObject alloc] init];
NSLog(@"\na=%p\nb=%p\nc=%p\nd=%p\ne=%p\nf=%p\nstr=%p\nobj=%p\nobj2=%p",&a,&b,&c,&d,&e,&f,str,obj,obj2);

根据打印的结果,再做一次内存地址从低到高的排序

a=0x10ef8b520
b=0x10ef8b5ec
c=0x10ef8b524
d=0x10ef8b5e8
e=0x7ffee0c77cc4
f=0x7ffee0c77cc0
str=0x10ef89040
obj=0x600001578090

就可以验证在数据段的布局顺序从低到高依次是:

//字符串常量
str=0x10ef89040
//已初始化的全局变量、静态变量
a=0x10ef8b520
c=0x10ef8b524
//未初始化的全局变量、静态变量
d=0x10ef8b5e8
b=0x10ef8b5ec
//堆  alloc分配内存
obj=0x600001578090
//栈  先分配的内存地址高  后分配的地址低
f=0x7ffee0c77cc0
e=0x7ffee0c77cc4

Tagged Pointer

Tagged Pointer是从64bit之后iOS引入的一项技术,用于优化NSNumber、NSDate、NSString等小对象的存储。我们知道,在没有使用Tagged Pointer之前,所有的对象都需要动态分配内存,维护引用计数等。

NSNumber为例,使用Tagged Pointer之前,NSNumber的指针中存储的是堆中NSNumber对象的地址值,在使用时都要先根据指针找到对应的内存地址,再取出内存地址中存储的值;而使用Tagged Pointer之后,NSNumber的指针中存储的数据变成了Tag + DataTag是一个标志位,标识了对象的类型,Data则就是NSNumber的值,也就是将数据直接存储在了指针中。

所以,对应的objc_msgSend也能识别Tagged Pointer,可以直接从指针中提取数据,节省了调用的开销。

当然,如果数据比较大,指针不够存储数据的时候,依然是使用动态分配内存的方式来存储数据。

在runtime源码NSObject.mm中,可以看到判断是否是Tagged Pointer的方法

_objc_isTaggedPointer(const void * _Nullable ptr)
{
    return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;
}

就是传入的指针ptr & _OBJC_TAG_MASK,_OBJC_TAG_MASK分为是iOS系统和Mac OS系统两个宏定义

#if OBJC_MSB_TAGGED_POINTERS
#   define _OBJC_TAG_MASK (1UL<<63)
#else
#   define _OBJC_TAG_MASK 1UL
#endif

总结:
Mac os:指针的最低有效位是1 就是tagged pointer 否则是对象
iOS:指针的最高有效位是1 就是tagged pointer 否则是对象

简单验证一下(iOS环境下)

NSNumber *number1 = @(2);
NSLog(@"%p",number1);//0xd749d63ace671147

0x d749 d63a ce67 1147
最高位d 转化为二进制是1101
最高位是1 就是tagged pointer

面试题

通过上面对Tagged Pointer的分享,我们再看看这个面试题,是不是就不难做出来了。

思考一下两段代码的区别?

dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
for (int i = 0; i < 1000; i++) {
    dispatch_async(queue, ^{
        self.name = [NSString stringWithFormat:@"abcdefghijk"];
    });
}
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"];
    });
}

两段代码的唯一区别就在于@"abcdefghijk"和@"abc",根据Tagged Pointer的特点,就可以知道这道面试题考察的点是什么了。Tagged Pointer用于优化NSNumber、NSDate、NSString等小对象的存储,如果超过8个字节存不下,就会恢复堆空间存储,正是本题的考点。

答案就是,字符串内容少一点就不会crash,因为tagged pointer不是oc对象;而crash的原因是坏内存访问。 要证明这两点也很容易,其实直接写两个字符串打印他们的类型和地址就可以知道了。

NSString *str = [NSString stringWithFormat:@"abcdefghijk"];
NSString *str2 = [NSString stringWithFormat:@"234abc"];
NSLog(@"%p %p",str, str2);//0x600001a27c00 0xa9a09f4b43fcc530
NSLog(@"%@ %@",[str class], [str2 class]);//__NSCFString NSTaggedPointerString

而坏内存访问呢,我们都知道setter方法的内部实现,其实是调用了release的,多线程同时调用release,导致了坏内存访问。

- (void)setName:(NSString *)name {
    if (_name != name) {
        [_name release]; //多线程同时调用release 导致坏内存访问
        _name = [name copy];
    }
}

最后再复习一下之前学过的知识 ,说说这种crash如何解决 ?
方案一:改为atomic(当然不推荐使用)
方案二:加锁

MRC

其实写到这一节的时候,有点儿不知道怎么写下去了。本意是多掌握一点MRC下编码时需要做的工作,帮助我们理解ARC下,编译器帮我们做了什么。带着这个目的,写一写代码感受一下好了。

苹果在 iOS 5 中引入了ARC(Automatic Reference Counting)自动引用计数内存管理技术,通过LLVM编译器和Runtime协作来进行自动管理内存。LLVM编译器编译时在合适的地方为 OC 对象插入retain、release和autorelease代码,省去了在MRC(Manual Reference Counting)手动引用计数下手动插入这些代码的工作,减轻了开发者的工作量。

在MRC下,当我们不需要一个对象的时候,要调用release或autorelease方法来释放它。调用release会立即让对象的引用计数减 1 ,如果此时对象的引用计数为 0,对象就会被销毁。调用autorelease会将该对象添加进自动释放池中,它会在一个恰当的时刻自动给对象调用release,所以autorelease相当于延迟了对象的释放。

下面通过代码测试分析一下,首先第一步是在XCODE中改一下配置,修改为MRC

release

先创建一个Person类作为测试的基础

Person *person = [[Person alloc] init];//alloc分配内存 init初始化
NSLog(@"%lu",(unsigned long)person.retainCount);
[person release];

MRC下其实就一个最重要的规则,有一个alloc就需要对应有一个release去释放对象,所谓内存泄漏,就是该释放的对象没有被释放。

除了上面这种一一对应的写法之外,还有autorelease,是在一个恰当的时候调用release,可以先理解为这个方法的作用域{}结束之后。

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Person *person1 = [[[Person alloc] init] autorelease];
        NSLog(@"1");
    }
    NSLog(@"2");
    return 0;
}

这段代码的打印结果如下:

内存管理-MRC[1612:94630] 1
内存管理-MRC[1612:94630] -[Person dealloc]
内存管理-MRC[1612:94630] 2

注意这里是@autoreleasepool和autorelease的配合使用,如果只使用了autorelease则不是方法作用域{}结束后就释放。这里先简单带一句,后面打算在写文章专门分享一下autorelease的内容。

setter

MRC下,setter方法需要区分数据类型,如果是基础数据类型,不牵扯内存管理的,则直接赋值即可

- (void)setAge:(int)age {
    _age = age;
}

如果是对象类型,则需要做对应的retain和release操作

- (void)setCar:(Car *)car {
    if (_car != car) {
        [_car release];
        _car = [car retain];
    }
}

@property

MRC下,对象类型的属性,@property修饰词要使用retain。最初,@property只生成属性的setter和getter的声明,在.m文件中添加@synthesize才会自动生成方法实现,不需要在手动写setter和getter ,但是还是要实现dealloc方法。

- (void)dealloc
{
// 	先释放属性,并把指针置为nil
    [_dog release];
    _dog = nil;

    self.car = nil;

//    父类的dealloc放在最后调用
    [super dealloc];
    NSLog(@"%s",__func__);
}

类方法初始化

最后再补充一点,不是alloc、 init 或者new方法创建的,一般不需要release, 其内部实现已经调用了一次release。

self.data = [NSMutableArray array];

比如[NSMutableArray array]这种类方法的创建方式,就不需要再手动调用release了。