Runtime是近年来面试遇到的一个高频方向,也是我们平时开发中或多或少接触的一个领域,那么什么是runtime呢?它又可以用来做什么呢?
什么是Runtime?平时项目中有用过么?
OC是一门动态性比较强的编程语言,允许很多操作推迟到程序运行时再进行
OC的动态性就是由Runtime来支撑和实现的,Runtime是一套C语言的API,封装了很多动 态性相关的函数
平时编写的OC代码,底层都是转换成了Runtime API进行调用
具体应用
利用关联对象(AssociatedObject)给分类添加属性
遍历类的所有成员变量(修改textfield的占位文字颜色、字典转模型、自动归档解档)
交换方法实现(交换系统的方法)
利用消息转发机制解决方法找不到的异常问题
1、详解isa
我们在研究对象的本质的时候提到过isa,当时说的是isa是个指针,存储的是个类对象或者元类对象的地址,实例对象的isa指向类对象,类对象的isa指向元类对象。确实,在arm64架构(真机环境)前,isa单纯的就是一个指针,里面存储着类对象或者元类对象地址,但是arm64架构后,系统对isa指针进行了优化,我们在源码中可以探其结构:
可以看到,isa是个isa_t类型的数据,我们在点进去看一下isa_t是什么数据:
isa_t是个union结构,里面包含了一个结构体,结构体里面是个宏ISA_BITFIELD,我们看看这个宏是什么?
也就是这个机构体里面包含很多东西,但是究竟是什么东西要根据系统来确定。
那么在arm64架构下,isa指针的真实结构是:
在我们具体分析isa内部各个参数分别代表什么之前,我们需要弄清楚这个union是什么呢?我们看着这个union和结构体的结构很像,这两者的区别如下↓↓
union:共用体,顾名思义,就是多个成员共用一块内存。在编译时会选取成员中长度最长的来声明。 共用体内存=MAX(各变量)
struct:结构体,每个成员都是独立的一块内存。 结构的内存=sizeof(各变量之和)+内存对齐
也就是说,union共用体内所有的变量,都用同一块内存,而struct结构体内的变量是各个变量有各个变量自己的内存,举例说明:
我们分别定义了一个共用体test1和一个结构体test2,里面都各自有八个char变量,打印出来各自占用内存我们发现共用体只占用了1个内存,而结构体占用了8个内存,
其实结构体占用8个内存很好理解,8个char变量,每个char占用一个,所以是8;而union共用体为什么只占用一个呢?这是因为他们共享同一个内存存储东西,他们的内存结构是这样的:
我们看到te就一个内存空间,也就是所有的公用体成员公用一个空间,并且同一时间只能存储其中一个成员变量的值,这一点我们可以打断点或打印进行确认:
我们发现,第一次打印的时候,bdf这些值都是1的打印出来都是0,这是因为当te.g = '0',执行完后,这个内存存储的是g的值0,所以访问的时候打印结果都是0。第二次打印同理,te.h执行完内存中存储的是1,再访问这块内存那么得到的结果都会是1。所以我们从这也可以看出,union共用体就是系统分配一个内存供里面的成员共同使用,某一时间只能存储其中某一个变量的值,这样做相比结构体而言可以很大程度的节省内存空间。
既然我们已经知道isa_t使用共用体的原因是为了最大限度节省内存空间,那么各个成员后面的数字代表什么呢?这就涉及到了位域.
我们看到union共用体为了节省空间是不断的进行值覆盖操作,也就是新值覆盖旧值,结合位域的话可以更大限度的节约内存空间还不用覆盖旧值。我们都知道一个字节是8个bit位,所以位域的作用就是将字节这个内存单位缩小为bit位来存储东西。我们把上面这个union共用体加上位域:
上面这段代码的意思就是,abcdefgh这八个char变量不再是不停地覆盖旧值操作了,而是将一个字节分成8个bit位,每个变量一个bit位,按照顺序从右到左一次排列。
我们都知道char变量占用一个字节,一个字节有8个bit位,也就是char变量有8位,那么te和te2的内存结构如下所示:
这个结构我们也可以通过打印来验证:te占用一个字节位置,内存地址对应的值是0xaa,转换成二进制正好是10101010,也就是a~h存储的值。
我们可以看到,现在是将一个字节中的8个bit位分别让给8个char变量存储数据,所以这些char变量存储的数据不是0就是1,可以看出来这种方式非常省内存空间,将一个字节分成8个bit位存储东西,物尽其用。所以我们根据isa_t结构体中的所占用bit位加起来=64可以得知isa指针占用8个字节空间。
虽然位域极大限度的节省了内存空间,但是现在面临着一个问题,那就是如何给这些变量赋值或者取值呢?普通结构体中因为每个变量都有自己的内存地址,所以直接根据地址读取值即可, 但是union共用体中是大家共用同一个内存地址,只是分布在不同的bit位上,所以是没有办法通过内存地址读取值的,那么这就用到了位运算符,我们需要知道以下几个概念:
&:按位与,同真为真,其余为假
|:按位或,有真则真,全假则假
[:左移,表示左移动一位 (默认是00000001 那么1[1 则变成了00000010 1[2就是00000100)
~:按位取反
掩码 : 一般把用来进行按位与(&)运算来取出相应的值的值称之为掩码(Mask)。如 #define TallMask 0b00000100 :TallMask就是用来取出右边第三个bit位数据的掩码
好,那么我们来看下这些运算符是怎么可以做到取值赋值的呢?比如说我们上面的te共用体内有8个char,要是我们想出去char b的值怎么取呢?这就用到了&:
按位与上1[1 就可以取出b位的值了,b是1那么结果就是1,b是0那么结果就是0;
同理,当我们为f设置值的时候,也是类似的操作,就是在改变f的值的同时不影响其他值,这里我们要看赋的值是0还是1,不同值操作不同:
所以,这就是共同体中取值赋值的操作流程,那么我们接下来回到isa指针这个结构体中,看一下它里面的各个成员以及怎么取赋值的↓↓
/nonpointer
0,代表普通的指针,存储着Class、Meta-Class对象的内存地址
1,代表优化过,使用位域存储更多的信息
/
uintptr_t nonpointer : 1; \
/has_assoc:是否有设置过关联对象,如果没有,释放时会更快/
uintptr_t has_assoc : 1; \
/是否有C++的析构函数(.cxx_destruct),如果没有,释放时会更快/
uintptr_t has_cxx_dtor : 1; \
/存储着Class、Meta-Class对象的内存地址信息/
uintptr_t shiftcls : 33; /MACH_VM_MAX_ADDRESS 0x1000000000/ \
/用于在调试时分辨对象是否未完成初始化/
uintptr_t magic : 6; \
/是否有被弱引用指向过,如果没有,释放时会更快/
uintptr_t weakly_referenced : 1; \
/对象是否正在释放/
uintptr_t deallocating : 1; \
/里面存储的值是引用计数器减1/
uintptr_t has_sidetable_rc : 1; \
/
引用计数器是否过大无法存储在isa中
如果为1,那么引用计数会存储在一个叫SideTable的类的属性中
/
uintptr_t extra_rc : 19;
我们看到,isa指针确实做了很大的优化,同样是占用8个字节,优化后的共用体不仅存放这类对象或元类对象地址,还存放了很多额外属性,接下来我们对这个结构进行验证:需要注意的是因为是arm64架构 所以这个验证需要是ios项目且需要运行在真机上 这样才会得出准确的结果
首先,我们来验证这个shiftcls是否就是类对象内存地址。
我们定义了一个dog对象,我们打印它的isa是0x000001a102a48de1
从上面的分析我们得知,要取出shiftcls的值需要isa的值&ISA_MASK(这个isa_mask在源码中有定义),得出$1 = 0x000001a102a48de0
而$1的地址值正是我们上面打印出来Dog类对象的地址值,所以这也验证了isa_t的结构。
我们还可以来看一下其他一些成员,比如说是否被弱指针指向过?我们先将上面没有被weak指向过的数据保存一下,其中红色框中的就是这个属性,0表示没有被指向过
然后我们修改代码,添加弱指针指向dog:weak Dog weaKDog = dog;
注意:只要设置过关联对象或者弱引用引用过对象,has_assoc或weakly_referenced的值就会变成1,不论之后是否将关联对象置为nil或断开弱引用。
发现确实由0变成了1,所以可以验证isa_t的结构,这个实验要确保程序运行在真机才能出现这个结果。所以arm64后确实对isa指针做了优化处理,不在单纯的存放类对象或者元类对象的内存地址,而是除此之外存储了更多内容。
2、class的具体结构
//代码效果参考:http://hnjlyzjd.com/hw/wz_24685.html
我们之前在讲分类的时候讲到了类的大体结构,如下图所示:就如我们之前讲到的,当我们调用方法的时候是从bits中的methods中查找方法,分类的方法是排在主类方法前面的,所以调用同名方法是先调用分类的,而且究竟调用哪个分类的方法要取决于编译的先后顺序等等:
那么这个rw_t中的methods和ro_t中的methods有什么不一样呢?
首先,ro_t中methods,是只包含原始类的方法,不包括分类的,而rw_t中的methods即包含原始类的也包含分类的;
其次,ro_t中的methods只能读取不能修改,而rw_t中的methods既可以读取也可以修改,所以我们今后在动态添加方法修改方法的时候是在rw_t中的methods去操作的;
然后,ro_t中的methods是个一维数组,里面存放着method_t(对方法/函数的封装,即一个method_t代表一个方法或函数),而rw_t中的methods是个二维数组,里面存放着各个分类和原始类的数组,分类和原始类的数组中存放着method_t。即:
我们也可以在源码中找到rw_t和ro_t的关系,
static Class realizeClass(Class cls)
{
runtimeLock.assertLocked();
const class_ro_t ro;
class_rw_t rw;
Class supercls;
Class metacls;
bool isMeta;
if (!cls) return nil;
if (cls->isRealized()) return cls;
assert(cls == remapClass(cls));
// 最开始cls->data是指向ro的
ro = (const class_ro_t )cls->data();
if (ro->flags & RO_FUTURE) {
// rw已经初始化并且分配内存空间
rw = cls->data(); // cls->data指向rw
ro = cls->data()->ro; // cls->data()->ro指向ro 即rw中的ro指向ro
cls->changeInfo(RW_REALIZED|RW_REALIZING, RW_FUTURE);
} else {
// 如果rw并不存在,则为rw分配空间
rw = (class_rw_t )calloc(sizeof(class_rw_t), 1);// 分配空间
rw->ro = ro;// rw->ro重新指向ro
rw->flags = RW_REALIZED|RW_REALIZING;
// 将rw传入setData函数,等于cls->data()重新指向rw
cls->setData(rw);
}
}
首先,cls->data(即bits)是指向存储类初始化信息的ro_t的,然后在运行过程中创建了class_rw_t,等rw_t分配好内存空间后,开始将cls->data指向了rw_t并将rw_t中的ro指向了存储初始化信息的ro_t。
那么ro_t和rw_t中存储的这个method_t是个什么结构呢?我们阅读源码发现结构如下,我们发现有三个成员:name、types、imp,我们一一来看:
name,表示方法的名称,一般叫做选择器,可以通过@selector()和sel_registerName()获得。
/
比如test方法,它的SEL就是@selector(test);或者sel_registerName("test");需要注意的一点就是不同类中的同名方法,它们的方法选择器是相同的,比如A、B两个类中都有test方法,那么这两个test方法的名称都是@selector(test);或者sel_registerName("test");
/
types,表示方法的编码,即返回值、参数的类型,通过字符串拼接的方式将返回值和参数拼接成一个字符串,来代表函数返回值及参数。
/
比如ViewDidload方法,我们都知道它的返回值是void,参数转为底层语言后是self和_cmd,即一个id类型和一个方法选择器,那么encode后就是v16@0:8(它所表示的意思是:返回值是void类型,参数一共占用16个字节,第一个参数是@类型,内存空间从0开始,第二个参数是:类型,内存空间从8开始),当然这里的数字可以不写,简写成V@:
/
关于更多encode规则,可以查看下面这个表:
当然除了自己手写外,iOS提供了@encode的指令,可以将具体的类型转化成字符串编码。
NSLog(@"%s",@encode(int));
NSLog(@"%s",@encode(float));
NSLog(@"%s",@encode(id));
NSLog(@"%s",@encode(SEL));
// 打印内容
Runtime-test【25275:9144176】 i
Runtime-test【25275:9144176】 f
Runtime-test【25275:9144176】 @
Runtime-test【25275:9144176】 :
imp,表示指向函数的指针(函数地址),即方法的具体实现,我们调用的方法实际上最后都是通过这个imp去进行最终操作的。
3、方法缓存
我们在分析清楚方法列表和方法的结构后,我们再来看一下方法的调用是怎么一个流程呢?是直接去方法列表里面遍历查找对应的方法吗?
其实不然,我们在分析类的结构的时候,除了bits(指向类的具体信息,包括rw_t、ro_t等等一些内容)外,还有一个方法缓存:cache,用来缓存曾经调用过的方法
所以系统查找对应方法不是通过遍历rw_t这个二维数组来寻找方法的,这样做太慢,效率太低,系统是先从方法缓存中找有没有对应的方法,有的话就直接调用缓存里的方法,根据imp去调用方法,没有的话,就再去方法数组中遍历查找,找到后调用并保存到方法缓存里,流程如下:
那么方法是怎么缓存到cache中的呢?系统又是怎么查找缓存中的方法的呢?我们通过源码来看一下cache的结构:
散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
我们可以看到,cache_t里面就三个成员,后两个代表长度和数量,是int类型,肯定不是存储方法的地方,所以方法应该是存储在_buckets这个散列表中。散列存储的是一个个的bucket_t的结构体,那么这个bucket_t又是个什么结构呢?
所以cache_t底部结构是这样的:
我们看到,bucket_t就两个值,一个key一个imp,key的话就是方法名,也就是SEL,而imp就是Value,也就是当我们调用一个方法是来到方法缓存中查找,通过比对方法名是不是一致,一致的话就返回对应的imp,也就是方法地址,从而可以调用方法,那么这个散列表是怎么查找的呢?难道也是通过遍历吗?
我们通过阅读源码来一探究竟:
通过上面代码的阅读,我们可以知道系统在cache_t中查找方法并不是通过遍历,而是通过方法名SEL&mask得到一个索引,直接去读数组索引中的方法,如果该方法的SEL与我们调用的方法名SEL一直,那么就返回这个方法,否则就一直向下寻找直到找完为止。
好,既然取值的时候不是遍历,而是直接读的索引,那么讲方法存储到缓存中也肯定是通过这种方式了,直接方法名&mask拿到索引,然后将_key和_imp存储到对应的索引上,这一点我们通过源码也可以确认:
我们看到无论是存还是读,都是调用了find函数,查看SEL&mask对应的索引的方法,不合适的话再向下寻找直到找到合适的位置。
那么这里有两个疑问,为什么SEL&mask会出现不是该方法名(读)或者不为空(写)的情况呢?散列表扩容后方法还在吗?
首先,SEL&mask这个问题,是因为不同的方法名&mask可能出现同一个结果,比如test方法的SEL是011,run方法的SEL是010,mask是010,那么无论是test的SEL&mask还是run的SEL&mask 记过都是010,如果大家都存在这个索引里面是会出问题的,所以为了解决这个索引重复的问题需要先做判断,即拿到索引后先判断这个索引对应的值是不是你想要的,是的话你拿走用,不是的话向下继续找,方法缓存也是同样的道理。我们先调用test方法,缓存到010索引,再调用run方法,发现010位置不为空了,那就判断010下面的索引是否为空,为空的话就将run方法缓存到这个位置。
关于散列表扩容后,缓存方法在不在的问题,通过源码就可以知道,旧散列表已经释放掉了,所以是不存在的,再次调用的时候就得重新去rw_t中遍历找方法然后重新缓存到散列表中,比如下面这个例子:
更正更正更正
我们前面讲到当SEL&mask出来一个索引发现被占用或者不是我想要的时候,系统是向索引下一位再次寻找,这个地方失误了,不是向下是向上寻找,这个地方看源码的时候忽略了条件,在x86或者i386架构中是向下寻找,在arm64架构中是向上寻找:(因为上面图片资源都已经删掉了就没有再更改,这里需要注意一下)
到现在我们清楚了,那就是散列表中并不是按照索引依次排序或者遍历索引依次读取,那么就会出现个问题,因为SEL&mask是个小于mask的随机值且散列表存储空间超过3/4的时候就要扩容,那就会导致散列表中有一部分空间始终被限制。确实,散列表当分配内存后,每个地方最初都是null的,当某个位置的索引被用到时,对应的位置才会存储方法,其余位置仍处于空闲状态,但是这样做可以极大提高查找速度(比遍历快很多),所以这是一种空间换时间的方式。
4、方法的传递过程
我们现在已经清楚方法的调用顺序了,实现从缓存中找没有的话再去rw_t中找,那么在没有的话就去其父类中找,父类中查找也是如此,先去父类中的cache中查找,没有的话再去父类的rw_t中找,以此类推。如果查找到基类还没有呢?难道就直接报unrecognized selector sent to instance 这个经典错误吗?
其实不是,方法的传递主要涉及到三个部分,这也是我们平时用得最多以及面试中经常出现的问题:
我们都知道,当我们调用一个方法是,其实底层是将这个方法转换成了objc_msgSend函数来进行调用,objc_msgSend的执行流程可以分为3大阶段:
消息发送->动态方法解析->消息转发
这个流程我们是可以从源码中得到确认,以下是源码:
1 /**
2 _class_lookupMethodAndLoadCache.
3 Method lookup for dispatchers ONLY. OTHER CODE SHOULD USE lookUpImp().
4 This lookup avoids optimistic cache scan because the dispatcher
5 already tried that.
6 **/
7 IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls)
8 {
9 return lookUpImpOrForward(cls, sel, obj,
10 YES/initialize/, NO/cache/, YES/resolver/);
11 }
12
13
14 /*
15 lookUpImpOrForward.
16 The standard IMP lookup.
17 initialize==NO tries to avoid +initialize (but sometimes fails)
18 cache==NO skips optimistic unlocked lookup (but uses cache elsewhere)
19 Most callers should use initialize==YES and cache==YES.
20 inst is an instance of cls or a subclass thereof, or nil if none is known.
21 If cls is an un-initialized metaclass then a non-nil inst is faster.
22 May return _objc_msgForward_impcache. IMPs destined for external use
23 must be converted to _objc_msgForward or _objc_msgForward_stret.
24 If you don't want forwarding at all, use lookUpImpOrNil() instead.
25 **/
26 //这个函数是方法调用流程的函数 即消息发送->动态方法解析->消息转发
27 IMP lookUpImpOrForward(Class cls, SEL sel, id inst,
28 <span style="color: rgba(0, 0