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汇编指令、编译内存屏障技术、内存垃圾回收技术等多种手段来解决多线程读写的无锁处理方案,既保证了安全,又提升了系统的性能。





posted @ 2022-03-30 10:35  码锋窝  阅读(739)  评论(0编辑  收藏  举报