内存管理

内存管理是 iOS 一个经典的话题,每当面试时,你是否只会谈论 weak、assign、strong 关键字?只会说简单的内存泄漏(NSTimer、Block、delegate等引起的内存泄漏)?如果答案是 YES,那么这篇文章也许能帮到您。

本文会借鉴一些工具或者源代码,这里提前列举一下:

  • Runtime。用于分析 Objective-C 对象在 C 语言层面的实现。版本选取了比较老的 706,而不是最新的,是因为相对来说更容易理解

对象地址

iOS 中 创建一个对象代码就会使用到内存,举例:

NSObject *obj = [[NSObject alloc] init];

分配内存的代码在 runtime 中可以看到:


/***********************************************************************
* class_createInstance
* fixme
* Locking: none
**********************************************************************/


static __attribute__((always_inline)) 
id
_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, 
                              bool cxxConstruct = true, 
                              size_t *outAllocatedSize = nil)
{
    if (!cls) return nil;

    assert(cls->isRealized());

    // Read class's info bits all at once for performance
    bool hasCxxCtor = cls->hasCxxCtor();
    bool hasCxxDtor = cls->hasCxxDtor();
    bool fast = cls->canAllocNonpointer();

    size_t size = cls->instanceSize(extraBytes);
    if (outAllocatedSize) *outAllocatedSize = size;

    id obj;
    if (!zone  &&  fast) {
        obj = (id)calloc(1, size);
        if (!obj) return nil;
        obj->initInstanceIsa(cls, hasCxxDtor);
    } 
    else {
        if (zone) {
            obj = (id)malloc_zone_calloc ((malloc_zone_t *)zone, 1, size);
        } else {
            obj = (id)calloc(1, size);
        }
        if (!obj) return nil;

        // Use raw pointer isa on the assumption that they might be 
        // doing something weird with the zone or RR.
        obj->initIsa(cls);
    }

    if (cxxConstruct && hasCxxCtor) {
        obj = _objc_constructOrFree(obj, cls);
    }

    return obj;
}

对象地址、指针内存分布

要了解对象的内存分配,我们只需要了解一下方法 calloc。其实现在 glibc 中,可以看到,其实就是 malloc 外面包一层皮。熟悉 C 语言开发的小伙伴对 malloc 应该不陌生了,他的原理可以在这篇文章中找到:Malloc tutorial

因此 Objective-C 中创建的对象位于堆中,且在内存空间中不是连续的。

扩展阅读:您可以在https://www.geeksforgeeks.org/stack-vs-heap-memory-allocation/了解堆内存和栈内存的区别。

属性(成员变量)内存分布

属性/成员变量在内存中的位置是通过“内存平移”来获取的,我们可以通过如下方式来确认:

假设我们有个类 Kyson,其代码如下:

@interface Kyson : NSObject

@property (nonatomic, copy) NSString *sex;
@property (nonatomic) NSInteger age;
@property (nonatomic) NSInteger height;
@property (nonatomic, copy) NSString *tel;
@property (nonatomic, copy) NSString *IDNumber;

-(void) doSomething;

@end


@implementation Kyson

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

@end

我们使用 clang 的 rewrite-objc 方法来看一下,类 Kyson 在 C 语言层面的实现:

typedef struct objc_object Kyson;

struct NSObject_IMPL {
    Class isa;
};

struct Kyson_IMPL {
    struct NSObject_IMPL NSObject_IVARS;
    NSString * _Nonnull _sex;
    NSInteger _age;
    NSInteger _height;
    NSString * _Nonnull _tel;
    NSString * _Nonnull _IDNumber;
};

extern "C" unsigned long int OBJC_IVAR_$_Kyson$_age __attribute__ \
((used, section ("__DATA,__objc_ivar"))) = __OFFSETOFIVAR__(struct Kyson, _age); 


static NSInteger _I_Kyson_age(Kyson * self, SEL _cmd) {
 return (*(NSInteger *)((char *)self + OBJC_IVAR_$_Kyson$_age)); 
}

从以上代码可知:

  1. Objective-C 中的对象本质是结构体
  2. 结构体的第一个属性是 isa 属性
  3. age 属性是通过内存 + offset的方式实现的(所谓的 内存平移)

测试时间(小试牛刀)

面试题

上面讲了这么多原理,下面来点干货,用于验证读者是否真正懂了对象、类等在内存的分布。
测试代码如下:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    Kyson *cls = [Kyson class];
    void *ks = &cls;
    
    Kyson *kyson = [[Kyson alloc] init];
    
    [kyson doSomething];
    [(__bridge id) ks doSomething];
}

请问,打印的内容是什么?

答案

-[Kyson doSomething]
-[Kyson doSomething]

相信代码 [(__bridge id) ks doSomething]; 也能打印出 -[Kyson doSomething] 是令读者疑惑的地方。

解析

我们仍然用 clangrewrite 命令看一下函数的实现逻辑,函数本质是在类的方法列表里的,而类又是通过对象指向 isa 来获取的,也就是指针平移了 8 个字节。
如此一来,这就很好理解了:对象 kysondoSomething方法,本质是对象 kyson 在内存中偏移 8 个字节,也就是对象 kyson 对应的类 Kyson 中的方法。
如果大家还不能理解,不妨和笔者一样,画一下图就一目了然了:

类和对象图

函数在内存中的分布

函数本身是在栈区的,这点可以通过打断点看出:

iOS中进程的内存布局

可以看到从上到下依次调用函数,这也就是我们所谓的函数“堆栈”的真正含义。说到函数在内存中的分布情况,我们顺便了解一下整个 app 内存分布情况。

iOS中进程的内存布局

测试时间(深入研究)

面试题

这次测试我们只在上次的代码基础上稍微改动一下,代码如下:

@interface Kyson : NSObject

@property (nonatomic, copy) NSString *sex;
@property (nonatomic) NSInteger age;
@property (nonatomic) NSInteger height;
@property (nonatomic, copy) NSString *tel;
@property (nonatomic, copy) NSString *IDNumber;

-(void) doSomething;

@end


@implementation Kyson

-(void) doSomething {
    NSLog(@"%s==%@",__func__,self.sex);
}

@end

可以发现,改动的一行是 doSomething 函数体。

测试代码如下:

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    
    Kyson *cls = [Kyson class];
    void *ks = &cls;
    
    Kyson *kyson = [[Kyson alloc] init];
    kyson.age = 13;
    kyson.tel = @"15265659598";
    kyson.height = 177;
    kyson.sex = @"male";
    
    [kyson doSomething];
    [(__bridge id) ks doSomething];
}

我们再次猜测一下, 命令行中输出的内容:

答案

笔者的 Xcode 输出如下:

-[Kyson doSomething]==male
-[Kyson doSomething]==<ViewController: 0x1040066f0>

相信大家会有一些疑惑,对象 kyson 调用 doSomething 方法输出 -[Kyson doSomething]==male 大家能理解,但 ks 调用 doSomething 方法输出 [Kyson doSomething]==<ViewController: 0x1040066f0> 就无法理解了。

答案分析

答案还是之前的知识点:内存平移。

ks 地址 = kyson 地址 + 8,
ks.sex = ks 地址 + male 的 offset(8) = ks 地址 + 8,也就是 viewcontroller 的地址了

笔者这里再次展示一下函数、函数中的临时变量、堆、堆中的 kyson 对象以及 Kyson 类在内存中的位置:

内存地址分布图

由于 sex 在属性中排名第一,因此 ks.sex 就是 kyson 指针往后 offset 8 即可。

参考

【彻底搞懂C指针】Malloc 和 Free 的具体实现

关于NSObject对象的内存布局,看我就够了!

你一定要搞明白的C函数调用方式与栈原理

Handling low memory conditions in iOS and Mavericks

深入理解iOS Jetsam机制,助力提升Flotsam召回率

iOS 性能优化实践:头条抖音如何实现 OOM 崩溃率下降 50%+

iOS开发+iOS防护+iOS底层原理探究(了解底层原理必看系列)