iOS-面试题4-内存管理

目录:

  1. 定时器
  2. 内存区域、Tagged Pointer
  3. MRC
  4. Copy
  5. 引用计数、__weak原理
  6. autorelease

一. 定时器

self.link = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkTest)];
[self.link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];

self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerTest) userInfo:nil repeats:YES];
  1. CADisplayLink这个定时器不能设置时间,保证调用频率和屏幕刷帧频率一致。屏幕刷帧频率大概是60FPS,所以这个定时器一般一秒钟调用60次。

  2. 创建NSTimer,如果是通过scheduledTimer创建,就是定制好的timer,定时器已经添加到RunLoop里面了。如果是timerWithTimeInterval创建的,就需要自己手动添加定时器到RunLoop里面。

  3. CADisplayLink、NSTimer会对target产生强引用,如果target又对它们产生强引用,那么就会引发循环引用。显而易见,上面两个定时器都有循环引用的问题。

  4. 解决方案:将NSTimer改成block形式的,如下:

__weak typeof(self) weakSelf = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
    [weakSelf timerTest];
}];

这时候是,self对定时器强引用,定时器对block强引用,block对self弱引用,不产生循环引用。

  1. 解决方案:使用中间对象解决
  • 在没使用中间对象之前,引用关系是,self里面的timer强引用着定时器,定时器里面的target强引用着self,产生循环引用。
  • 在添加中间对象之后,控制器中的timer强引用着定时器,定时器中的target强引用着中间对象,中间对象的target弱引用着控制器,这样就不会产生循环引用了。
  • 当定时器启动后,会从中间对象中寻找timerTest方法,中间对象中找不到timerTest方法,就通过消息转发,转发给控制器,最后调用控制器的timerTest方法。
  • 对于NSTimer,无论用block解决还是用中间对象解决都可以,但是对于CADisplayLink,因为它没有block的创建方式,所以只能使用中间对象。
  1. NSProxy的作用
    NSProxy和NSObject是同一级别的,都遵守NSObject协议。
    NSProxy就是专门做消息转发的。

  2. 那么NSProxy比上面继承于NSObject的中间对象好在哪里呢?
    如果调用的是继承于NSObject某个类的方法,那么它的方法寻找流程就是先查缓存,再走消息发送、动态方法解析、消息转发,效率低。
    如果调用的是继承于NSProxy某个类的方法,那么它的方法寻找流程是,先看自己有没有这个方法,如果没有,就直接一步到位,来到methodSignatureForSelector方法,效率高。

  3. NSTimer不准时的原因
    NSTimer依赖于RunLoop,如果RunLoop的任务过于繁重,可能会导致NSTimer不准时。
    而GCD的定时器会更加准时,因为GCD的定时器是直接和系统内核挂钩的。所以就算界面上添加一个scrollView,滚动的时候就算RunLoop模式切换了,GCD定时器还会照常工作,因为GCD和RunLoop一点关系都没有。

博客地址:定时器、NSProxy

二. 内存区域、Tagged Pointer

iOS程序的内存布局.png
  1. 从64bit开始,iOS引入了Tagged Pointer技术,用于优化NSNumber、NSDate、NSString等小对象的存储,Tagged Pointer技术是编译器帮我们做的。

  2. 在没有使用Tagged Pointer之前, NSNumber等对象需要动态分配内存、维护引用计数等,NSNumber指针存储的是堆中NSNumber对象的地址值。

  3. 使用Tagged Pointer之后,NSNumber指针里面存储的数据变成了:Tag + Data,也就是将数据直接存储在了指针中,其中Tag是标记是什么类型,比如NSNumber、NSDate、NSString等。

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

  5. objc_msgSend能识别Tagged Pointer,比如NSNumber的intValue方法,优点就是直接从指针提取数据,节省了以前的调用开销,同时也会节省内存

  6. 如何判断一个指针是否为Tagged Pointer?
    iOS平台,转换成二进制,最高有效位是1(第64bit位)
    Mac平台,转换成二进制,最低有效位是1(第0bit位)

  7. 思考以下两段代码能发生什么事?有什么区别?

@property (copy, nonatomic) NSString *name;

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"];
    });
}

for (int i = 0; i < 1000; i++) {
    dispatch_async(queue, ^{
        self.name = [NSString stringWithFormat:@"abc"];
    });
}

运行之后,第一段代码报错坏内存访问,并且崩在objc_release这个函数。第二段代码没问题。

上面的setter方法,ARC最终都要转成MRC,MRC内部实现是:

- (void)setName:(NSString *)name
{
    if (_name != name) {
        [_name release]; //先释放旧的
        _name = [name copy]; //再赋值新的
    }
}

问题就出在 [_name release],当多条线程同时调用setter方法的时候,就有可能多个线程同时访问[_name release],刚开始引用计数器为1,当第二次release的时候,_name已经释放了,这时候再访问_name就会报错:坏内存访问。

如何解决?
① 调用setter方法之前加锁,如下:

// 在这里加锁
self.name = [NSString stringWithFormat:@"abcdefghijk"];
// 在这里解锁

② 改用atomic修饰,不推荐

为什么第二段代码不会崩溃?
对于str2,不是OC对象,不会调用setter方法,而是直接从指针里面获取值,所以不会出现上面的崩溃。

博客地址:内存区域、Tagged Pointer

三. MRC

  1. Automatic Reference Counting:ARC
    Manual Reference Counting:MRC

  2. 在iOS中,使用引用计数来管理OC对象的内存

  3. 一个新创建的OC对象引用计数默认是1,当引用计数减为0,OC对象就会销毁,释放其占用的内存空间

  4. 调用retain会让OC对象的引用计数+1,调用release会让OC对象的引用计数-1

  5. 内存管理的经验总结:
    ① 当调用alloc、new、copy、mutableCopy方法返回了一个对象,在不需要这个对象时,要调用release或者autorelease来释放它
    ② 想拥有某个对象,就让它的引用计数+1,不想再拥有某个对象,就让它的引用计数-1

  6. MRC的setter方法怎么写?

- (void)setDog:(MJDog *)dog
{
    if (_dog != dog) {
        [_dog release]; 
        _dog = [dog retain];
}
  1. 解释一下为什么这么写?

为什么要retain新值?
当[[person dog] run]的时候person还存在,但是dog却死了,这样肯定不合理啊,所以我们要让person拥有dog,所以给dog先retain一次再赋值。否则报错:

-[MJDog run]: message sent to deallocated instance 0x10064dd40 
//意思是run消息发送给一个已经释放掉的对象了(僵尸对象)。

为什么要release旧值?

[person setDog:dog1]; // dog1 : 2
[person setDog:dog2]; // dog2 : 2
    
[dog1 release]; // dog1 : 1
[dog2 release]; // dog2 : 1

因为dog1是旧值,所以赋新值之前要对旧值进行release操作,否则就造成了内存泄漏(该释放的对象没有释放)。

为什么要判断新值旧值是否相等?

[person setDog:dog]; // dog 0 
[person setDog:dog];
[person setDog:dog];

先release旧值后retain新值,旧值和新值是一样的,由于旧值release之后引用计数器就为0,dog被释放了,这时候再[dog retain]就会报错:

-[MJDog retain]: message sent to deallocated instance 0x10058cdf0
//错误意思就是,retain消息发送给一个已经释放掉的对象了(僵尸对象)。
  1. 关于dealloc

在dealloc里面我们经常见到别人这么写:

- (void)dealloc
{
    self.dog = nil;
    [super dealloc];
}

其实,self.dog = nil就相当于:

- (void)setDog:(MJDog *)dog
{
    if (_dog !=nil) { // 如果旧值不为nil
        [_dog release]; //就把旧值release
        //_dog = [nil retain];
        _dog = nil; //并且将指针置为nil
    }
}

可以看出,和上面我们写的dealloc是一样的,所以推荐使用self.dog = nil这种方式,更简洁。

博客地址:MRC

四. Copy

  1. 什么是拷贝?
    copy就是拷贝, 拷贝的目的:产生一个副本对象,跟源对象互不影响。
    修改了源对象,不会影响副本对象,修改了副本对象,不会影响源对象。

  2. iOS提供了两个拷贝方法
    copy,不可变拷贝。不管原来是可变还是不可变,copy之后产生的都是不可变副本。
    mutableCopy,可变拷贝。不管原来是可变还是不可变,mutableCopy之后产生的都是可变副本。

  3. 深拷贝和浅拷贝
    深拷贝:内容拷贝,产生新的对象
    浅拷贝:指针拷贝,没有产生新的对象
    调用copy、mutableCopy后到底是深拷贝还是浅拷贝,系统说了算,只要达到产生一个副本对象,并且副本对象和源对象互不影响的目的就可以。

深拷贝、浅拷贝.png

拷贝的目的就是产生一个新的副本,并且副本对象和源对象互不影响。
为了达到这个目的并且尽量不占用没必要的内存,当调用copy、mutableCopy方法时,系统会自动决定是深拷贝还是浅拷贝(当从不可变到不可变,既然大家都是不可变,那么就直接指针拷贝得了,还省内存,其他情况的拷贝只要有可变的,为了拷贝之后互不影响只能深拷贝了)。

  1. copy修饰属性

使用copy修饰,右边放的一般都是不可变对象,如下:

@property (copy, nonatomic) NSString *str;
@property (copy, nonatomic) NSArray *data;

比如:如果我们使用copy修饰,把一个可变数组传进去,copy之后就变成了不可变数组,给不可变数组添加元素当然会报错啦!

  1. 为什么NSString使用copy?

使用copy之后,不管你外面传进来的是可变还是不可变的,我都能保证我里面的属性是不可变的。比如如下代码:

NSMutableString *mutableStr = [NSMutableString stringWithFormat:@"123"];
UITextField *textField;
textField.text = mutableStr;
//修改mutableStr不会影响到textField.text
[mutableStr appendString:@"456"];

textField.text是不可变的,给他传进去一个可变的mutableStr,修改mutableStr不会影响到textField.text,因为它们是拷贝之后两个独立的对象。
如果要是调用[mutableStr appendString:@"456"]之后,显示到UI界面上的文字也改变了,那么这就很诡异了,所以一般对于字符串这种和UI界面相关的,我们都使用copy,对于NSArray、NSDictionary一般还是使用strong。

  1. 如何自定义copy?

自定义类如果想实现copy方法,必须遵守NSCopying协议,并且实现copyWithZone方法,copy方法底层就是调用copyWithZone方法。

博客地址:Copy

五. 引用计数、__weak原理

  1. 引用计数存储在哪?

其实在isa存储信息分析中已经讲过了,在64bit中,引用计数可以直接存储在优化过的isa指针中,也可能存储在SideTable结构体中。
① 如果引用计数不大(不大于19),会存储在isa的extra_rc中,其中rc是retainCount的意思。
② 如果引用计数过大,isa中的has_sidetable_rc就为1,引用计数就存储在一个叫SideTable的结构体的refcnts成员中,refcnts是个散列表。

SideTable结构如下:

struct SideTable {
    spinlock_t slock;
    RefcountMap refcnts; //引用计数表(散列表)
    weak_table_t weak_table; //弱引用表(散列表)
}
  1. __strong、__weak、__unsafe_unretained的区别

__strong:强引用,引用计数器加一。
__weak:弱引用,引用计数器不加一,当对象的引用计数为0,对象被释放,指针被清空(person1 = nil)。
__unsafe_unretained:弱引用,引用计数器不加一,当对象的引用计数为0,对象被释放,对象的内存被回收,指针不被清空,所以这时候通过指针访问指向的对象就会报坏内存访问错误。

  1. ARC都帮我们做了什么?
    ① ARC是LLVM编译器和Runtime系统相互协作的一个结果。
    ② ARC利用LLVM编译器自动帮我们生成retain、release这些代码。
    ③ __weak弱引用这样的存在是需要Runtime的支持的,是在程序运行过程中监控到对象要销毁的时候就会把这个对象对应的弱引用都给清除掉,这个我们从Runtime源码也能看出来。

  2. 什么时候用weak,什么时候用assgin?
    ① ARC之后才有weak,weak是弱指针,当使用weak关键字修饰成员变量的时候,成员变量内部是用__weak修饰的,不会让引用计数器+1,如果指向对象被销毁,指针会自动清空,就不会报坏内存访问了。
    ② 当使用assgin修饰的时候,内部是用__unsafe_unretained修饰的,不会让引用计数器+1,如果指向对象被销毁,指针不会清空,如果这时候访问对象的指针,就会有坏内存访问错误。

  3. weak内部实现原理?

Runtime维护了一个weak表(SideTable中的弱引用表,是个散列表),用于存储指向某个对象的所有weak指针。weak表其实是一个hash(哈希)表,Key是所指对象的地址,Value是weak指针的地址(这个地址的值是所指对象的地址)数组。

① 初始化时:runtime会调用objc_initWeak函数,初始化一个新的weak指针指向对象的地址。
② 添加引用时:objc_initWeak函数会调用 objc_storeWeak() 函数, objc_storeWeak() 的作用是更新指针指向,创建对应的弱引用表。
③ 释放时,调用clearDeallocating函数。clearDeallocating函数首先根据对象地址获取所有weak指针地址的数组,然后遍历这个数组把其中的数据设为nil,最后把这个entry从weak表中删除,最后清理对象的记录。

追问:当weak引用指向的对象被释放时,又是如何去处理weak指针的呢?

  1. 当一个对象要释放时,会自动调用dealloc
  2. 在dealloc中,调用了_objc_rootDealloc函数
  3. 在_objc_rootDealloc中,调用了object_dispose函数
  4. 调用objc_destructInstance函数
  5. 最后调用clearDeallocating函数,将指向当前对象的弱指针置为nil,详细过程如下:
    a:从weak表中获取废弃对象的地址为键值的记录
    b:将包含在记录中的所有附有weak修饰符变量的地址,赋值为nil
    c:将weak表中该记录删除
    d:从引用计数表中删除废弃对象的地址为键值的记录

博客地址:引用计数、__weak原理

六. autorelease

下面代码:

@autoreleasepool {
    MJPerson *person = [[[MJPerson alloc] init] autorelease];
}

底层就是下面三行:

//构造函数
atautoreleasepoolobj = objc_autoreleasePoolPush();
//对象调用了autorelease
MJPerson *person = [[[MJPerson alloc] init] autorelease];
//析构函数
objc_autoreleasePoolPop(atautoreleasepoolobj);
  1. 自动释放池的主要底层数据结构是:__AtAutoreleasePool结构体、AutoreleasePoolPage类,调用了autorelease的对象最终都是通过AutoreleasePoolPage对象来管理的。
  2. 每个AutoreleasePoolPage对象占用4096字节内存,除了用来存放它内部的成员变量,剩下的空间用来存放调用了autorelease方法的对象的地址,所有的AutoreleasePoolPage对象通过双向链表的形式连接在一起。
  3. 调用push()方法会将一个POOL_BOUNDARY入栈,并且返回其存放的内存地址。
  4. 当发现有一个对象调用了autorelease,就把这个对象的地址值接着POOL_BOUNDARY往下存,当发现这个AutoreleasePoolPage对象不够存的时候,就会创建一个新的,然后用它的child指针指向这个新的AutoreleasePoolPage对象,然后用新的AutoreleasePoolPage对象存储。
  5. 调用pop()方法时传入一个POOL_BOUNDARY的内存地址,会从最后一个入栈的对象开始发送release消息,直到遇到这个POOL_BOUNDARY。

面试题

  1. 调用autorelease的对象在什么时机会被调用release?
    ① 如果有@autoreleasepool{},所以autoreleasepool里面调用了autorelease方法的对象会在{}结束之后释放。
    ② 如果没写@autoreleasepool{},由于整个程序没有退出,autoreleasepool里面调用了autorelease方法的对象会在RunLoop休眠之前被释放。

  2. ARC中,方法里有局部对象,出了方法后会立即释放吗?
    会立即释放,因为就相当于在方法的最后加一行release代码。

博客地址:autorelease

你可能感兴趣的:(iOS-面试题4-内存管理)