OC-内存管理

直接看看下面的面试题

  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 来转发消息,断开强引用
--------- 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

+ (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 个字节无法保存,则会创建真正的对象来保存该值

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

@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 关键字在设置属性的时指定了其变量内存管理的方式为 copy。

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

// .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;

- (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 位机器上经过优化,如下。

# 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

[图片上传失败...(image-314824-1595316835729)]

weak 实现原理

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

- (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;
}

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 可以看到其被重写为

// 自动释放池变量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 内部结构如下:

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 的释放时机。

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

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

// 监听 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 的实现原理。

  • end
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268