OC类结构之Cache结构解析

637 阅读6分钟

src=http___img9.doubanio.com_view_note_l_public_p60856515.jpg&refer=http___img9.doubanio.jpeg

前言:

之前我们已经学习了类的结构,里面有isa,superClass(isa走位图、继承链),bits(存储了methodlist,properties,protocol),类里面仅剩下了cache,cache顾名思义就是缓存,那么在类中,缓存的到底是什么呢?这期博客就是探究类的缓存以及底层原理。

cache结构初探---cache是什么?数据结构如何?

此图先贴为敬,不管你看不看,他就在这里。总体思路流程是沿着这个往下走的。

Cooci 关于Cache_t原理分析图.png

方法:根据我们原先对bits的探索方式,同样的,我们来探索cache

截屏2021-06-25 下午3.38.40.png 从类结构中可以看到,他在第16个字节以后(前面有isa和superClass)所以要平移16位。 得出如下结果:可以看到主要包括了bucketsAndMaybeMask、_maybeMask,_originalPreoptCache。

截屏2021-06-25 下午3.38.17.png

此时应该好奇、验证并且找到定义cache的结构体:

截屏2021-06-25 下午3.56.10.png

补充:

LP64 macOS系统,这是一个联合体,联合体互斥,所以很明显是走了上面,不会走originalPreoptCache。那么很明显整个cache占用的就是 8 + 8 = 16。

分析:

此时如果是缓存,必定缓存的是属性、方法等,那就必然有sel或者imp,这时候在实现中必然有方法。稍微浏览了一下,果然里面有很多方法,比如说开辟空间,比如说清空,还有插入。那么在缓存的时候,必然事先是要插入,才会有缓存。那么此时就找到了这个insert方法。

经过以上探究,大致可以得出这样的结构图:

截屏2021-06-25 下午4.13.05.png

通过lldb验证方法的存储

获取bucketsAndMaybeMask发现,结构体跟之前的不同,并且直接打印value是无法获取值得,这个时候的获取方式就卡住了,但是由之前调试bits的经验,我们可以通过找到里面对应实现的方法来获取里面的值。 023D4C19-5597-4C77-82EC-C8D154ADB3BC.png

其实这里的探究我感觉就是不断地尝试,通过打印里面的方法来获取,只不过不是瞎找瞎打印,所有的方法都遵循简明达意,在类中快就发现了buckets()这个方法,并且这个方法看起来像是一种初始化的结构体构造方法,所以尝试打印,果不其然。

023F7DB9-4778-43F4-9F06-BF39392AF5AB.png

注意

这里的buckets()方法的调用者是$2,也就是cache,而不是bucketsAndMaybeMask,其方法在460行。

打印$4打印并没有数据,分析原因是没有调用方法,没有产生缓存。这里也就印证了,cache里面存的是方法,而不是其他数据。

0E101D71-71E5-4FAC-A5AD-1713F33C6E3B.png

此时此刻我们调用一下类里的方法,再次打印。

补充

这里有两种打印方法的方式。

A8BF248E-6B09-4E9A-9992-88D1B8F01EDA.png 结构体通过->的方式调用,对象通过点的方式调用。

注意

在打印的时候直接通过buckets()方法无法打印出来,在occpied中确实可以看到1,表示有一个方法缓存,通过buckets[1]却可以打印出数据并且可以打出来。 打印方式时,通过源码查看,内部需要传两个参数,第一个是base不知道传什么就传他nil吧。

分析

oc对于方法缓存是有自己的处理的,存取方式是用得哈希数组存的,哈希是结合了链表和数组,方便增删查,内部是无序的。

621A21B8-E8F6-4092-85B2-6EF62869AC0B.png

脱离源码查找方法

lldb调试的弊端

1.需要的门槛或者说需要对底层有比较深入的了解,对方法有相当敏锐的感知,调试比较耗费时间。 2.每次都需要不停的打印,层层深入。

这里就直接把cocci老师的贴出来了。

截屏2021-06-25 下午5.24.58.png

方法缓存底层解析

通过对源码的分析一层层探索,总结如下: cache_t是个结构体指针,首先会通过结构体指针平移去方法区找对应的方法,取得时候上面已经分析过了,因为他是哈希数组,根据数组的方式取即可实际就是通过指针平移。存的方式也是通过哈希存。当方法区没有没有缓存时,进来默认是先创建一个bucket桶,里面可能多有个bucket,所以他叫做buckets,每个bucket里面有对应的sel和imp的地址,如果说在cache中存取,cache会越来越大,也就超过了之前定义好的大小,这样是不符合结构体定义的。存取插入的过程,内部会做dowhile循环防止哈希冲突,如果没有超过容器大小,内部是默认超出3/4溶容积,会做两倍扩容,在刚进入的时候,会有判断,是首次进入还是扩容,首次默认是1<<2,1左移2单位,就是4,oc内部做了-1操作就是3,如果是扩容状态,会调用清空操作,直接清空内存地址。原因是内存在开辟好了以后,是不能改变的,只能通过伪新增,即删除再添加,没有把原来的方法再次添加的原因是:1.数组拷贝平移需要耗费很多资源,且原先老的旧的方法直接用新的代替老的,可以节省内存,方便查询。

好家伙,非常的清晰~~!

补充

之前的分析中,没有对bucketsAndMaybeMask做说明,这里补充一下,分别输出他的地址和buckets数组的首地址。两者是一样的,可以看出,bucketsAndMaybeMask就是buckets数组的首地址。

4E508B74-4B19-4AE1-BE6E-6E55242E4900.png

3/4扩容:就是一个负载因子,在0.75时扩容对空间利用率高,对哈希冲突可以有一定成都的避免,过多的哈希冲突会导致系统的压力大。

问题:

在lldb模式下只调用一个方法,再打印的时候会发现,_maybeMask的值是7??

截屏2021-06-25 下午8.26.51.png

解决

在插入insert方法之前,用打印的方式查看所有的插入的方法,会发现存在responseToSeletor 和class方法在say之前已经调用,但是扩容的操作是在第三个方法结束以后才会产生。所以打印的方式还是无法找到原因。进一步具体的解决就是在插入之前调用打印所有buckets里面的sel和imp以及地址,看到第四个方法的imp地址和第一个一样。因为在插入bucket的时候必然会调用set方法,而在set方法的注释中可以找到,在lldb中,第一点会调class方法,调用class方法就会调用相应的allocBucket,然后调用set方法,此时产生四个空间,在最后一个bucket中存入了mask作为边界,而当我们的方法进来时,空间里已经存在四个了,肯定此时是需要扩容的,所以打印出来是1-7

bucket结构补充分析

32FF4788-4BC2-4B96-A209-99369CA112D5.png

通过buckets的方法可以看得出来,首先通过load方法获取到地址,然后通过&mask还原原来的地址并强转,获得buckets_t的16字节的地址。

截屏2021-06-27 上午8.03.51.png 高16表示占16位,大端模式(macOs)表示从左边开始的16位,小端(ios)表示右边开始的16位.

首先通过bucketAndMaybeMask获取当前的内存首地址,通过它来平移获取其他buckets。

关于insert插入

思考

目前我们已经知道了整个插入的流程,是通过insert方法,但是我们仍然无法得知何时插入,插入之前又做了什么工作。

处理

通过搜索cache_t::insert就可以看到,在objc_cache里已经有了注释,在insert之前会先getImp获取缓存,在get之前,会有一个消息发送的机制,下面就要了解这个objc_msgSend.

截屏2021-06-27 上午8.20.11.png

哈希冲突的处理

在插入的时候,可能会存在哈希冲突,当然3/4在一定程度上做了规避,如果存在哈希冲突,那么系统会走cache_next在哈希,此时是向前或者向后位移一个单位取决于架构,直到0之后回到mask也就是末尾,如果走了一圈还是没有位置(外层是dowhile循环)那么就会报bad_cache.

截屏2021-06-27 下午11.30.46.png

截屏2021-06-27 下午11.30.57.png

真机和模拟器插入顺序不同

截屏2021-06-27 下午11.34.29.png

关于objc_msgSend

补充

RunTime的三种方式

1.我们自定义的对象的方法--[person say]

2.nsobject提供的方法 --iskindof

3.runtime的API,class_getInstanceSize

消息发送机制

上层调用方法,都会变成下层的msgSend方法,那么我们也可以直接把下层的放到上层来用,但是直接调用不行,需要做两点,第一,在buildSetting里面,对msg的设置enable为no,第二,引入#import <objc/message.h>。objc_msgSend(接收者receiver,sel)

截屏2021-06-27 上午8.30.45.png

底层汇编对于消息发送机制的理解

进入objc源码并且全局搜索objc_msgSend,找到objc-msg-arm64.s真机环境,点击Entry _obcj_msgSend.

截屏2021-06-27 上午8.44.14.png

1.cmp--对比 p0--p0的地址,p0就是当前接收者的地址。第一句:判断当前消息有没有接收者,#0就是存不存在的意思。

2.TAGGER_POINTERS没讲,不解释。LReturnZero是没有消息接收者,消息接收者是空值。

3.如果和#0没有可比性,有地址接收,那么就往下走了。

4.p13 = [X0] x0是寄存器的地址,把首地址给了p13,p13 = isa

5.getClassFromIsa p16,通过isa找到对应的class。

以上的操作,通过receiver获取到了class, 因为class中存了cache。

获取到class以后做了什么?

在获取到了class以后,他走了CacheLookup方法,参数先不看,找到了宏定义的地方,下面就开始解读这段。

截屏2021-06-27 下午11.41.19.png

截屏2021-06-27 下午11.42.25.png

把x16给了x15,真机走high16方法,看ldr p11,[x16,#cache],把x16的地址平移cache个位置,经过查找,cache是2*sizeOfPointer,cache是16.class平移16个位置->cache_t.把cache_t给p11,接下来是判断是不是config_user_preopt_caches,这个值是1,hasfeature是A12以后的,不通用,一般不走这段,看下面的判断,p10 = p11 & 掩码,通过把后面的#0x那个数值打印出来是0-48位置,按位与取出buckets数据,因为在64位中,mask和bucket是同时存取的,存取的方式之前讲过是按照架构不同,存取不同。

tbnz p11 #0,判断如果不是0直接跳转到llookupPreopt,如果是0就走下面,先分析下面。

eor开始------拿到p1和p1的右移当前的七个单位存在p12中,再去寻找(这里看了三遍感觉没有说清楚留个问题吧~~~)

截屏2021-06-28 上午12.02.54.png

文字总结:(汇编实在太生涩难懂了。。。)

拿到class以后,通过内存平移找到cache,因为cache中有bucket和mask,也就是那个bucketAndMaybeMask,然后通过bucket掩码->bucket,通过mask掩码->mask,在inert的哈希函数(cache_hash)中,通过 (mask_t)(value & mask),要获取缓存,一定要获取第一次查找的index,根据bucket+index找到相应的bucket,从bucket中的sel与传进来的cmd比较如果相同就调用cachehit命中,从而把imp^class找到imp的实现,调用call方法。如果没有找到,则会再次平移(--),整个查询都是在dowhile循环里的,所以不用再哈希了,插入的情况是需要再哈希的。如果最终也没有找到,则会__objc_msgSend_unCached。

后记

ios oc底层对cache的方法缓存做了很多处理,从算法到存取,获取,包括对内存的优化,其中处理到的逻辑可能还有很多遗漏,但是主流程应该就是这么走的。

给所有ios开发者共勉:革命尚未成功,同志仍需努力!!!