2022iOS面试题之runtime
runtime的数据结构图
对象、类对象、元类对象
类对象存储实例方法列表等信息
元类对象存储类方法列表等信息
根元类的isa指针指向根类对象
————————————————
1.什么是runtime:
Runtime运行时机制,最主要的是消息机制,是一套比较底层的纯C语言API,属于1个C语言库, 包含了很多底层的C语言API。(引入<objc/runtime.h>或者<objc/ message.h>) 在我们平时编写的oc代码中,程序运行过程时,其实最终都是转成了runtime的C语言代码,在 编译的时候并不能决定真正调用哪个西数,只有在真正运行的时候才能根据函数的名称找到对 应的函数来调用。runtime算是OC的幕后工作者,objc_msgSend
2.runtime使用场景:
1. 动态创建一个类(比如KVO的底层实现)objc_allocateClassPair, class_addIvar objc_resisterClassPair 例如:热创建,在程序运行过程中,动态地为某个类添加属性/方法,修改属性值/方法(修改封装的框架) objc_setAssociatedObject, object_setIvar 例如:热更新 2. 遍历一个类的所有成员变量(属性)\所有方法(字典转模型,归解档) class_copyIvarList,class_copyPropertyList,class_ copyMethodList 如YYmodel、 MJextension、JsonModel 3.查找对象 ,实现万能跳转跳转,例如收到推送的通知跳转到对应的页面,
4.方法替换,替换一些系统的方法,来实现自定义需求,如做访问统计。
3.消息传递机制:
01-当前类对象的缓存是否找到,缓存查找是哈希查找 02-当前类方法列表是否命中,已排序好的是二分查找,未排序好的是一般查找 03-逐级父类方法列表是否命中,根据superClass指针逐级查找父类,在父类中也是先查找缓存,再查找父类,直到基类,一旦找到「方法,SEL」的「实现,IMP」,
就会立即缓存,并且执行objc_msgSend函数,执行「方法」的「实现」,都没命中转到消息转发流程。
遍历类方法和实例方法的区别是:在根元类的指向根元类的super会指向根类对象
4.消息的转发机制:三个步骤:
1、「消息动态解析」
对应有两个方法捕获,分别是,「类方法」+ (BOOL)resolveClassMethod:(SEL)sel、「实例方法」+ (BOOL)resolveInstanceMethod:(SEL)sel。
通常的做法是「动态添加一个方法」,并返回
YES 告诉程序已经成功处理消息。如果这两个方法返回 NO ,这个流程会继续往下走。
「动态添加方法」一般调用BOOL class_addMethod(Class cls, SEL name, IMP imp, const char *types);函数来实现。
// 重写 resolveInstanceMethod: 添加对象方法实现 + (BOOL)resolveInstanceMethod:(SEL)sel { if (sel == @selector(xxx_unrecognized_func)) { class_addMethod([self class], sel, (IMP) xxx_unrecognized_func, "v@:"); return YES; } return [super resolveInstanceMethod:sel]; }
2、「消息接收者的重定向」
在第一步的时候,resolveInstanceMethod:没有添加其他函数实现,运行时就会进行下一步,进行「消息接收者的重定向」。
对应的方法是:
forwardingTargetForSelector仅支持一个对象的返回,也就是说消息只能被转发给一个对象
- (id)forwardingTargetForSelector:(SEL)aSelector {
NSLog(@"%@",NSStringFromSelector(aSelector));
// 2、消息转发重定向
if ([NSStringFromSelector(aSelector) isEqualToString:@"run"]) {
return [Animation new];
} else {
return [super forwardingTargetForSelector:aSelector];
}
}
3、「消息重定向」
如果错过了「消息动态解析」、「消息接收者重定向」两次机会后,NSObject提供最后一次机会「消息重定向」。
其大致的过程如下:
a、提供「函数签名」即NSMethodSignature对象,「函数签名」 = 函数的参数 + 返回值类型。Runtime会通过以下方法获取「函数签名」
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
+ (NSMethodSignature *)instanceMethodSignatureForSelector:(SEL)aSelector
b、如果 methodSignatureForSelector: 返回合法的 NSMethodSignature对象;
Runtime会创建 「调用者」即NSInvocation对象,然后并通过 forwardInvocation: 消息通知当前对象,
方法里面指定某个对象来处理某个方法。 对应方法:- (void)forwardInvocation:(NSInvocation *)anInvocation
// 3、生成方法签名 - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { NSString *sel = NSStringFromSelector(aSelector); if ([sel isEqualToString:@"run"]) { return [NSMethodSignature signatureWithObjCTypes:"v@:"]; } else { return [super methodSignatureForSelector:aSelector]; } } // 4、拿到方法签名配发消息 - (void)forwardInvocation:(NSInvocation *)anInvocation { NSLog(@"-----%@",anInvocation); SEL seletor = [anInvocation selector]; Animation *anim = [Animation new]; if ([anim respondsToSelector:seletor]) { [anInvocation invokeWithTarget:anim]; } else { [super forwardInvocation:anInvocation]; } }
c、如果 methodSignatureForSelector: 返回nil,则Runtime 系统会发出 doesNotRecognizeSelector: 消息,程序也就崩溃了。
对应方法:- (void)doesNotRecognizeSelector:(SEL)aSelector;
// 5、抛出友好异常 - (void)doesNotRecognizeSelector:(SEL)aSelector { NSString *selStr = NSStringFromSelector(aSelector); NSLog(@"%@ dose not recognize.......",selStr); }
2.runtime如何通过Selector找到对应的IMP地址?
其实就是消息传递机制本质,
先查找当前实例对应类对象的缓存,
是否有Selector对应的缓存IMP实现,若有,则返回给调用方。
若没有,再根据当前类的方法列表,去查找Selector找到对应的IMP,
当前类如果没有,再根据当前类的superClass指针逐级查找父类方法列表,然后查找Selector所对应的IMP实现
一道面试题:
在当前对象里调用[self class]和[super class]得到的结果是一样的,因为[self class]调用的是objc_msgSend,[super class]调用的是objc_msgSendSuper,objc_msgSuperSend里的接受者是对象自己。
Self代表的是当前对象,但super代表的可不是父类的一个对象,super关键字仅仅是一个编译器指示符,作用是告诉当前消息接受者直接去它的父类里查找方法,而不是从它的类里开始找方法,消息的接受者还是self.
[self class]和[super class]返回的是当前对象所属的类
[self superClass]返回是当前对象所属的类的父类
[super superClass]返回是当前对象所属的类的父类
————————————————
————————————————
performSelector可以向任何对象发送消息,底层实现就是发送消息,延迟到运行时才绑定方法,常规的方法调用是会在编译时检查,performSelector可以调用runtime动态添加的方法。
https://segmentfault.com/a/1190000021994646
我们可以看到,在 performSelector:WithObject:afterDelay: 底层
* 获取当前线程的 NSRunLoop 对象。
* 通过传入的 SEL 、argument 和 delay 初始化一个 GSTimedPerformer 实例对象,GSTimedPerformer 类型里面封装了 NSTimer 对象。
* 然后把 GSTimedPerformer 实例加入到 RunLoop 对象的 _timedPerformers 成员变量中
* 释放掉 GSTimedPerformer 对象
* 以 default mode 将 timer 对象加入到 runloop 中
在子线程中执行会不会调用test方法(因为子线程中的runloop默认是没有启动的状态)
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); dispatch_async(queue, ^{ [self performSelector:@selector(test) withObject:nil afterDelay:2]; }); dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); dispatch_async(queue, ^{ [self performSelector:@selector(test) withObject:nil afterDelay:2]; [[NSRunLoop currentRunLoop] run]; });
注意:在子线程中两者的顺序必须是先执行performSelector延迟方法之后再执行run方法。
因为run方法只是尝试想要开启当前线程中的runloop,但是如果该线程中并没有任何事件(source、timer、observer)的话,并不会成功的开启。
对于该performSelector延迟方法而言,如果在主线程中调用,那么test方法也是在主线程中执行;如果是在子线程中调用,那么test也会在该子线程中执行
performSelector:withObject:只是一个单纯的消息发送,和时间没有一点关系。所以不需要添加到子线程的Runloop中也能执行。
perSelector也可以开启线程
performSelectorInBackground 后台执行
②performSelector:onThread:在指定线程执行
performSelector如何进行多值传输?
1、发送消息objc_msgSend:
2、NSArray
————————————————
3.能否向编译后的类中增加实例变量?
runtime支持在运行时动态添加类,要注意是编译后的类还是动态添加的类
编译前创建的类,已经完成了实例变量的布局,在具体分析RunTime的数据结构当中知道class_ro_t,因为是readonly,class_ro_t 内存布局定好了之后不能再增加了,所以在编译后是无法修改的,所以编译后的类,不能为他增加实例变量
能向运行时创建的类中添加实例变量!
运行时创建的类是可以添加实例变量,调用 class_addIvar 函数,但是得在调用 objc_allocateClassPair 之后,objc_registerClassPair 之前,原因同上。
————————————————
你是否使用过@dynamic这样一个编译器关键字?编译时语言和动态运行时语言的区别
动态运行时语言将函数决议推迟到运行时,实际上就是在运行时再去为方法添加具体的执行函数.
当我们把属性标识为@dynamic时,代表着不需要编译器在编译时为我们生成这个属性的get方法和set方法的具体实现,而是在运行时当我们具体的调用了get和set方法时,再去为它添加具体实现,只有动态运行时语言支持这种功能
编译时语言是在编译期进行函数决议,在编译期就决定了具体实现,在运行时无法修改
————————————————
runtime的缓存方法
//方法缓存在一个cache_t的结构体里 struct cache_t { struct bucket_t *_buckets; // 散列表 数组 mask_t _mask; // 散列表的长度-1,可以理解为最大的数组索引 mask_t _occupied; // 已经缓存的方法数量 }; 通过cache_t结构,可以看出是一个典型的散列表结构 _buckets 用来缓存方法的散列/哈希表 _mask 容量的临界值(散列表长度 - 1) _occupied 表示已经缓存的方法的数量 bucket_t是以数组的方式存储方法列表的 struct bucket_t { private: /// 获取方法实现 explicit_atomic<uintptr_t> _imp; /// 以方法名为key explicit_atomic<SEL> _sel; }
缓存过程:
当第一次调用方法时,消息机制通过isa找到方法之后,会对方法以SEL为key,IMP为value包装成一个bucket_t,缓存在cache的_buckets数组中。 当第一次调用方法的时候,会创建长度为4的散列表,并将_mask的值置为散列表的长度减一,之后通过位运算SEL & mask计算出方法存储的索引值,并将方法缓存在散列表中。
例如,如果计算出下标值为3,那么就将方法直接存储在下标为3的空间中,前面的空间会留空。 当散列表中存储的方法占据散列表长度超过3/4的时候,散列表会进行扩容操作,会创建一个新的散列表并且空间扩容至原来空间的2倍,并重置_mask的值,并且会将原来已经缓存的bucket_t重新计算新的索引值,
按照新的索引值进行存储,最后释放旧的散列表。此时再有方法要进行缓存的话,就需要重新通过SEL & mask计算出下标值之后在按照索引进行存储了。 如果一个类中方法很多,其中很可能会出现多个方法的SEL & mask得到的值为同一个索引值,那么会调用cache_next函数往索引值-1的位值去进行存储,如果索引值-1位空间中有存储方法,
并且key不与要存储的key相同,那么再到前面一位进行比较,直到找到一位空间没有存储方法或者key与要存储的key相同为止,如果到索引为0位置的话,就会到下标为_mask的空间,也就是最大索引处进行重新存储。
当多个线程同时调用一个方法时,可分以下几种情况:
多线程读缓存:读缓存由汇编实现,无锁且高效,由于并没有改变_buckets和_mask,所以并无安全隐患。
多线程写缓存:OC用了个全局的互斥锁(cacheUpdateLock.assertLocked())来保证不会出现写两次缓存的情况。
多线程读写缓存:OC使用了ldp汇编指令、编译内存屏障技术、内存垃圾回收技术等多种手段来解决多线程读写的无锁处理方案,既保证了安全,又提升了系统的性能。