文章

内存管理

内存管理

面试题

  1. 介绍一下内存的几大区域
  2. 使用 CDDisplayLink、NSTimer 有什么注意点
  3. 讲一下对 iOS 内存管理的理解
  4. autorelease 什么时候释放
  5. 方法里有局部变量,出了方法后会立即释放吗? 表现上是的
  6. ARC 都帮我们做了什么
  7. weak 指针的实现原理

CDDisplayLink、NSTimer 使用注意与处理

CDDisplayLink、NSTimer 会对 target 产生强引用,如果target 对它再产生强引用,那就会发生循环引用。

处理这个循环引用问题:

  1. 针对 NSTimer 可以使用 initWithBlock 的方式,直接在block 内做事情。
  2. CDDisplayLink 没有block 方法,可以从消息转发的机制入手,设置一个代理 NSProxy 来转发消息,断开强引用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
--------- VC --------------
- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:[MyProxy proxyWithTarget:self] selector:@selector(timerTest) userInfo:nil repeats:YES];
}

- (void)timerTest
{
    NSLog(@"%s", __func__);
}

- (void)dealloc
{
    NSLog(@"%s", __func__);
    [self.timer invalidate];
}

--------- Proxy ------------
+ (instancetype)proxyWithTarget:(id)target
{
    // NSProxy对象不需要调用init,因为它本来就没有init方法
    MyProxy *proxy = [MyProxy alloc];
    proxy.target = target;
    return proxy;
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
{
    return [self.target methodSignatureForSelector:sel];
}

- (void)forwardInvocation:(NSInvocation *)invocation
{
    [invocation invokeWithTarget:self.target];
}

注意 应该直接使用 NSProxy 的子类做代理对象。其本身是基类,内部对于方法的处理,直接走的是消息转发阶段,不会在父类找方法实现和调用。 如果继承 NSObject 子类做代理,会执行方法查找和转发的完整步骤,效率会低

GCD Timer

NSTimer 依赖于 Runloop,如果Runloop 的任务比较繁重,可能会导致 NSTimer 不准时

GCD 的 Timer 会更加准时,它是一种专门监听系统时间定时器

原理: GCD 框架提供了一系列的接口来监听底层系统对象的活动 当监听被触发,会自动将 block 提交到指定的消息队列来处理回调 系统底层对象包括:file descriptors, Mach ports, signals, VFS nodes, etc

基本使用如下: 完整代码地址 GCD_Timer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
+ (NSString *)execTask:(void (^)(void))task start:(NSTimeInterval)start interval:(NSTimeInterval)interval repeats:(BOOL)repeats async:(BOOL)async
{
    if (!task || start < 0 || (interval <= 0 && repeats)) return nil;
    
    // 队列
    dispatch_queue_t queue = async ? dispatch_get_global_queue(0, 0) : dispatch_get_main_queue();
    
    // 创建定时器
    dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
    
    // 设置时间
    dispatch_source_set_timer(timer,
                              dispatch_time(DISPATCH_TIME_NOW, start * NSEC_PER_SEC),
                              interval * NSEC_PER_SEC, 0);
    
    
    dispatch_semaphore_wait(semaphore_, DISPATCH_TIME_FOREVER);
    // 定时器的唯一标识
    NSString *name = [NSString stringWithFormat:@"%zd", timers_.count];
    // 存放到字典中
    timers_[name] = timer;
    dispatch_semaphore_signal(semaphore_);
    
    // 设置回调
    dispatch_source_set_event_handler(timer, ^{
        task();
        
        if (!repeats) { // 不重复的任务
            [self cancelTask:name];
        }
    });
    
    // 启动定时器
    dispatch_resume(timer);
    
    return name;
}

iOS 程序的内存布局

程序装载到内存中之后,内存分布如下。

Tagged Pointer

从 64 bit 开始, iOS 引入了 Tagged Pointer 技术,用于优化 NSNumber、NSDate、NSString 等小对象的存储。

简单来说:

  • Tagged Pointer 是直接保存值的指针,非对象,无 isa 指针,不指向堆空间地址,无需对其进行内存管理
  • 对象值小于8个字节可以表示,则将其指针拆分为两部分,一个部分标记,一部分存值。它是一个含值的指针,并非真正的对象
  • 当对象值 8 个字节无法保存,则会创建真正的对象来保存该值

看以下代码,存在什么问题,如何解决。

1
2
3
4
5
6
7
8
9
@property (nonatomic, strong) NSString *string;

dispatch_queue_t queue = dispatch_queue_create("memoryBeingFreedCase", DISPATCH_QUEUE_CONCURRENT);
    for (int i = 0; i < 1000000; i++) {
        dispatch_async(queue, ^{
            self.string = [NSString stringWithFormat:@"The num is %d", i];
            // self.string = [NSString stringWithFormat:@"%d", i]; 
        });
}

出现问题: 代码会崩溃。

因为: strong 类型的属性,在set方法内部会 release oldValue, 然后 retain newValue. 因为是多线程并发,可能会对 oldValue 进行多次 release 造成坏内存访问,崩溃。

解决方法:

  1. 使用线程同步技术,将并发任务改成串行
  2. 将属性改为 atomic 属性
  3. 改用 Tagged Point 技术 【上文中的注释即可】

Copy 和 mutableCopy

  1. copy: 不可变拷贝,产生不可变副本
  2. mutableCopy: 可变拷贝,产生可变副本

拷贝分为深拷贝(内容拷贝,产生新内容) 和浅拷贝(指针拷贝)。

不可变对象 copy -> 指针拷贝 -> 结果为两个指针指向同一个不可变对象 不可变对象 mutableCopy -> 内容拷贝 -> 结果为两个指针,一个可变对象,一个不可变对象

可变对象 copy -> 深拷贝 -> 结果两个指针,一个可变对象,一个copy出来的不可变对象 可变对象 mutableCopy -> 深拷贝 -> 结果是两个指针,两个可变对象

总结:

copy 关键字

copy 关键字在设置属性的时指定了其变量内存管理的方式为 copy。

下面代码会发生什么,为什么?

1
2
3
4
5
6
7
8
9
10
// .h 文件
@property (nonatomic, copy) NSMutableArray *mutableArray;

// .m 文件
NSMutableArray *array = [NSMutableArray arrayWithObjects:@1,@2,nil];
self.mutableArray = array;
[self.mutableArray removeObjectAtIndex:0];

// 会崩溃,-[__NSArrayI removeObjectAtIndex:]: unrecognized selector sent to instance 0x7fcd1bc30460
// 因为 属性指定 copy 修饰符,直接在 setter 赋值的时候将 [mutableArray copy] 成了不可变数组,虽然它的声明依旧是 NSMutableArray

自己的类实现 copy 关键字

若想令自己所写的对象具有拷贝功能,则需实现 NSCopying 协议。如果自定义的对象分为可变版本与不可变版本,那么就要同时实现 NSCopying 与 NSMutableCopying 协议。

1.需声明该类遵从 NSCopying 协议 2.实现 NSCopying 协议。该协议只有一个方法:- (id)copyWithZone:(NSZone *)zone;

1
2
3
4
5
6
7
- (id)copyWithZone:(NSZone *)zone {
	User *copy = [[[self class] allocWithZone:zone] 
		             initWithName:_name
 							      age:_age
						          sex:_sex];
	return copy;
}

如果是对象内部有 array 这样的属性,需要考虑 array 内部的拷贝赋值问题。

MRC 与 ARC

引用计数的存储

在 iOS 中,使用引用计数来管理OC对象内存,一个新创建的 OC 对象引用计数默认为 1,当引用计数减为 0 ,OC 对象就会被销毁,释放其占用的内存空间。

在 MRC 中需要手动管理内存,调用 retain 会让 OC 对象引用计数 +1,调用 release 会让 OC 对象的引用计数 -1

内存管理的经验总结

  1. 当调用 alloc/new/copy/mutableCopy 方法返回了一个对象,在不需要这个对象的时候,需要调用 release/autorelease 来释放它
  2. 想拥有某个对象,就让它的引用计数 +1,不再想拥有某个对象就让它的计数 -1

引用计数的保存在 isa 中,在 64 位机器上经过优化,如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# if __arm64__
#   define ISA_MASK        0x0000000ffffffff8ULL
#   define ISA_MAGIC_MASK  0x000003f000000001ULL
#   define ISA_MAGIC_VALUE 0x000001a000000001ULL
    struct {
        uintptr_t nonpointer        : 1;
        uintptr_t has_assoc         : 1;
        uintptr_t has_cxx_dtor      : 1;
        uintptr_t shiftcls          : 33; // MACH_VM_MAX_ADDRESS 0x1000000000
        uintptr_t magic             : 6;
        uintptr_t weakly_referenced : 1;
        uintptr_t deallocating      : 1;
        uintptr_t has_sidetable_rc  : 1;  // 当19位不够保存,则在 sideTable 中保存。
        uintptr_t extra_rc          : 19; // 高19位为保存引用计数
#       define RC_ONE   (1ULL<<45)
#       define RC_HALF  (1ULL<<18)
    };
#endif

weak 实现原理

这个可以从 weak 的作用入手。weak 修饰的对象,被销毁后其指针被赋值为 nil。所以可以从 runtime 源码看看 dealloc 方法的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
- (void)dealloc {
    _objc_rootDealloc(self);
}

void
_objc_rootDealloc(id obj)
{
    obj->rootDealloc();
}

inline void
objc_object::rootDealloc()
{
    if (isTaggedPointer()) return;  // Tagged Pointer 直接结束

    if (fastpath(isa.nonpointer  &&  
                 !isa.weakly_referenced  &&  
                 !isa.has_assoc  &&  
                 !isa.has_cxx_dtor  &&  
                 !isa.has_sidetable_rc))
    {   // 优化指针,弱引用,无关联对象,无c++析构函数,无sidetable保存引用计数
        // 直接释放当前对象
        free(this);
    } 
    else {
        // 进入内部释放
        object_dispose((id)this);
    }
}

id 
object_dispose(id obj)
{
    objc_destructInstance(obj);    
    free(obj);
    return nil;
}

// dealloc方法的核心实现,内部会做判断和析构操作
void *objc_destructInstance(id obj) 
{
    if (obj) {
        // Read all of the flags at once for performance.
        // 判断是否有OC或C++的析构函数
        bool cxx = obj->hasCxxDtor();
        // 对象是否有相关联的引用
        bool assoc = obj->hasAssociatedObjects();

        // This order is important.
        // 对当前对象进行析构
        if (cxx) object_cxxDestruct(obj);
        // 移除所有对象的关联,例如把weak指针置nil
        if (assoc) _object_remove_assocations(obj);
        obj->clearDeallocating();
    }

    return obj;
}

__weak 在 runtime 中的实现

ARC 帮我们做了什么?

ARC是 LLVM + runtime 协作的结果,LLVM 编译器会自动帮我们生成 release/retain 等相关内存管理代码。runtime 在程序运行期间检测对象引用计数,释放弱引用等,帮我们管理内存。

autorelease 原理

autorelease 基于自动释放池 AutoreleasePool

autoreleasePool 的主要底层数据结构是: __AtAutoreleasePool(编译器层)、AutoreleasePoolPage(runtime源码)

调用了 autorelease 的对象最终都是通过 AutoreleasePoolPage 对象来管理的

通过 xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp 重写@autoreleasepool 可以看到其被重写为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 自动释放池变量C++实现
struct __AtAutoreleasePool {
  __AtAutoreleasePool() {atautoreleasepoolobj = objc_autoreleasePoolPush();}
  ~__AtAutoreleasePool() {objc_autoreleasePoolPop(atautoreleasepoolobj);}
  void * atautoreleasepoolobj;
};

int main(int argc, const char * argv[]) {
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool;  // 声明释放池变量
    }
    return 0;
}

// 实际自动释放池的大括号执行处被改写为了
__AtAutoreleasePool()
// 用户代码
~__AtAutoreleasePool()

AutoreleasePoolPage 内部结构如下:

1
2
3
4
5
6
7
8
9
10
class AutoreleasePoolPage 
{
    magic_t const magic;
    id *next;
    pthread_t const thread;
    AutoreleasePoolPage * const parent;
    AutoreleasePoolPage *child;
    uint32_t const depth;
    uint32_t hiwat;
}

AutoreleasePoolPage 实现逻辑

  1. 释放池内部结构是栈,它依据线程存在
  2. 池(栈)中指针是自动释放obj指针,或者 POOL_BOUNDARY 指针
  3. 池释放时,以 POOL_BOUNDARY 为界,先释放内部的 obj
  4. 所有的pool,被组织成双向链表,根据需要添加/移除 pages 5.线程本地存储永远指向 hotPage: 即新存储自动释放obj 的page

调用 push 方法会将一个 POOL_BOUNDARY 入栈,并返回其存放的内存地址 调用 pop 方法时传入一个 POOL_BOUNDARY 内存地址,会从最后一个入栈的对象开始发送 release 消息,直到遇到这个 POOL_BOUNDARY id *next 指向下一个能存放 Autorelease obj 的地址

具体看一下 runtime/NSObject.mm 源码中。

对象调用 autorelease 知道后的释放时机是什么?

从源码可以看 [obj autorelease] 实际上是将 obj 放入到自动释放池。autorelease 的释放时机就是 AutoreleasePool 的释放时机。

1
2
3
4
5
[obj autorelease];
.... 
最终调用的是 
AutoreleasePoolPage::autorelease(obj);
obj 会被加入到自动释放池

实际上 AutoreleasePool 的释放时机是和 Runloop 相关的。Runloop 内部会注册两个和 AutoreleasePool 相关的 observer,

1
2
3
4
5
// 监听 Runloop 进入,最先执行 autoreleasePush()
"<CFRunLoopObserver 0x600000ca4320 [0x7fff8062d610]>{valid = Yes, activities = 0x1, repeats = Yes, order = -2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler (0x7fff48c84b28), context = <CFArray 0x6000033d12c0 [0x7fff8062d610]>{type = mutable-small, count = 1, values = (\n\t0 : <0x7fb41780b038>\n)}}",

// 监听 Runloop 休眠和退出,执行 autoreleasePop()
"<CFRunLoopObserver 0x600000ca43c0 [0x7fff8062d610]>{valid = Yes, activities = 0xa0, repeats = Yes, order = 2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler (0x7fff48c84b28), context = <CFArray 0x6000033d12c0 [0x7fff8062d610]>{type = mutable-small, count = 1, values = (\n\t0 : <0x7fb41780b038>\n)}}"

可以发现: 进入 Runloop 的时候最先执行 autoreleasePush() 创建并进入 pool。 Runloop 进入休眠的时候,执行 autoreleasePop() 释放当前 pool 内的对象。 Runloop 退出的时候,执行 autoreleasePop() 释放当前 pool 内的对象。

谈谈对 iOS 内存管理的理解

思路: MRC - 自己写 retain/release 时代 ARC - 编译器自动生成 RR 代码,简化开发 核心概念 AutoreleasePool 的实现原理。

###

本文由作者按照 CC BY 4.0 进行授权