iOS 升级打怪 - NSObject 内存占用及分配

2,197 阅读6分钟

预备知识

xcrun

一种从命令行调试或者定位 Xcode 的工具链。在命令行输入man xcrun可以看完整文档。

它可以用来查询工具的路径,比如寻找 clang 的文件路径:xcrun -f clang

xcrun-f.png 输入 xcrun --help 可以看到 xcrun 支持的全部命令:

Usage: xcrun [options] <tool name> ... arguments ...

Find and execute the named command line tool from the active developer
directory.

The active developer directory can be set using `xcode-select`, or via the
DEVELOPER_DIR environment variable. See the xcrun and xcode-select manual
pages for more information.

Options:
  -h, --help                  show this help message and exit
  --version                   show the xcrun version
  -v, --verbose               show verbose logging output
  --sdk <sdk name>            find the tool for the given SDK name
  --toolchain <name>          find the tool for the given toolchain
  -l, --log                   show commands to be executed (with --run)
  -f, --find                  only find and print the tool path
  -r, --run                   find and execute the tool (the default behavior)
  -n, --no-cache              do not use the lookup cache
  -k, --kill-cache            invalidate all existing cache entries
  --show-sdk-path             show selected SDK install path
  --show-sdk-version          show selected SDK version
  --show-sdk-build-version    show selected SDK build version
  --show-sdk-platform-path    show selected SDK platform path
  --show-sdk-platform-version show selected SDK platform version

Clang

Clang是一个C++编写、基于LLVM发布于LLVM BSD许可证下的C/C++/Objective-C编译器。可以使用它来将 OC 代码编译成 C++ 代码。

clang -rewrite-objc main.m -o main.cpp:将 main.m 编译成 main.cpp。

LLDB

LLDB 全称 Low Level Debugger,是一款轻量级的高性能调试器,默认内置于Xcode中。

常用命令 p、po。

更多的使用可参见这篇文章

术语解释

  • 大端模式:低位字节存放内存中的高地址端,高位字节存放内存中的低地址端;小端模式则低位字节存放内存中的低地址端,高位字节存放内存中的高地址端。

大小端.png

  • 内存对齐:计算机系统对基本类型数据在内存中存放的位置有限制,它们会要求这些数据的首地址的值是某个数k(通常它为4或8)的倍数,这就是所谓的内存对齐。

了解了上述工具及术语解释后,下面来看一下 NSObject 的底层是如何实现的。

NSObject 底层实现

首先在 main 函数中创建一个 NSObject 对象:NSObject *obj = [NSObject new];,接下来通过 xcrun 和 clang 来编译成 C++ 代码。

xcrun --sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp

xcrun --sdk iphoneos 表示将 SDK 指定为 iphoneos;clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp 表示指定 arm64 架构,将 main.m 的OC 代码编译成 C++代码并输出到 main-arm64.cpp 中。

在 main-arm64.cpp 中我们可以看到 NSObject 是由一个 NSObject_IMPL 的结构体实现:

struct NSObject_IMPL {
    Class isa;
};

通过上面的结构体可以看到 NSObject_IMPL 中只包含一个 Class 类型的 isa 字段。通过代码中可查询得知 Class 是一个指针类型:

typedef struct objc_class *Class;

NSObject 只包含一个指针类型的 isa 字段,所以它实际需要的内存是 8 bytes, 那 obj 系统分配的内存是 8 bytes 吗?它实际占用的内存又是多少呢?

在解答该问题前先明确两个概念:实际需要内存和系统分配内存。实际需要内存指的是对象所需要的内存,而系统分配的内存则值得是系统分配给该对象的内存,系统分配内存 >= 实际需要内存。

这就好比高铁的容客量(系统分配内存)和实际乘客量(实际需要内存)。假设今天高铁只有 64 个乘客,但高铁上的座位该是多少还是多少,它不会因为乘客的变化而变化。

首先我们通过malloc_size() 函数来看一下 obj 分配了多少内存空间:

#import <malloc/malloc.h>

NSObject *obj = [NSObject new];
NSLog(@"%zd", malloc_size(( **__bridge** **const** **void** *)(obj))); // print 16

通过打印可以得知 obj 虽然只需要 8 bytes,但系统还是分配了 16 bytes 给它。

系统分配了 16 bytes ,那 obj 到底使用了多少字节呢?我们可以通过 size_t class_getInstanceSize(Class cls); 来得知,

#import <objc/runtime.h>
NSLog(@"%zd", class_getInstanceSize([obj class])); // print 8

由打印可以看出,obj 使用的和它实际需要的都是 8 bytes ,那为什么系统会给它分配 16 bytes呢?带着这个疑问来查看一下 objc4 的源码版本:objc4-818.2

allocWithZone:

截屏2021-10-12 下午1.29.48.png

上图可以看出 alloc 底层调用的 allocWithZone:,打开下载的 objc4 源码可以在 NSObject.mm 文件找到其实现:

  • allocWithZone: 代码实现:
+ (id)allocWithZone:(struct _NSZone *)zone {
    return _objc_rootAllocWithZone(self, (malloc_zone_t *)zone);
}
  • _objc_rootAllocWithZone 代码实现:
id
_objc_rootAllocWithZone(Class cls, malloc_zone_t *zone __unused) {
    // allocWithZone under __OBJC2__ ignores the zone parameter
    return _class_createInstanceFromZone(cls, 0, nil,
                                         OBJECT_CONSTRUCT_CALL_BADALLOC);
}
  • _class_createInstanceFromZone 代码实现:
static ALWAYS_INLINE id
_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone,
                              int construct_flags = OBJECT_CONSTRUCT_NONE,
                              bool cxxConstruct = true,
                              size_t *outAllocatedSize = nil)
{
    ASSERT(cls->isRealized());

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

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

    id obj;
    if (zone) {
        obj = (id)malloc_zone_calloc((malloc_zone_t *)zone, 1, size);
    } else {
        obj = (id)calloc(1, size);
    }
    if (slowpath(!obj)) {
        if (construct_flags & OBJECT_CONSTRUCT_CALL_BADALLOC) {
            return _objc_callBadAllocHandler(cls);
        }
        return nil;
    }

    if (!zone && fast) {
        obj->initInstanceIsa(cls, hasCxxDtor);
    } else {
        // Use raw pointer isa on the assumption that they might be
        // doing something weird with the zone or RR.
        obj->initIsa(cls);
    }

    if (fastpath(!hasCxxCtor)) {
        return obj;
    }

    construct_flags |= OBJECT_CONSTRUCT_FREE_ONFAILURE;
    return object_cxxConstructFromClass(obj, cls, construct_flags);
}

通过上述的 size = cls->instanceSize(extraBytes); 这句代码可以看出 size 取的是 instanceSize 函数的值。

  • instanceSize 代码实现
inline size_t instanceSize(size_t extraBytes) const {
    if (fastpath(cache.hasFastInstanceSize(extraBytes))) {
        return cache.fastInstanceSize(extraBytes);
    }

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

上述代码 if (size < 16) size = 16; 可以得知对象内存占用最小为 16 bytes。该代码上面的注释也写得很清楚,CF 要求所有的对象内存最小为 16 bytes。

因此,虽然 obj 实际需要的是 8 bytes,但系统会分配给它 16 bytes。

下图为 allocWithZone 调用流程:

allocWithZone-flow.png

分析实际中的例子

下述代码中 good 的实际占用内存和系统分配内存分别为多少?

@interface Goods : NSObject
{
    int _count;
    NSString *_name;
}
@end


Goods *good = [Goods alloc];
NSLog(@"%zd", malloc_size((__bridge const void *)(good))); 
NSLog(@"%zd", class_getInstanceSize([good class])); 

计算一下可以得出 good 各字段需要的内存:isa(8) + _count(4) + _name(8) = 20 bytes。按道理讲 class_getInstanceSize 返回的应该是 20 bytes,但实际上确实 24 bytes,这是为什么呢?这是因为内存对齐的原因。

结构体的内存为各字段占内存最大的倍数,而 good 的 isa 和 name 都是 8 bytes,所以它应该是 8 的倍数 24 bytes。

内存对齐

打开项目搜索 class_getInstanceSize,可以在 objc-class.mm 文件中找到其实现:

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

通过观看 alignedInstanceSize 函数的名字也可以看出,它的作用就是用来内存对齐的。

  • alignedInstanceSize 代码实现:
// Class's ivar size rounded up to a pointer-size boundary.
uint32_t alignedInstanceSize() const {
    return word_align(unalignedInstanceSize());
}

通过 unalignedInstanceSize 获取未对齐的大小,通过 word_align 函数内存对齐。

  • unalignedInstanceSize 代码实现:
uint32_t unalignedInstanceSize() const {
    ASSERT(isRealized());
    return data()->ro()->instanceSize;
}
  • word_align 代码实现:
#   define WORD_SHIFT 3UL
#   define WORD_MASK 7UL

static inline size_t word_align(size_t x) {
    return (x + WORD_MASK) & ~WORD_MASK;
}

假设 x 为上述计算的 20 bytes,我们来推导一下 24 bytes 是如何计算出来的:

1)(x + WORD_MASK):20 + 7 = 27(二进制:0001 1011)

2)~WORD_MASK:对 7 进行取反为 1111 1000

3)1111 1000 & 0001 1011 = 0001 1000(24)

计算过程:

截屏2021-10-12 下午2.32.39.png 如果 x 为 24 bytes 的话,计算出来的结果还是 24 bytes,感兴趣的小伙伴可以自己计算一下。

  • class_getInstanceSize 调用流程图:

class_getInstane-flow.png

那 malloc_size 返回的是多少呢?答案是 32 bytes。具体原因是 Apple 系统中的 malloc 函数分配内存空间时,内存是根据一个 bucket 的大小来分配的。bucket的大小是16,32,48,64,80 等,可以看出系统是按16的倍数来分配对象的内存大小的。

64 位编译器下基本类型所占字节数

char: 1个字节
char*(即指针变量): 8个字节
short int: 2个字节
int: 4个字节                     
unsigned int: 4个字节
long: 8个字节                  
long long: 8个字节            
unsigned long long: 8个字节
float: 4个字节
double: 8个字节

总结

  • NSObject 的底层由 NSObject_IMPL 实现。
  • 对象实际占用的内存与系统分配的内存并不总是一致的。
  • 内存对齐。

参考