互联网产品思考iOS进阶

「类与对象」关于NSObject对象的内存布局

2020-06-16  本文已影响0人  Jack_lin
0-1-0-1

概述

在上篇文章「类与对象」揭秘本质的第一步讲解Objective-C代码的转换过程,本文在此基础上继续探究一下NSObject对象的内存布局。

如何获取NSObject对象的内存大小?

获取NSObject对象的内存大小,需要用到以下几个函数:

其中,sizeof确切来说并不算做函数,它是一种操作符。

要想回答这个问题,还先得认识几个常用的获取内存大小的工具:class_getInstanceSizemalloc_sizesizeof

废话不多说,先撸几串,"尝尝":

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSObject *obj = [[NSObject alloc] init];
        
        NSLog(@"class_getInstanceSize = %zd", class_getInstanceSize([NSObject class]));
        NSLog(@"malloc_size = %zd", malloc_size((__bridge const void *)(obj)));
        NSLog(@"sizeOf = %zd", sizeof(obj));
    }
    return 0;
}

控制台打印如下:

class_getInstanceSize = 8
malloc_size = 16
sizeOf = 8

咦!? 获取结果居然不一样,那是为什么呢?那就继续探究一下源码实现吧!

class_getInstanceSize

这个是一个runtime提供的API,用于获取类实例对象所占用的内存大小,返回所占用的字节数。

苹果开源网站,找到对应的objc4-756.zip压缩包。

看一下源码实现,在objc-class.mm文件到找到了该方法的实现,如下所示:

size_t class_getInstanceSize(Class cls)
{
    if (!cls) return 0;
    return cls->alignedInstanceSize();
}

好像看不出所以然来,继续查看alignedInstanceSize实现:

// Class's ivar size rounded up to a pointer-size boundary.
uint32_t alignedInstanceSize() {
    return word_align(unalignedInstanceSize());
}

Class's ivar size rounded up to a pointer-size boundary

通过注释发现了蛛丝马迹(ivar size),翻译一下,返回实例对象中成员变量内存大小。说白了,class_getInstanceSize就是获取实例对象中成员变量内存大小。

仔细想一下,实例对象在创建的时候,系统应该就会分配对应的内存空间,那咱继续探究一下,在对象初始化的过程中,是否有对应的内存分配呢?

alloc

对象的创建离不开alloc方法,对象创建的过程中可能存在分配内存空间的方法,一起看下源码。

NSObject.mm类中找到alloc以及allocFromZone方法的实现:

+ (id)alloc {
    return _objc_rootAlloc(self);
}

// Replaced by ObjectAlloc
+ (id)allocWithZone:(struct _NSZone *)zone {
    return _objc_rootAllocWithZone(self, (malloc_zone_t *)zone);
}

找到时机调用的核心方法是:_objc_rootAllocWithZone

id _objc_rootAllocWithZone(Class cls, malloc_zone_t *zone)
{
    id obj;

#if __OBJC2__
    // allocWithZone under __OBJC2__ ignores the zone parameter
    (void)zone;
    obj = class_createInstance(cls, 0);
#else
    if (!zone) {
        obj = class_createInstance(cls, 0);
    }
    else {
        obj = class_createInstanceFromZone(cls, 0, zone);
    }
#endif

    if (slowpath(!obj)) obj = callBadAllocHandler(cls);
    return obj;
}

继续寻找:class_createInstance

id  class_createInstance(Class cls, size_t extraBytes)
{
    return _class_createInstanceFromZone(cls, extraBytes, nil);
}

继续查找:_class_createInstanceFromZone

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或者malloc_zone_calloc函数是需要传入size参数,可以发现size变量来源于下面的代码:

size_t size = cls->instanceSize(extraBytes);

继续看一下instanceSize函数的实现:

size_t instanceSize(size_t extraBytes) {
        size_t size = alignedInstanceSize() + extraBytes;
        // CF requires all objects be at least 16 bytes.
        if (size < 16) size = 16;
        return size;
    }

CF requires all objects be at least 16 bytes.

CoreFoundation 框架要求所有对象至少分配16个字节。

终于看到了希望,当实例对象不足16个字节,系统分配给16个字节,属于系统的硬性规定。

仔细看,会发现alignedInstanceSize函数不就是class_getInstanceSize函数的内部实现。

看到这似乎明白了一些?

malloc_size

这个函数主要获取系统实际分配的内存大小,具体的底层实现也可以在源码libmalloc找到,具体如下:

size_t malloc_size(const void *ptr)
{
    size_t size = 0;

    if (!ptr) {
        return size;
    }

    (void)find_registered_zone(ptr, &size);
    return size;
}

核心的方法是find_registered_zone,具体如下:

static inline malloc_zone_t *
find_registered_zone(const void *ptr, size_t *returned_size)
{
    // Returns a zone which contains ptr, else NULL

    if (0 == malloc_num_zones) {
        if (returned_size) {
            *returned_size = 0;
        }
        return NULL;
    }

    // first look in the lite zone
    if (lite_zone) {
        malloc_zone_t *zone = lite_zone;
        size_t size = zone->size(zone, ptr);
        if (size) { // Claimed by this zone?
            if (returned_size) {
                *returned_size = size;
            }
            // Return the virtual default zone instead of the lite zone - see <rdar://problem/24994311>
            return default_zone;
        }
    }

    malloc_zone_t *zone = malloc_zones[0];
    size_t size = zone->size(zone, ptr);
    if (size) { // Claimed by this zone?
        if (returned_size) {
            *returned_size = size;
        }
        if (!has_default_zone0()) {
            return zone;
        } else {
            return default_zone;
        }
    }

    int32_t volatile *pFRZCounter = pFRZCounterLive;   // Capture pointer to the counter of the moment
    OSAtomicIncrement32Barrier(pFRZCounter); // Advance this counter -- our thread is in FRZ

    unsigned index;
    int32_t limit = *(int32_t volatile *)&malloc_num_zones;
    malloc_zone_t **zones = &malloc_zones[1];
    for (index = 1; index < limit; ++index, ++zones) {
        zone = *zones;
        size = zone->size(zone, ptr);
        if (size) { // Claimed by this zone?
            goto out;
        }
    }
    // Unclaimed by any zone.
    zone = NULL;
    size = 0;
out:
    if (returned_size) {
        *returned_size = size;
    }
    OSAtomicDecrement32Barrier(pFRZCounter); // our thread is leaving FRZ
    return zone;
}

由于该方法涉及到虚拟内存分配的流程,过于复杂,本文就再详细展开了。理解一点即可,这个函数是获取系统实际分配的内存大小。

sizeOf

值得注意的一点是,sizeof是操作符,属于系统特性,不是函数,它的作用对象是数据类型,主要作用于编译时。

因此,它作用于变量时,也是对其类型进行操作。得到的结果是该数据类型占用空间大小,即size_t类型。

struct test
{
    int a;
    char b;
};
NSLog(@"%zd", sizeof([NSObject class]));

sizeof 只会计算类型所占用的内存大小,不会关心具体的对象的内存布局;

例如:在64位架构下,自定义一个NSObject对象,无论该对象生命多少个成员变量,最后得到的内存大小都是8个字节。

应用

通过上面的学习,我们可以很好回答下面的这个经典的问题了:

1、一个NSObject对象占用多少内存?

内存对齐

在系统分配内存的时候,会考虑内存对齐的问题,就像上一节内容里获取结构体struct test占用内存大小时,会在内存对齐的基础上计算。

内存对齐是什么?

在计算机中,内存大小的基本单位是字节,理论上来讲,可以从任意地址访问某种基本数据类型。

但是实际上,计算机并非按照字节大小读写内存,而是以2、4、8的倍数的字节块来读写内存。因此,编译器会对基本数据类型的合法地址作出一些限制,即它的地址必须是2、4、8的倍数。那么就要求各种数据类型按照一定的规则在空间上排列,这就是对齐。

在iOS开发过程中,编译器会自动的进行字节对齐的处理,并且在64位架构下,是以8字节进行内存对齐的。

内存对齐的原则

内存对齐应该是编译器的管辖范围,编译器为程序中的每个数据单元安排在适当的位置上,方便计算机快速高效的进行读取数据。每个平台的编译器都有自己的对齐系数和相应的对齐规则。在iOS中的64位架构下,对齐系数就是8个字节。

1、数据成员对齐

结构体或者共用体中的成员变量中,首个成员变量放在偏移量为0的位置上,后面的成员变量的对齐偏移量是取指定对齐系数和本身该成员变量所占用大小中的较小值,即min(对齐系数,成员变量的内存大小 )

2、数据整体对齐

在结构体或者共用体中的成员变量完成自身的对齐之后,整个结构体或者共用体也需要进行字节对齐处理,一般为min(对齐系数,最大成员变量的内存大小 )的整数倍。

结合上述原则1、2,可以推断出下面的常用原则,以结构体为例:

在熟悉上述对齐原则基础上,默认在64位架构下,举个例子:

例子1
struct object {
    int a; // 4
    NSString *b; // 8
    int c; // 4
    char d; // 1
};

控制台打印:

Align------24

结构体中最大的成员变量占用8个字节,根据上面的对齐原则,最终获得的对齐系数是min(最大成员变量大小8个字节, 对齐系数8个字节) = 8。

不考虑内存对齐的情况下,实际占用4 + 8 + 4 + 1 = 17个字节,考虑字节对齐的情况下,分配24个字节。

具体分配的情况如下表所示:

未对齐时

图-1

对齐时:

图-2
例子2
struct object {
    int a; // 4
    char b; // 1
    int c; // 4
};

控制台打印:

Align------12

根据上面结构体,可以得出需要对齐的字节数为min(对齐系数, 最大成员变量的内存大小) = 4个字节。对齐后的内存分配表如下所示:

图-3

内存对齐的原因

为了减少CPU访问内存的次数,提高计算机性能,一些计算机硬件平台要求存储在内存中的变量按自然边界对齐。

性能上的提升

从内存占用的角度讲,对齐后比未对齐有些情况反而增加了内存分配的开支,是为了什么呢?

数据结构(尤其是栈)应该尽可能地在自然边界上对齐,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。最重要的是提高内存系统的性能。

跨平台

有些硬件平台并不能访问任意地址上的任意数据的,只能处理特定类型的数据,否则会导致硬件层级的错误。

有些CPU(如基于 Alpha,IA-64,MIPS,和 SuperH 体系的)拒绝读取未对齐数据。当一个程序要求这些 CPU 读取未对齐数据时,这时 CPU 会进入异常处理状态并且通知程序不能继续执行。

举个例子,在 ARM,MIPS,和 SH 硬件平台上,当操作系统被要求存取一个未对齐数据时会默认给应用程序抛出硬件异常。所以,如果编译器不进行内存对齐,那在很多平台的上的开发将难以进行。

内存对齐的注意事项

内存分配

在结构体中,声明成员变量的顺序不一致,也会导致最终分配内存大小的不同。

struct object {
    int a; // 4
    NSString *b; // 8
    int c; // 4
};

对齐情况下,系统分配24个字节,具体分配如下:

图-4

调换一下成员变量的声明顺序:

struct object {
    int a; // 4
    int c; // 4
    NSString *b; // 8
};

控制台打印:

Align------16

这种情况下,系统分配16个字节,具体分配如下:

图-5

通过上面对比可以看出,在日常开发中,设计结构的时候,合理调换成员变量的顺序,可以很好地节省内存空间。

跨平台通信

在跨平台通信的过程中,由于不同平台的对齐方式或者对齐系数可能不同,这样就会导致同样的数据结构在不同的平台其内存大小就可能存在不同。

那如何处理呢?有如下两种方式:

1字节对齐

如何实现1字节对齐呢?需要用到伪指令#pragma pack(n)(n为字节对齐数)使得结构体1字节对齐。所谓的1字节对齐,就是取消原有平台的对齐限制,或者认为是重新设置对齐系数为1个字节。

在结构体声明的时候添加上述的伪代码指令,如下所示:

#pragma pack(1) /*1字节对齐*/
struct object {
    int a; // 4
    char b; // 1
    int c; // 4
};
#pragma pack() /*还原默认对齐*/

控制台打印:

Align------9

按照实际占用大小去计算,4+1+4 = 9个字节,确实达到了1字节对齐的目的。

去除字节对齐限制呢?如下所示:

//#pragma pack(1) /*1字节对齐*/
struct object {
    int a; // 4
    char b; // 1
    int c; // 4
};
//#pragma pack() /*还原默认对齐*/

控制台打印:

Align------12

分配的数据又重新变成了12个字节。

在1字节对齐下,确实能够很好的保证跨平台正常的通信,同时也节省了内存空间,但是这样做降低读取的效率,只能按单个字节为单位读取,耗时低效。

还有两种方式也可以定义对齐方式:

1、__attribute__((packed)):消结构在编译过程中的优化对齐,也可以认为是1字节对齐;

struct object {
    int a; // 4
    char b; // 1
    int c; // 4
}__attribute__((packed));

控制台打印:

Align------9

2、__attribute__((aligned(n))):使结构体中的成员变量对齐在n字节的自然边界上,即取最大成员变量的长度和n的最大值作为对齐字节数。

在下面的结构体中,最大的成员变量的大小是4个字节,此时设置n=8,结果如下:

struct object {
    int a; // 4
    char b; // 1
    int c; // 4
}__attribute__((aligned(8)));

控制台打印:

Align------16

得出结果正是按照8字节对齐的。

如果将n设置为1呢?

struct object {
    int a; // 4
    char b; // 1
    int c; // 4
}__attribute__((aligned(1)));

控制台打印:

Align------12

发现最终打印的结果是按照4字节对齐的得出的,因为结构体中最大的成员变量占用4个字节,即max(4, 1) = 4

自己对结构体填充
struct object {
    int a; // 4
    char b; // 1
    char b1[3]; // 自己填充字节
    int c; // 4
};

这样虽然保证了访问效率高,但并不节省空间,同时扩展性不是很好。例如,当字节对齐有变化时,需要填充的字节数可能就会发生变化。

小结

在真正的开发过程中,我们不用过多关注字节对齐的问题,编译器会帮我们处理好。但是,我们能做的是,在设计数据结构时,合理安排成员变量的顺序。具体总结如下:

OC对象的内存布局

通过上述内容的讲解,咱们掌握了OC对象转换成C/C++对象的方法、获取实例对象内存的方法以及内存对齐的前因后果。万事俱备只欠东风了,那就继续探究一下OC对象的内存布局。

主要从几种不同创建对象的场景,进行讲解。

情景一:带有一个成员变量的对象占用内存的大小

在创建好的Mac命令过程中新建一个类Animal,并且利用上述讲解的三个工具查看一下具体占用的内存,具体代码如下:

@interface Animal : NSObject {
    @public
    int _age;
}

@end

@implementation Animal

@end

main.m中打印对应的内存大小:

Animal *animal = [[Animal alloc] init];
NSLog(@"Animal -- class_getInstanceSize = %zd", class_getInstanceSize([animal class]));
NSLog(@"Animal -- malloc_size = %zd", malloc_size((__bridge const void *)(animal)));
NSLog(@"Animal -- sizeOf = %zd", sizeof(animal));

在控制台的打印结果:

Animal -- class_getInstanceSize = 16
Animal -- malloc_size = 16
Animal -- sizeOf = 8

那它的内存是如何计算的呢?那就看一下,OC代码转换成C++语言,是如何构造数据的。

main.m文件所在的目录下,继续执行上述讲解的Clang的命令。

main.cpp文件中,我们搜索查找到Animal类的定义,究其精华如下:

struct NSObject_IMPL {
    Class isa;
};

struct Animal_IMPL {
    struct NSObject_IMPL NSObject_IVARS; //指针对象,占用一个机器字长,占用8个字节
    int _age; // 4个字节
};

发现Animal结构体中自动增加了一个成员变量NSObject_IVARS,即Class类型的成员变量。于是,获取Animal实例对象的内存大小,就等价于获取Animal_IMPL结构体所占用的内存大小。

在上述的讲解中,可以知道获取内存大小方法的区别。malloc_size函数获取系统分配的内存大小,class_getInstanceSize是获取实际占用的内存大小。

通过上述打印结果可以看出,系统分配给Animal实例对象内存是16个字节,实际占用的也是16个字节。看到这,想必疑问重重,实际占用的内存大小不应该是8 + 4 = 12个字节么?这是怎么回事呢?

其实这里涉及一点计算机的知识点——内存对齐。在结构体中,总大小为结构体对最大成员大小的整数倍,如不满足,最后填充字节以满足,可分配的最小内存是结构体中内存占用最大的成员变量的大小。

Animal_IMPL结构体中,占用内存最大的成员变量是NSObject_IVARS,占用的内存是8个字节。成员变量_age占用4个字节,由于不满足内存对齐规则,故实际占用8个字节,实际占用的内存大小就是16个字节。

具体内存分配如下图所示:

图-6

情景二:不同成员变量的对象占用内存的大小

在情景一的基础上,在Animal对象再添加一个成员变量_weight,如下所示:

@interface Animal : NSObject {
    @public
    int _age;
    int _weight;
}

@end

@implementation Animal

@end

控制台打印结果如下所示:

Animal -- class_getInstanceSize = 16
Animal -- malloc_size = 16
Animal -- sizeOf = 8

那它的内存是如何计算的呢?那就看一下,OC代码转换成C++语言,是如何构造数据的。

执行完Clang命令后,在main.cpp文件中,我们搜索查找到Animal类的定义,究其精华如下:

struct NSObject_IMPL {
    Class isa;
};

struct Animal_IMPL {
    struct NSObject_IMPL NSObject_IVARS; // 8个字节
    int _age; // 4个字节
    int _weight; // 4个字节
};
图-7

结构体中成员变量的内存都是连续分配,具体演示如下:

图-8

啊!?怎么还是16个字节呢?难道我多一个成员变量没有分配内存么?请往下看:

图-9

通过图标分析,可以看出两个成员变量正好占用了8个字节,满足内存对齐的原则。

是不是感觉还是有点懵?继续往下看...

情景三:继续添加不同类型的成员变量

添加整型成员变量

@interface Animal : NSObject {
    @public
    int _age;
    int _weight;
    int _height;
}

@end

@implementation Animal

@end

控制台打印结果:

Animal -- class_getInstanceSize = 24
Animal -- malloc_size = 32
Animal -- sizeOf = 8

按照之前的讲解,系统实际分配内存大小是16个字节的整数倍,故malloc_size函数获取内存大小是32个字节。新添加的成员变量占用4个字节,应该是16+4 = 20字节,但是由于内存对齐的原则存在,实际占用16+8 = 24个字节。

图-10

添加字符串型成员变量

@interface Animal : NSObject {
    @public
    int _age;
    int _weight;
    int _height;
    NSString *_name;
}
@end

@implementation Animal

@end

控制台打印:

Animal -- class_getInstanceSize = 32
Animal -- malloc_size = 32
Animal -- sizeOf = 8

这个案例,很容易就能算出实际分配了32个字节。

图-11

看到这,大概掌握了一些规律,接下来难度升级一下。

情景四:调换成员变量声明顺序

情况一:整型变量中掺杂字符串变量

@interface Animal : NSObject {
    @public
    int _age;
    NSString *_name;
    int _weight;
    NSString *_nick;
    int _height;
}

@end

@implementation Animal

@end

控制台打印:

Animal -- class_getInstanceSize = 48
Animal -- malloc_size = 48
Animal -- sizeOf = 8

系统分配了48个字节,实际占用了48个字节。

图-12

情况二:调换一下声明成员变量的顺序

@interface Animal : NSObject {
    @public
    int _age;
    int _weight;
    int _height;
    NSString *_name;
    NSString *_nick;
}
@end

@implementation Animal
@end

控制台打印:

Animal -- class_getInstanceSize = 40
Animal -- malloc_size = 48
Animal -- sizeOf = 8

系统分配了48个字节,实际占用了40个字节。

具体的内存分配表如下:

图-13

情景五:继承体系下的内存分配

新加一个Animal的子类Dog,如下所示:

@interface Animal : NSObject {
    @public
    int _age;
    int _weight;
}
@end

@implementation Animal
@end

@interface Dog : Animal {
    @public
    int _height;
}
@end

@implementation Dog
@end

控制台打印:

Dog -- class_getInstanceSize = 24
Dog -- malloc_size = 32
Dog -- sizeOf = 8

系统分配了32个字节,实际占用了24个字节。

继承关系如下:Dog ---> Animal --> NSObject

那么在这样继承关系下,是如何计算内存大小的呢?

main.cpp中找到一些编译后的数据结构:

struct NSObject_IMPL {
    Class isa; // 8个字节
};

struct Animal_IMPL {
    struct NSObject_IMPL NSObject_IVARS; // 8个字节
    int _age; // 4个字节
    int _weight; // 4个字节
};

struct Dog_IMPL {
    struct Animal_IMPL Animal_IVARS; // 16个字节
    int _height; // 4个字节
};

// 转化精简之后的结构体:
struct Dog_IMPL {
    Class isa; // 8个字节
  int _age; // 4个字节
  int _weight; // 4个字节
    int _height; // 4个字节
};

内存分配表如下:

图-14

小结

本文主要以探索OC对象的内存布局为核心目的。先通过Clang编译器命令将OC代码转化成C/C++代码,了解OC对象的底层结构。紧接着,通过剖析获取内存大小API的源码以及内存对齐的原理,明确了数据结构底层计算内存大小的普适规则。最后,又通过不同场景下的数据举例,验证了不同数据结构内存大小计算规则,进而揭示了OC对象在内存布局上所遵循的通用规律。

在掌握OC对象的内存布局的原理后,可以在以后的开发过程中,更加合理地设计数据结构,更高效的利用系统内存,进而写出健壮性更高的代码。

参考文献

想要关注更多iOS知识,请关注下方公众号。

猿视角
上一篇下一篇

猜你喜欢

热点阅读