iOS 底层对象探索(上)

准备工作

汇编基础

  1. b bl: 跳转指令,方法调用
  2. ret: 函数的返回
  3. ; : 注释

三种寻找源码的方式

1.断点
2.符号断点
在这里插入图片描述在这里插入图片描述

  1. 通过汇编
    在Xcode菜单栏中选择Debug->Debug WorkFlow->Always Show Disassembly,即可显示汇编代码
    在这里插入图片描述

alloc 方法的底层调用流程

alloc -> objc_alloc -> callAlloc -> objc_msgSend -> 
alloc -> objc_rootAlloc -> callAlloc -> _objc_rootAllocWithZone ->
_class_createInstanceFromZone

追踪 alloc

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

User *user = [User alloc];

User *user1 = [user init];
User *user2 = [user init];

打断点控制输出 user1 和 user2 的内存地址,发现内存地址是一样的:
在这里插入图片描述
说明 init 方法不会去开辟内存空间。在 alloc 方法这行打上断点,开启汇编调试,运行代码(注意使用 control + step into,否则会跳过这行代码):
; symbol stub for: objc_alloc: 意思是该地址保存的是 objc_alloc 方法的符号。
在这里插入图片描述
查看 objc 源码(地址在附录),搜索 objc_alloc 发现,fixupMessageRef调用 alloc时,使用的实现是objc_alloc。(sel 代表方法名)
在这里插入图片描述
回到自己的项目添加一个 objc_alloc符号断点,再次调试 alloc 方法,发现跳过了_objc_rootAllocWithZone,直接到了objc_msgSend,先不管,打印一下寄存器 x0 和 x1,确实是 alloc 方法。

  • x0寄存器存放函数返回的值,这里返回 User 的一个实例对象
  • x1寄存器 参数传递给函数,并将函数结果返回,传的参数是 “alloc"
    在这里插入图片描述
    接着增加一个[NSObject alloc]的符号断点(这是所有 alloc 方法的最初实现):
    在这里插入图片描述
    并通过 step into 跳转到方法 _objc_rootAlloc内部,这一次 step over 能执行到_objc_rootAllocWithZone了:
    在这里插入图片描述
    去源码看一下_objc_rootAllocWithZone
    在这里插入图片描述
    方法返回的是 id 类型,return 调用的是 _class_createInstanceFromZone,跳转到该方法:
    在这里插入图片描述
    可以看到返回的是 obj,也就是返回实例对象。下面去验证对象是通过该方法返回的,回到自己的项目汇编调试,通过 step into 进入:
    在这里插入图片描述
    ret、retab、retaa都代表函数返回,并打印寄存器,确实是返回实例对象。

结论:

alloc 方法返回的就是实例对象。

追踪 init

增加 init 方法的符号断点 [NSObject init],进入汇编调试:
在这里插入图片描述
源码查找 init_objc_rootInit 只是返回了 obj
在这里插入图片描述
在这里插入图片描述

结论:

init 只是返回了自身, init 作为工厂方法,目的是让子类继承并重写。比如 NSArray 继承 NSObject, 重写了 init 方法。

追踪 new

重写 init 方法,并给默认值:

@implementation User

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

    return self;
}

@end

通过调试发现通过 new 方法出来的对象也调用了 init:
在这里插入图片描述
找到源码 NSObject 类里的 new 方法,发现也是调用了 calloc,加上 init 方法
在这里插入图片描述
回到自己项目调试 new 方法,按住 control + Step into 发现实际调用的是 objc_opt_new:
在这里插入图片描述
查找源码发现,如果不是__OBJC2__,也会通过objc_msgSend转发给源码中的 new 方法:
在这里插入图片描述

结论:new = alloc + init

优化等级

之前追踪 alloc 时发现的 _class_createInstanceFromZone并没有出现在汇编调试里。这涉及到编译器优化,在 Xcode 找到以下设置:Optimization Level
在这里插入图片描述

debug 模式下优化等级默认是 None,先调试以下代码:

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

汇编调试可以看到数字:
在这里插入图片描述
接下来优化等级调整和 release 一样,汇编调试下:(对应的 Target 一定要选对再改设置)
在这里插入图片描述
发现少了那两个 w8 寄存器存储代码的变量值,这个就是编译优化的效果,代码中声明了变量却没有使用,编译时就被干掉了。没使用的函数也是同理。

- (void)viewDidLoad {
    [super viewDidLoad];

    int result = add(1111, 222);
	// 断点
}

int add(int a, int b) {
    return a + b;
}

没开优化:
在这里插入图片描述
优化后:虽然代码里调用了 add 方法,但是返回值没有被使用,就会被优化
在这里插入图片描述
接下来使用以下方法的返回值:

- (void)viewDidLoad {
    [super viewDidLoad];

    int result = add(1111, 222);
    NSLog(@"result = %d", result);
	// 断点
}

int add(int a, int b) {
    return a + b;
}

调试发现,编译器直接在编译时把结果计算好了,相当于把函数里的实现直接替换到代码中:
在这里插入图片描述

源码调试 alloc

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

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

int test (User *user) {
   
    NSLog(@"%zu",class_getInstanceSize(user.class));
    NSLog(@"%zu",malloc_size((__bridge const void *)(user)));
    
    return 0;
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        User *user = [User alloc]; // 断点
        test(user);
    }
    return 0;
}
  • objc_alloc
    在这里插入图片描述
  • callAlloc,此时走的是底下的分支,说明前面的分支条件不满足,验证了前面汇编调试跳过了_objc_rootAllocWithZone
    在这里插入图片描述
  • alloc
    在这里插入图片描述
  • _objc_rootAlloc,参数 cls 不为 nil
    在这里插入图片描述
  • callAlloc,这次走的另外一个分支
    在这里插入图片描述
  • _objc_rootAllocWithZone
    在这里插入图片描述
  • _class_createInstanceFromZone,返回 obj,运行结束
    在这里插入图片描述

但是,汇编调试时没有发现 callAlloc_class_createInstanceFromZone 的符号调用,这就是编译器优化的效果,哪怕优化水平是 None

通过源码了解了 alloc 的调用顺序:
在这里插入图片描述

对象的创建

_class_createInstanceFromZone 返回对象,查看该方法源码:

  • _class_createInstancesFromZone 中使用 cls->instanceSize() 计算所需内存的大小
    在这里插入图片描述在这里插入图片描述
  • 通过计算出 size,并且最小字节为 16字节;alignedInstanceSize 是字节对齐(64位系统下是8字节)
    模拟 8 字节对齐:
int align_8 (int byte) {
    // byte + 7 : 为了得到超过8字节的部分,
    // 例如 (9 + 7) / 8 = 2, 返回 2 * 8 = 16字节
    return (byte + 7) / 8 * 8;
}

内存对齐

苹果官方的实现是:根据 64 或 32 位系统,进行8字节或4字节对齐。
在这里插入图片描述
这里 & ~WORD_MASK 怎么理解?在 64 位系统下,先进行非运算:

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

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

(x + WORD_MASK) & ~WORD_MASK = (0101 + 0111) & 1000 = 1100 & 1000 = 1000 // 8

通过一个函数兼容 64 位和 32 位系统下的字节对齐,这就是精妙的地方。回到之前的 instanceSize 方法,在字节对齐钱,如果有缓存,会进入另外一个方法cache.fastInstanceSize()中,实现如下:
在这里插入图片描述
可以看到 align16,也就是 16 字节对齐,那么究竟是多少字节对齐的?这里只是计算出需要的大小,最终的创建从_class_createInstanceFromZone中找到 obj = (id)calloc(1, size);这里传入 size 去计算。

calloc

objc 源码中没有 calloc,需要到 libmalloc源码中查找实现:
在这里插入图片描述
在这里插入图片描述
关键方法在于 segregated_size_to_fit(直接是这个结论,具体怎么跳转的逻辑很深)
在这里插入图片描述
这也是个字节对齐的算法:

#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
#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 字节对齐。

  • 为什么要字节对齐?
    • 字节是内存的容量单位。但是,CPU 在读取内存的时候,却不是以字节为单位来读取的,⽽是以“块”为单位读取的,所以⼤家也经常听到⼀块内存,“块”的⼤⼩也就是内存存取的⼒度。如果不对⻬的话,在我们频繁的存取内存的时候,CPU 就需要花费⼤量的精⼒去分辨你要读取多少字节,这就会造成 CPU 的效率低下,如果想要 CPU 能够⾼效读取数据,那就需要找⼀个规范,这个规范就是字节对⻬。
  • 为什么对象内部的成员变量是以 8 字节对⻬,系统实际分配的内存以 16 字节对⻬?
    • 以空间换时间。苹果采取16字节对⻬,是因为 OC 的对象中,第⼀位叫 isa 指针,它是必然存在的,⽽且它就占了8位字节,就算对象中没有其他的属性了,也⼀定有⼀ isa,那对象就⾄少要占⽤ 8 位字节。如果以 8 位字节对⻬的话,如果连续的两块内存都是没有属性的对象,那么它们的内存空间就会完全的挨在⼀起,是容易混乱的。以 16 字节为⼀块,这就保证了 CPU 在读取的时候,按照块读取就可以,效率更⾼,同时还不容易混乱。

对象的本质

  1. 使用 clang 命令编译文件
    在这里插入图片描述 clang -rewrite-objc main.m
    就可得到 main.cpp 文件
  2. 搜索对象名
    在这里插入图片描述
  3. 再搜索 NSObject_IMPL
    在这里插入图片描述
    所以对象的本质是 objc_object 结构体,⾥⾯存储 isa 指针和成员变量的值。

结构体的内存对齐方式

  1. 数据成员对⻬规则:结构(struct)的第⼀个数据成员放在 offset 为 0 的地⽅,以后每个数据成员存储的起始位置要从该成员⼤⼩或者成员的⼦成员⼤⼩的整数倍开始(⽐如 int 为 4 字节,则要从4的整数倍地址开始存储)。
  2. 结构体作为成员:如果⼀个结构⾥有某些结构体成员,则结构体成员要从其内部最⼤元素⼤⼩的整数倍地址开始存储.(struct a ⾥存有struct b,b ⾥有 char, int , double等元素,那 b应该从 8 的整数倍开始存储)。
  3. 收尾⼯作: 结构体的总⼤⼩,也就是sizeof的结果必须是其内部最⼤成员的整数倍,不⾜的要补⻬。

下表是基础数据类型在内存中的占用大小(字节)

类型32位64位
BOOL11
char11
unsigned char11
short22
unsigned short22
int44
unsigned int44
long48
unsigned long48
long long88
NSInteger48
float44
double88
CGFloat48
指针48
struct StructOne {
    // 8 字节,0~7
    double a;
    // 1 字节,9
    char b;
    // 4 字节,从 4 的整数倍开始,12~15
    int c;
    // 2 字节,16~17
    short d;
    // 总字节按最大成员字节的整数倍,所以占 24 字节
}structOne;

struct StructTwo {
    // 8 字节,0~7
    double a;
    // 4 字节,8~11
    int b;
    // 1 字节,12
    char c;
    // 2 字节,14~15
    short d;
    // 总共占 16 字节
}structTwo;

struct StructThree {
    // 8 字节,0~7
    double a;
    // 4 字节,8~11
    int b;
    // 1 字节,12
    char c;
    // 2 字节,14~15
    short d;
    // 4 字节,16~19
    int e;
    // 24 字节,且最大成员占 8 字节,24~47
    struct StructOne myStruct;
    // 总的大小按最大成员所占的 8 字节对齐(结构体不算基本类型),所以占 48 字节
}structThree;

NSLog(@"\n structOne'size = %lu;\n structOne'size = %lu;\n structOne'size = %lu;\n",
              sizeof(structOne),sizeof(structTwo),sizeof(structThree));

附录

objc 源码下载

objc源码地址

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值