iOS底层-alloc方法之旅

794 阅读9分钟

前言

通过汇编调试和源码分析,介绍iOS开发当中alloc方法到底做了什么。

追踪 alloc

实例化一个对象往往是通过 [[xxx alloc] init] 那么alloc和init的区别是什么?将两个方法分开调用,并用2个指针引用

NS_ASSUME_NONNULL_BEGIN

@interface FFPhone : NSObject

@property (nonatomic, copy) NSString * name;

@end

NS_ASSUME_NONNULL_END

#import "FFPhone.h"

@implementation FFPhone

-(instancetype)init {
    if (self = [super init]) {
        self.name = @"init iPhone";
    }
    return self;
}

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSLog(@"Hello World!");
    
        FFPhone *phone = [FFPhone alloc];
				FFPhone *p1 = [phone init];
				FFPhone *p2 = [phone init];
      
    }
    return 0;
}

进行调试:发现这两内存地址居然是一样的。

objc_C1_001

这说明init方法不会去开辟内存空间。在alloc方法这行断点,通过汇编调试看一下初始化的方法调用。重新运行:

汇编简介:机器语言只有0和1, 汇编语言用符合代替0/1,增加可读性 b和bl都是跳转指令,理解为函数的调用 ret: 函数的返回 分号';' 代表注释

; symbol stub for: objc_alloc 意思是该地址保存的是objc_alloc方法的符号。

objc_C1_002

查看objc源码(地址见文末),搜索 objc_alloc 发现,fixupMessageRef 调用 alloc 时,使用的实现是 objc_alloc 。(sel代表方法名)

image-20220416165223525

汇编断点刚才的callq,进入 objc_alloc 方法内部。step over调试的时候,居然跳过了_objc_rootAllocWithZone直接到objc_msgSend,先不管。打印一下寄存器x0和x1,确实是alloc方法。

image-20220416204421176

接着增加一个 [NSObject alloc] 的符号断点

image-20220416204958584

并通过step into 跳转到方法 _objc_rootAlloc 内部:这一次step over能执行到 _objc_rootAllocWithZone 了;

image-20220416205359271

鉴于不知道这是啥,再查源码看一下:

NEVER_INLINE
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);
}

方法返回的是id类型,return调用的是 _class_createInstanceFromZone ,跳转该方法:

/***********************************************************************
* class_createInstance
* fixme
* Locking: none
*
* Note: this function has been carefully written so that the fastpath
* takes no branch.
**********************************************************************/
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)

可以看到返回的是obj:也就是说该方法返回实例对象。

image-20220416205856959

怎么去验证这个对象就是通过该方法返回的呢?回到挂起的汇编调试,通过step into进入:

image-20220416210337831

这里就用到前面提到ret汇编指令(这里遇到的是retab,类似的还有retaa,都代表函数返回),找到并打印寄存器验证,返回的对象如上图。

目前的结论就是,alloc方法就返回实例对象了

追踪 init

增加init方法的符号断点 [NSObject init] ,进入汇编调试:

image-20220416212401586

这里可以看到只有一个孤独的 ret 。打印 x1 ,确实是 init 方法,那这是啥也没干?找找源码:这里 _objc_rootInit 也只是返回了obj。

// Replaced by CF (throws an NSException)
+ (id)init {
    return (id)self;
}

- (id)init {
    return _objc_rootInit(self);
}

// 跳转_objc_rootInit
id
_objc_rootInit(id obj)
{
    // In practice, it will be hard to rely on this function.
    // Many classes do not properly chain -init calls.
    return obj;
}

其实是出于设计模式的工厂模式,init 作为工厂方法,目的只是让子类继承并重写。比如NSArray继承NSObject,重写了init方法。

追踪 new

重写init方法,给默认值:

#import "FFPhone.h"

@implementation FFPhone

-(instancetype)init {
    if (self = [super init]) {
        self.name = @"init iPhone";
    }
    return self;
}

@end

调试发现通过new方法出来的对象也调用了init:

image-20220421093810384

源码NSObject类里找到new方法:也是调用了calloc,加上init方法。至于calloc没传第二个参数?默认是false;

+ (id)new {
    return [callAlloc(self, false/*checkNil*/) init];
}
// callAlloc方法
static ALWAYS_INLINE id
callAlloc(Class cls, bool checkNil, bool allocWithZone=false)

汇编调试new方法,实际调用是objc_opt_new方法:

image-20220421104119109

源码搜索可以看到,如果不是__OBJC2__,也会通过objc_msgSend转发给源码中的new方法

// Calls [cls new]
id
objc_opt_new(Class cls)
{
#if __OBJC2__
    if (fastpath(cls && !cls->ISA()->hasCustomCore())) {
        return [callAlloc(cls, false/*checkNil*/) init];
    }
#endif
		// 消息转发给new方法
    return ((id(*)(id, SEL))objc_msgSend)(cls, @selector(new));
}

结论就是,new = alloc + init

优化等级

之前查看源码的发现的 _class_createInstanceFromZone 似乎没有出现在汇编调试里。这涉及到编译器的优化。在xcode找到以下设置:optimization Level

image-20220416213508251

debug模式优化等级默认是none。先试一下以下代码:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    int a = 99;
    int b = 256;
  	// 此处断点
}

汇编调试可以看到数字:

image-20220416214507265

接下来优化等级调整和release一样,汇编调试下:(对应Target一定要选对在改设置,免得弄错了以为没效果)

image-20220416215039721

发现少了那两个w8寄存器存储代码的变量值;这个就是编译器优化的,代码中声明了变量却没有使用,编译时就被干掉了。没使用的函数也是同理。


- (void)viewDidLoad {
    [super viewDidLoad];
    
    int result = mul(5, 20);
    // 断点
}

int mul(int a, int b) {
    return a * b;
}

没优化:

image-20220416220215391

优化后:虽然代码里调用了 mul 方法,但是如果返回值没使用到,就会被优化。

image-20220416220305527

接下来添加一行打印代码:

NSLog(@"result = %d", result);

再调试:其中 0x64 = 6 * 16 + 4 = 100; 也就是 5 * 20 的计算结果;这说明编译器直接在编译时把结果计算好了。相当于把函数里的实现直接替换到代码中。

image-20220416220714877

源码调试 alloc

接下来通过在源码项目中运行测试代码,并断点调试验证完整的过程:

#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import <malloc/malloc.h>
#import "FFPhone.h"

int logPhone (FFPhone *phone) {
   
    NSLog(@"class: %zu", class_getInstanceSize(phone.class));
    NSLog(@"malloc_size: %zu", malloc_size((__bridge const void *)(phone)));
    
    return 0;
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSLog(@"Hello World!");
    
        FFPhone *phone = [FFPhone alloc]; // 断点
        logPhone(phone);
    }
    return 0;
}

步骤1:objc_alloc

image-20220416230808875

步骤2:callAlloc ,此时走的是底下的分支,说明前面分支的条件不满足。

image-20220416231050071

步骤3:alloc

image-20220416231303361

步骤4:_objc_rootAlloc ;参数cls不为nil

image-20220416232115505

步骤5:callAlloc ,这回走的是另一个分支了

image-20220416232343127

步骤5:_objc_rootAllocWithZone ,这回走的是另一个分支了

image-20220416232917633

步骤5:_class_createInstanceFromZone ,运行结束。

2022-04-16 23:41:59.574433+0800 FFObjcDebug[98337:7503737] Hello World!
2022-04-16 23:41:59.575150+0800 FFObjcDebug[98337:7503737] class: 32
2022-04-16 23:41:59.575291+0800 FFObjcDebug[98337:7503737] malloc_size: 32
Program ended with exit code: 0

但是,汇编调试的时候没看到 callalloc_class_createInstanceFromZone 的符号调用。这依旧是编译器优化的功劳,哪怕优化水平还是none。

通过源码了解alloc调用顺序:

graph TD
		Z{开始}-->A
    A[代码调用 alloc]-->B
    B[方法转换为 objc_alloc]-->C
    C[callAlloc]-->D
    D[通过objc_msgSend调用 alloc]-->E
    E[_objc_rootAlloc]-->F
    F[第二次调用 callAlloc]-->G
    G[走了另一个分支 _objc_rootAllocWithZone]-->H
    H[最终依靠 _class_createInstanceFromZone 返回对象]-->I{结束}

对象的创建

目前为止的方法调用都没有看到什么实质性的代码,直接来到_class_createInstanceFromZone方法源码:


/***********************************************************************
* class_createInstance
* fixme
* Locking: none
*
* Note: this function has been carefully written so that the fastpath
* takes no branch.
**********************************************************************/
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); 分配内存空间,实现如下:

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

通过计算出size,并且最小为16字节;alignedInstanceSize 是字节对齐(64位系统下是8字节)。

通常8字节对齐只需要得到8的倍数:先除8再乘8,对齐还需要计算多出的部分:

// 模拟8字节对齐
int align_8 (int byte) {
    // byte + 7 : 为了得到超过8字节的部分,
    // 例如 (9 + 7) / 8 = 2, 返回 2 * 8 = 16字节
    return (byte + 7) / 8 * 8;
}

8的倍数在计算器中是如何表示的?在二进制中,8的倍数前三位永远是0,比起除法再乘法,位运算效率肯定更高。

image-20220417005232006

对应代码就是先右移再左移3位 >> 3 << 3

内存对齐

苹果官方的实现是:根据64或32位系统,进行8字节或4字节对齐。

#ifdef __LP64__
#   define WORD_SHIFT 3UL
#   define WORD_MASK 7UL // 0111,用于8字节对齐
#   define WORD_BITS 64
#else
#   define WORD_SHIFT 2UL
#   define WORD_MASK 3UL // 0010,用于4字节对齐
#   define WORD_BITS 32
#endif

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

这里 & ~WORD_MASK 怎么理解呢,例如64位系统下,先进行非运算:

~WORD_MASK = ~7UL = ~0111 = 1000;

再进行与运算:0与上任何数都是0,只有1 & 1 = 1。例如23对齐后是24:

(23 + WORD_MASK) & ~WORD_MASK = 0001 1110 & 1000 = 0001 1000 = 24;

通过一个函数兼容64位和32位系统下的字节对齐,这就是值得学习的地方。回到上文方法 instanceSize ,在字节对齐前,如果有缓存,会进入另一个方法 return cache.fastInstanceSize(extraBytes); ,实现如下:

size_t fastInstanceSize(size_t extra) const
{
    ASSERT(hasFastInstanceSize(extra));

    if (__builtin_constant_p(extra) && extra == 0) {
        return _flags & FAST_CACHE_ALLOC_MASK16;
    } else {
        size_t size = _flags & FAST_CACHE_ALLOC_MASK;
        // remove the FAST_CACHE_ALLOC_DELTA16 that was added
        // by setFastInstanceSize
        return align16(size + extra - FAST_CACHE_ALLOC_DELTA16);
    }
}

可以看到 align16,也就是进行16字节对齐(前面8字节对齐源码里有对应实现)。令人疑惑的是,究竟以多少字节进行对齐呢?这里只是计算出需要的大小,最终的创建顺着 _class_createInstanceFromZone 源码接着往下看到 obj = (id)calloc(1, size); ;这里传入size去计算。callocobjc 源码中只到这一步:

void	*calloc(size_t __count, size_t __size) __result_use_check __alloc_size(1,2);

calloc

接下来要到 libmalloc 源码中查找实现:

// 查找 calloc
void	*calloc(size_t __count, size_t __size) __result_use_check __alloc_size(1,2);
// 跳转 calloc
void *
calloc(size_t num_items, size_t size)
{
	return _malloc_zone_calloc(default_zone, num_items, size, MZ_POSIX);
}
// 跳转 _malloc_zone_calloc
MALLOC_NOINLINE
static void *
_malloc_zone_calloc(malloc_zone_t *zone, size_t num_items, size_t size,
		malloc_zone_options_t mzo)
{
	MALLOC_TRACE(TRACE_calloc | DBG_FUNC_START, (uintptr_t)zone, num_items, size, 0);

	void *ptr;
	if (malloc_check_start) {
		internal_check();
	}

	ptr = zone->calloc(zone, num_items, size);

	if (os_unlikely(malloc_logger)) {
		malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE | MALLOC_LOG_TYPE_CLEARED, (uintptr_t)zone,
				(uintptr_t)(num_items * size), 0, (uintptr_t)ptr, 0);
	}

	MALLOC_TRACE(TRACE_calloc | DBG_FUNC_END, (uintptr_t)zone, num_items, size, (uintptr_t)ptr);
	if (os_unlikely(ptr == NULL)) {
		malloc_set_errno_fast(mzo, ENOMEM);
	}
	return ptr;
}

关键方法在于 segregated_size_to_fit

image-20220417014420368

这也是个字节对齐的算法:

#define NANO_MAX_SIZE			256 /* Buckets sized {16, 32, 48, ..., 256} */
#define SHIFT_NANO_QUANTUM		4
#define NANO_REGIME_QUANTA_SIZE	(1 << SHIFT_NANO_QUANTUM)	// 16 = 1 << 4 = 0001 0000
#define NANO_QUANTA_MASK		(NANO_REGIME_QUANTA_SIZE - 1)
#define NANO_SIZE_CLASSES		(NANO_MAX_SIZE/NANO_REGIME_QUANTA_SIZE)

// 方法与宏定义不在同个文件中
static MALLOC_INLINE size_t
segregated_size_to_fit(nanozone_t *nanozone, size_t size, size_t *pKey)
{
	size_t k, slot_bytes;

	if (0 == size) {
		size = NANO_REGIME_QUANTA_SIZE; // Historical behavior
	}
  // size + 15, 然后右移4位,
	k = (size + NANO_REGIME_QUANTA_SIZE - 1) >> SHIFT_NANO_QUANTUM; // round up and shift for number of quanta
	// 再左移4位
  slot_bytes = k << SHIFT_NANO_QUANTUM;							// multiply by power of two quanta size
	*pKey = k - 1;													// Zero-based!

	return slot_bytes;
}

size + 15, 然后右移4位,再左移4位,也就是进行16字节对齐。

在对象内部是8字节对齐,系统分配内存的时候却以16字节进行?这里涉及到以空间换时间的概念:

字节是内存的基本调度单位,但是CPU在读取内存的时候,并不是以字节为单位,而是以为单位。块的大小就是内存读取的粒度。如果不进行字节对齐,当CPU在频繁的读取内存时,要花费大量时间去计算需要分配多少字节。要想读取高效,就要约定一个规范,也就是字节对齐。

苹果采取16字节对齐是因为对象固定的isa指针占8字节,如果以8字节对齐,块是连续的内存,CPU读取的时候,读完8字节需要计算下一个块中的内容是否属于同个对象,这样计算量增加了。而16字节内存消耗大,换来时间消耗小。16字节是平衡时间和空间的后得出来的。比如32字节的对象在一般开发中比较少,而8~16之间比较多。字节对齐越大,浪费内存也就越多。(早期32位系统就是8字节对齐)。

对象的本质

当然是结构体了!新建一个项目:

#import <Cocoa/Cocoa.h>

@interface FFPhone : NSObject

// 价格
@property (nonatomic, assign) int price;
// 型号
@property (nonatomic, copy) NSString *type;

- (void)testPhone;

@end

@implementation FFPhone

- (void)testPhone {}

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // Setup code that might create autoreleased objects goes here.
    }
    return NSApplicationMain(argc, argv);
}

打开终端,cd到该目录。输入指令:

clang -rewrite-objc main.m

就会将main.m文件编译成main.cpp文件(c++语言的)。

#ifndef _REWRITER_typedef_FFPhone
#define _REWRITER_typedef_FFPhone
typedef struct objc_object FFPhone;
typedef struct {} _objc_exc_FFPhone;
#endif

extern "C" unsigned long OBJC_IVAR_$_FFPhone$_price;
extern "C" unsigned long OBJC_IVAR_$_FFPhone$_type;
struct FFPhone_IMPL {
	struct NSObject_IMPL NSObject_IVARS;
	int _price;
	NSString *_type;
};

可以看到对象是 objc_object 结构体类型的。自定义的类,系统会自动生成类名+_IMPL 的结构体,并且包含一个 NSObject_IMPL 结构体类型的属性,包含isa属性:

struct NSObject_IMPL {
	Class isa;
};

也就是 objc_object 存储了isa 和 成员变量的值(接下来理解为数据成员,因为结构体成员保存的不是变量本身,只有值)。

结构体的对齐方式

结构体内存对⻬分为3个部分:

1.基本类型数据成员:结构体的第一个数据成员放在偏移量为0的地址,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小的整数倍开始(int为4字节,则要从4的整数倍地址开始存储,哪怕前面有一点空间浪费)。

2.结构体类型数据成员:结构体成员要从其内部最大元素大小的整数倍地址开始存储。(struct a 包含成员 struct b,struct b 的数据成员有有char、int 、double等类型,那b应该从8的整数倍(double的大小)开始存储)。

3.整体字节对齐:结构体的总大小,也就是sizeof的结果必须是其内部最大成员的整数倍,不足的要补⻬。

验证代码:(这里简化了内存编号,实际例如 0x7abc001 等)


struct FFStructOne {
    // 8字节,0~7 ,
    double a;
    // 1字节,9
    char b;
    // 4字节,12~15
    int c;
    // 2字节,16~17
    short d;
    // 总的大小按最大成员所占的8字节进行对齐,所以占24字节
}structOne;

struct FFStructTwo {
    // 8字节,0~7
    double a;
    // 4字节,8~11
    int b;
    // 1字节,12
    char c;
    // 2字节,14~15
    short d;
    // 总的大小按最大成员所占的8字节进行对齐,所以占16字节
}structTwo;

struct FFStructThree {
    // 8字节,0~7
    double a;
    // 4字节,8~11
    int b;
    // 1字节,12
    char c;
    // 2字节,14~15
    short d;
    // 4字节,16~21
    int e;
    // 24字节,且最大成员占8字节,24~47
    struct FFStructOne ffStruct;
    // 总的大小按最大成员所占的8字节进行对齐,所以占48字节
}structThree;

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // Setup code that might create autoreleased objects goes here.
        NSLog(@"\n structOne'size = %lu;\n structOne'size = %lu;\n structOne'size = %lu;\n",
              sizeof(structOne),sizeof(structTwo),sizeof(structThree));
    }
    return NSApplicationMain(argc, argv);
}

/*
 structOne'size = 24;
 structOne'size = 16;
 structOne'size = 48;
 */

Tips: 为什么要从倍数大小开始读取?整数倍内存读取效率更快。

扩展

objc源码下载

objc源码地址

image-20220417173239308

点开12.2之后再搜索源码版本:

image-20220417173301261

xcode添加符号断点

image-20220416201943817

例如方法的符号 objc_alloc,每次进入符号都会进入汇编调试。