OC底层原理20:OC底层面试题解析

张建 lol

【面试题1】Runtime Associate 方法关系的对象,需要在dealloc中释放?

当我们对象释放时,会调用 dealloc

  • C++函数释放:objc_cxxDestruce
  • 移除关联属性:_object_remove_associations
  • 将弱引用自动设值nil:weak_clear_no_lock(&table_weak_table。(id)this);
  • 引用计数处理:table_refcnts_erase(this)
  • 销毁对象:free(obj)

所以,关联对象 不需要我们手动移除,会在对象析构即 dealloc 时释放

dealloc源码

dealloc的源码查找路径:dealloc -> _objc_rootDealloc -> rootDealloc -> object_dispose(释放对象) -> objc_destructInstance -> _object_remove_assocations

  • 在objc源码中搜索 dealloc 的源码实现

  • 进入 _objc_rootDealloc 源码实现,主要是对 对象进行析构

  • 进入 rootDealloc 源码实现,发现其中有 关联属性时设置bool值,当有这些条件时,需要进入else流程

  • 进入 object_dispose 源码实现,主要是 销毁实例对象

  • 进入 objc_destructInstance 源码实现,在这里有一处关联属性的方法

  • 进入 _object_remove_assocations 源码,关联属性的移除,主要是 从全局哈希map中招待相关对象的迭代器,然后将迭代器中关联属性,从头到尾的移除

【面试2】方法的调用顺序

类的方法分类方法 重名,如果调用,是什么情况?

  • 如果同名方法是 普通方法,包括 initialize先调用分类方法

    • 因为 分类的方法是在类realize之后attach进去的,插在类的方法的前面,所以 优先调用分类的方法(注意:不是分类覆盖主类!!),所以你看到的只有 类的initialize

* `initialize` 方法什么时候调用?`initialize` 方法也是 `主动调用`,即 `第一次消息时` 调用,为了不影响整个load,可以将需要 `提前加载的数据` 写到 `initialize` 中

【面试3】Runtime是什么?

  • runtime 是由 C和C++ 汇编实现的一套 API,为 OC 语言加入了 面向对象、以及运行时的功能

  • 运行时是指将 数据类型的确定由编译时推到了运行时

    • 举例:extensioncategory 的区别
  • 平时编写的 OC 代码,在程序运行过程中,其实最终会转变成 runtimeC语言代码,runtime是OC的幕后工作者

1. category类别、分类

  • 专门用来给类添加新的方法

  • 不能给类添加成员变量,添加了成员属性,也无法取到

  • 注意:其实 可以通过runtime给分类添加属性,即属性关联,重写setter、getter方法

  • 分类中用 @property 定义变量,只会生成 变量的 setter、getter 方法的 声明不能生成方法实现和带下划线的成员变量

2. extension 类扩展

  • 可以说成是 特殊的类,也可以称作 匿名的类

  • 可以 给类添加成员属性,但是 是私有变量

  • 可以 给类添加方法,也是 私有方法

【面试4】方法的本质,sel是什么?IMP是什么?两者之间的关系又是什么?

  • 方法的本质:发送消息,消息会有以下几个流程

    • 快速查找(objc_msgSend)-> cache_t 缓存消息中查找

    • 慢速查找 - 递归自己|父类 -> lookUpImpOrForward

    • 查找不到消息 -> 动态方法解析 - resolveInstanceMethod

    • 消息快速转发 - forwardingTargetForSelector

    • 消息慢速转发 - methodSignatureForSelector & forwardInvocation

  • sel方法编号 - 在 read_images 期间就编译进了内存,相当于一本书的 目录title

  • imp函数实现指针找imp就是找函数的过程,相当于 书本的页码

  • 查找具体的函数就是想看这本书具体篇章的内容

    • 首先知道想看什么,即目录 title-sel
    • 根据目录查找到对应的页码 - imp
    • 通过页码去翻到具体的内容

【面试5】能否像编译后得到的类中增加实例变量?能否像运行时创建的类中添加实例变量?

  • 不能 像编译后得到的类中增加实例变量
  • 只要类没有注册到内存还是可以添加的
  • 可以 添加属性+方法

【原因】 :编译好的实例变量存储的位置是ro,一旦编译完成,内存结构就完全确定了

【面试6】[self class] 和 [super class]的区别以及原理分析

  • [self class] 就是发送消息 objc_msgSend,消息接收者是 self,方法编号是 class

  • [super class] 本质就是 objc_msgSendSuper,消息的接收者还是 self,方法编号 class,在运行时,底层调用的是 _objc_msgSendSuper2【重点!!!】

  • 只是 objc_msgSendSuper2 会更快,直接跳过 self 的查找

【代码调试】

  • ZJPerson 中的 init 方法中打印这两种 class 调用

  • 进入 [self class] 中的 class 源码

其底层是 获取对象的isa,当前的 对象是ZJPerson,其isa是其同名的 ZJPerson,所以 [self class] 打印的是 ZJPerson

  • [super class]中,其中 super 是语法的 关键字,可以通过 clangsuper 的本质,这是 编译时 的底层源码,其中第一个参数是消息接收者,是一个 __rw_objc_super 结构

  • 底层源码中搜索 __rw_objc_super,是一个中间结构体

  • objc中搜索 objc_msgSendSuper,查看其隐藏参数

  • 搜索 struct objc_super

通过 clang 的底层编译代码可知,当前 消息的接收者self,而 self 等于 ZJPerson,所以 [super class] 进入 class 方法来源后,其中的 self是init后的实例对象,实例对象的 isa 指向的是本类,即消息接收者是 ZJPerson本类

  • 我们再来看 [super class] 在运行时是否如上一步的底层编码所示,是 objc_msgSendSuper,打开汇编调试,调试结果如下

  • 搜索 objc_msgSendSuper2,从注释得知,是 从类开始查找的,而不是父类

  • 查看 objc_msgSendSuper2 的汇编源码,是从 superclass 中的 cache 中查找方法
1
2
3
4
5
6
7
8
ENTRY _objc_msgSendSuper2
UNWIND _objc_msgSendSuper2, NoFrame

ldp p0, p16, [x0] // p0 = real receiver, p16 = class 取出receiver 和 class
ldr p16, [x16, #SUPERCLASS] // p16 = class->superclass
CacheLookup NORMAL, _objc_msgSendSuper2//cache中查找--快速查找

END_ENTRY _objc_msgSendSuper2

【完成回答】

  • [self class] 方法调用的本质是 发送消息,调用class的消息流程,拿到 元类的类型,在这里是因为类已经加载到内存,所以在读取时是一个字符串类型,这个字符串类型是在 map_imagesreadClass 时已经加入表中,所以打印为 ZJPerson

  • [super class] 打印的是 ZJPerson,原因是当前的 super 是一个 关键字,在这里只是调用 objc_msgSendSuper2,其实他的消息接收者和[self class]是一模一样的,所以返回的是 ZJPerson

【面试7】内存平移问题

ZJPerson中有一个 属性zj_name实例方法saySomething,通过下面代码这种方式能否调用实例方法?为什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@interface ZJPerson : NSObject
@property (nonatomic,copy)NSString * zj_name;
- (void)saySomething;
@end
@implementation ZJPerson
- (void)saySomething{
NSLog(@"%s",__func__);
}
@end

// 不一般的调用实例的方法
Class cls = [ZJPerson class];
void *zj = &cls;
[(__bridge id)zj saySomething];

【代码调试】

  • 我们在日常开发中的调用方式是下面这种
1
2
3
// 常见的调用实例方法
ZJPerson * person = [[ZJPerson alloc] init];
[person saySomething];
  • 通过运行发现,是可以执行的,打印结果如下:
1
2
2022-03-16 10:30:27.686250+0800 load+initialize-demo[6296:4626148] -[ZJPerson saySomething]
2022-03-16 10:30:27.686295+0800 load+initialize-demo[6296:4626148] -[ZJPerson saySomething]
  • [person saySomething] 的本质是 对象发送消息,那么当前的 person 是什么?

    • personisa 指向类 ZJPerson, 即 person 的首地址 指向 ZJPerson 的首地址,我们可以通过 ZJPerson 的内存平移找到 cache,在 cache 中查找方法

  • [(__bridge id)zj saySomething] 中的 zj 是来自于 ZJPerson 这个类,然后有一个指针 zj,将其 指向ZJPerson的首地址

所以,person 是指向 ZJPeron 类的结构,zj 也是指向 ZJPerson 类的结构,然后都是在 ZJPerson 中的 methodList 中查找方法

修改:saySomething里面有属性 self.zj_name 的打印

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)saySomething{
NSLog(@"%s - %@",__func__,self.zj_name);
}

// 下面这两种方式调用
// 方式一
Class cls = [ZJPerson class];
void *zj = &cls;
[(__bridge id)zj saySomething];

// 方式二
ZJPerson * person = [[ZJPerson alloc] init];
[person saySomething];
  • 查看这两种调用方式的打印结果,如下所示

    • zj 方式的调用打印的 zj_name<ViewController: 0x100e06eb0>
    • person 方式调用打印的 zj_name(null)

为什么会出现打印不一致的情况?

  • 其中 person 方式的 zj_name 是由于 self指向person的内部结构,然后通过 内存平移8字节,取出去zj_name,即 self指针首地址平移8字节获取

  • 【方式一】其中 zj 指针中没有任何,所以 zj表示8字节指针self.zj_name 的获取,相当于 zj首地址的指针也需要平移8字节找zj_name,那么此时的zj的指针地址是多少?平移8字节获取的是什么?

    • zj 是一个指针,是存在 中的,栈是一个 先进后出 的结构,参数传入就是一个不断压栈的过程

      • 其中 隐藏参数会压入栈,且每个函数都会有两个隐藏参数 (id self,sel _cmd),可以通过 clang 查看底层编译

      • 隐藏参数压栈 的过程,其地址是 递减 的,而 栈是从高地址 -> 低地址分配 的,即 在栈中参数会从前往后一直压

      • super通过clang查看底层的编译,是 objc_msgSendSuper,其第一个参数是一个结构体 __rw_objc_super(self,class_getSuperclass),那么结构体中的属性时如何压栈的?可以通过自定义一个结构体,判断结构体内部成员的压栈情况

      • p &person3

      • p *(NSNumber **)0x000000016d2f59f0

      • p *(NSNumber **)0x000000016d2f59f8

1
2
3
4
struct zj_struct{
NSNumber *num1;
NSNumber *num2;
}zj_struct;

所以图中可以得到 20先加入,再加入10,因此 结构体内部 的压栈情况是 低地址 -> 高 地址递增 的,栈中 结构体内部 的成员是 反向 压入栈,即 低地址 -> 高地址,是递增的

  • 所以到目前为止,栈中 从高地址到低地址 的顺序的:self -> _cmd -> (id)class_getSuperclass(objc_getClass("ViewController")) -> self -> cls -> zj -> person

    • self_cmdviewDidLoad 方法的两个隐藏的参数,是高地址 -> 低地址 正向压栈

    • class_getSuperClassselfobjc_msgSendSuper2 中的结构体成员,是从最后一个成员变量,即低地址 -> 高地址 反向压栈

可以通过下面这段代码打印下栈的存储是否如上面所说

其中为什么 class_getSuperclassviewController,因为 objc_msgSendSuper2 返回的是 当前类,两个self,并不是同一个self,而是栈的指针不同,但是指向同一片内存空间

  • [(__bridge id)zj saySomething] 调用时,此时的 zjZJPerson:0x16bbc1a08 ,所以 saySomething 方法中传入的 self 还是 ZJPerson ,但并不是我们通常认为的 ZJPerson,使我们当前 传入的消息接收者,即 ZJPerson:0x16bbc1a08,是ZJPerson的实例对象,此时操作与普通的ZJPerson是一致的,即 ZJPerson的地址内存平移8字节

    • 普通person流程:person -> zj_name -> 内存平移8字节

    • zj流程:0x16bbc1a08 + 0x8 - > 0x16bbc1a10,即为 self,指向 <ViewController: 0x10470a370>,如下图所示

其中 personZJPerson 的关系是 person是以ZJPerson为模板实例化对象,即alloc有一个指针地址,指向isa,isa指向ZJPerson,它们之间关联是由一个isa指向

而zj也是指向ZJPerson的关系,编译器会人为 zj也是ZJPerson的一个实例化对象,即 zj相当于isa,即首地址,指向ZJPerson,具有和person一样的效果,简单来说,我们已经完全将编译器骗过了,即zj也有zj_name,由于 person查找zj_name是通过内存地址平移8字节,所以zj也是通过内存地址平移8字节去查找zj_name。

哪些东西在栈里?哪些在堆里?

  • alloc 的对象都在

  • 指针、对象 中,例如 person指向的空间 中,person所在的空间

  • 临时变量

  • 属性值,属性随对象是在

【注意】

  • 是从小到大,即低地址 -> 高地址

  • 是从大到小,即从高地址 -> 低地址分配

    • 函数隐藏参数会 从前往后 一直压,即 从高地址 -> 低地址 开始入栈
    • 结构体内部成员是 从低地址 -> 高地址
  • 一般情况下,内存地址有如下规则

    • 0x60 开头表示在
    • 0x70 开头的地址表示在
    • 0x10 开头的地址表示在 全局区域

【面试8】runtime是如何实现weak的,为什么可以自动置为nil?

  • 通过 sideTable 找到我们的 weak_table
  • weak_table 根据 referent 找到或创建 weak_entry_t
  • 然后 append_referrer(entry,referrer) 将我的新弱引用的对象加进去entry
  • 最后 weak_entry_insert,把 entry 加入到我们的 weak_table

底层源码调用流程如下图所示

  • Post title:OC底层原理20:OC底层面试题解析
  • Post author:张建
  • Create time:2020-12-15 15:22:19
  • Post link:https://redefine.ohevan.com/2020/12/15/OC底层原理/OC底层原理20:OC底层面试题解析/
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.