这是我参与更文挑战的第8天,活动详情查看: 更文挑战
前言
经过前面的探究,我们对class
中的bits
中的信息已经有所了解,但是class
中还有一个专门缓存方法的cache
, 这里一起对cache
进行探究下;
Class探究注意点
数组取值方式
- 在取数组中对象值的时候我们可以通过C++提供的根据数组下标取值的方法
p $n.get(0)
; - 也可以通过内存平移的方式
p &$n + 1
,$$n
是数组起始的地址,此时+1
会自动根据当前数组中数据的类型进行地址偏移;
M1电脑查看method
- M1电脑因为架构原因在打印method的时候需要使用
small().getDescription()
进行打印
Class对象的isa
- 为什么
class
的isa
获取MetaClass
的时候不需要&isa_Mask
,或者说&
了之后也没用? instance
对象的isa
里面存储了是否正在释放
、是否加入到弱引用
等信息,剥离掉这部分信息之后才是Class
,所以需要&isa_Mask
进行剥离;- 而
class
因为整个app
生命周期内都只有一份,不需要像instance
对象那样保存很多其他信息在isa
中,class
的isa
只存储了MetaClass
,所以class
的isa
不需要&isa_Mask
,即便&isa_Mask
它的值也不会有变化;
iOS常见架构信息
- 真机
arm64
;模拟器:i386
,mac:x86_64
; - 常见数据类型
哈希表
数组
是从头到位固定长度的一段连续空间,根据下标取值
很方便,但是增删
不方便,要循环便利慢慢找空位置,需要一个一个遍历才能知道是否对应上;- 链表是通过前后节点连接起来的一种数据,
增删
数据很方便,但是查找
数据不方便,需要一个一个遍历数据; hash链表
是结合了数组
和链表
的优势,方便增删的同时又方便查找;- 根据
哈希函数
可以很容易的找到当前的数据存储的位置,当多个数据得到的同一个存储位置时就发生了哈希冲突,此时需要再次对输入数据计算哈希值作为存储的下标;哈希列表存入的位置不是按照顺序的,它必定会浪费一部分空间,但是带来的是时间效率的提升;
缓存Cache_t
Cache_t基础数据结构
- 由于
cache_t
在class
中位于第2个位置,它的前面存放着isa
和superclass
2. 我们可以通过class对象地址偏移16个字节的方式获取cache_t的地址,然后强转为cache_t* 并打印,里面的信息如下
(lldb) p/x pClass //获取GCPerson类
(Class) $0 = 0x0000000100004520 GCPerson
(lldb) p (cache_t *)0x0000000100004530 //0x0000000100004520 + 0x10
(cache_t *) $1 = 0x0000000100004530
(lldb) p *$1// 取$1的地址进行打印
(cache_t) $2 = {
_bucketsAndMaybeMask = {
std::__1::atomic<unsigned long> = {
Value = 4298421120
}
}
= {
= {
_maybeMask = {
std::__1::atomic<unsigned int> = {
Value = 0
}
}
_flags = 32808
_occupied = 0
}
_originalPreoptCache = {
std::__1::atomic<preopt_cache_t *> = {
Value = 0x0000802800000000
}
}
}
}
(lldb)
复制代码
- 进入
cache_t
的定义中发现cache_t
中的方法大部分都是围绕bucket_t
这个类型进行操作,例如insert()
、emptyBuckets(
),说明cache_t
中的核心数据类型是bucket_t
;
static bucket_t *emptyBuckets();
static bucket_t *allocateBuckets(mask_t newCapacity);
static bucket_t *emptyBucketsForCapacity(mask_t capacity, bool allocate = true);
void cache_t::insert(SEL sel, IMP imp, id receiver)
}
复制代码
bucket_t
中主要存放SEL
和IMP
,如果说bucket_t
中的sel
和imp
对应书的目录和内容,那么buctets
则相当于一本书,书中有很多目录和内容
LLDB调试验证Cache_t结构
验证步骤
- 首先打印出P $3._maybeMask;
- 直接打印数据失败,寻找结构体提供的获取数据的方法
struct bucket_t *buckets() const
; - 发现2打印为空,猜测是因为没有调用方法,所以没有任何缓存,尝试用
lldb
调用方法[p saysomething]
,再次打印buckets()[1]
发现有值了,但是不一定每次都有值,主要原因是buckets是用哈希表进行存储的,存放的下标是经过哈希函数计算后分配的,不是按照顺序从0开始存放; - 哈希冲突发生时,会结合
拉链法
、二次哈希
、哈希后下标减1
等操作,来重新获取下标; - 打印
buckets()[1]
虽然有值,但不是我们需要的,经过分析发现bucket_t
中提供了sel()和imp()这两个方法,尝试使用方法打印p $13.sel()
、p $13.imp(nil,pClass)
最终成功;
具体调试代码如下
//源码
GCPerson *p = [GCPerson alloc];
Class pClass = [GCPerson class];
NSLog(@"%@",pClass);
//LLDB调试
(lldb) po [p saySomething]//0、先调用方法让缓存有数据
2021-06-26 02:49:13.859894+0800 KCObjcBuild[71828:6438662] -[GCPerson saySomething]
(lldb) p/x pClass //1、打印class
(Class) $0 = 0x0000000100004520 GCPerson
(lldb) p (cache_t *)0x0000000100004530//2、 class 地址偏移获取cache地址
(cache_t *) $1 = 0x0000000100004530
(lldb) p $1->buckets()//3、 获取cache中buckets地址
(bucket_t *) $2 = 0x000000010065cf90
(lldb) p $2[0]//尝试打印哈希表中的bucket数据失败了
(bucket_t) $3 = {
_sel = {
std::__1::atomic<objc_selector *> = (null) {
Value = (null)
}
}
_imp = {
std::__1::atomic<unsigned long> = {
Value = 0
}
}
}
(lldb) p $2[1]//尝试打印哈希表中的bucket数据又失败了
(bucket_t) $4 = {
_sel = {
std::__1::atomic<objc_selector *> = (null) {
Value = (null)
}
}
_imp = {
std::__1::atomic<unsigned long> = {
Value = 0
}
}
}
(lldb) p $2[2]////尝试打印哈希表中的bucket数据成功了
(bucket_t) $5 = {
_sel = {
std::__1::atomic<objc_selector *> = "" {
Value = ""
}
}
_imp = {
std::__1::atomic<unsigned long> = {
Value = 31024
}
}
}
(lldb) p $5.sel()//获取bucket中的sel
(SEL) $6 = "saySomething"
(lldb) p $5.imp(nil,pClass)//获取bucket中的imp
(IMP) $7 = 0x0000000100003c10 (KCObjcBuild`-[GCPerson saySomething])
(lldb)
复制代码
仿写Cache_t
- 通过源码和LLDB结合调试、我们可以分析出来
cache_t
、buckets
、bucket
之间的关系;
- 某些场景下我们源码无法调试、LLDB调试不够方便,我们还可以通过仿写系统
cache_t
、buckets
、bucket
的数据结构,进行小规模取样,在无源码环境下进行调试; - 仿写系统的数据结构时,各个数据的位置和内存占用size一定要和
objc
的源码里的结构对应上,否则获取的数据将无法于objc
的源码中数据进行匹配;
4.仿写代码的原理是通过强制类型转换
,把数据转换成我们需要的形式,实际上产生这些数据的还是被仿写的代码
,仿写后只是方便我们观察调试;
最终的仿写代码如下
typedef uint32_t mask_t; // x86_64 & arm64 asm are less efficient with 16-bits
struct gc_bucket_t {//仿写objc源码的bucket
SEL _sel;
IMP _imp;
};
struct gc_cache_t {//仿写objc源码的cache_t
struct gc_bucket_t *_bukets; // 8
mask_t _maybeMask; // 4
uint16_t _flags; // 2
uint16_t _occupied; // 2
};
struct gc_class_data_bits_t {//仿写objc源码的class_data_bits_t
uintptr_t bits;
};
// cache class
struct gc_objc_class {//仿写objc源码的objc_class
Class isa;
Class superclass;
struct gc_cache_t cache; // formerly cache pointer and vtable
struct gc_class_data_bits_t bits;
};
复制代码
调试仿写的cache_t
- 准备好仿写代码之后,我们添加一些调试代码对仿写的
cache_t
进行分析,调试代码如下
GCPerson *p = [GCPerson alloc];
Class pClass = p.class; // objc_clas
[p say1];
[p say2];
[p say3];
[p say4];
[p say1];
[p say2];
[pClass sayHappy];
struct gc_objc_class *gc_class = (__bridge struct gc_objc_class *)(pClass);
NSLog(@"%hu - %u",gc_class->cache._occupied,gc_class->cache._maybeMask);
for (mask_t i = 0; i<gc_class->cache._maybeMask; i++) {
struct gc_bucket_t bucket = gc_class->cache._bukets[i];
NSLog(@"%@ - %pf",NSStringFromSelector(bucket._sel),bucket._imp);
}
复制代码
- 通过调试仿写代码,依次打印出调用不同方法的时候
_occupied
、_maybeMask
、bucket._sel
、bucket._imp
的数据; - 根据仿写猜测出
_occupied
:已占用的缓存个数;_maybeMask
:当前的缓存空间size-1
; - 父类的方法也会进行缓存;
缓存插入过程分析;
explicit_atomic<uintptr_t> _bucketsAndMaybeMask
;是无符号长整型,像isa
一样不同的位数存储不同的信息,主要有_buckets
和MaybeMask
;_bucketsAndMaybeMask
里面的数据,我们猜测是通过void insert(SEL sel, IMP imp, id receiver)
方法进行插入的;- 为什么扩容不能追加到后面?因为原来的缓存表是连续的空间,追加是新开辟的没办法再次加到原来的后面了,不再是连续的空间;
- 为什么不把原来的数据重新存放到扩容后的空间?因为数组平移很耗内存,查找方法缓存调用及其频繁,做这样的操作会产生很大的性能问题;
insert
方法源码分析
void cache_t::insert(SEL sel, IMP imp, id receiver)
{
mask_t newOccupied = occupied() + 1; // 1+1 //进入之后先将occupied+1
unsigned oldCapacity = capacity(), capacity = oldCapacity;
if (slowpath(isConstantEmptyCache())) {//缓存为空时进入
if (!capacity) capacity = INIT_CACHE_SIZE;//4 1左移两位=4;
reallocate(oldCapacity, capacity, /* freeOld */false);//根据旧容量和新容量创建筒子,空筒子不需要清空
}
//缓存不为空时进入
//虽然还没插入,newOccupied在此时已经加过1了
//newOccupied + 1 <= 容量的3/4 则不处理
else if (fastpath(newOccupied + CACHE_END_MARKER <= cache_fill_ratio(capacity))) {
// Cache is less than 3/4 or 7/8 full. Use it as-is.
}
//如果newOccupied + 1 > 容量的3/4 则扩容
else {// 4*2 = 8
//重新分配容量
capacity = capacity ? capacity * 2 : INIT_CACHE_SIZE;
if (capacity > MAX_CACHE_SIZE) {
capacity = MAX_CACHE_SIZE;
}
reallocate(oldCapacity, capacity, true);//根据旧容量和新容量创建筒子,并清空筒子的老数据
}
bucket_t *b = buckets();//拿到当前的筒子
mask_t m = capacity - 1; // 容量-1:4-1=3
mask_t begin = cache_hash(sel, m);//根据容量和sel通过hash来 获取插入的开始位置;从开始位置去找空间插入,这里并不能直接获取要插入的位置
mask_t i = begin;
do {
if (fastpath(b[i].sel() == 0)) {//如果起始位置为空则直接插入;
incrementOccupied();//_occupied加1;
b[i].set<Atomic, Encoded>(b, sel, imp, cls());//在i的位置插入这个缓存
return;
}
if (b[i].sel() == sel) {//如果起始位置已存入方法,则直接返回
return;
}
} while (fastpath((i = cache_next(i, m)) != begin));//再次进行哈希,如果再次哈希后的开始位置!=当前的开始位置则,则继续插入,如果再次哈希和第一次哈希一样,就结束插入;
bad_cache(receiver, (SEL)sel);//再次哈希的起始值和首次哈希一致时,按照坏缓存处理;
}
复制代码
reallocate
和setBucketsAndMask
源码分析
void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity, bool freeOld)
{
bucket_t *oldBuckets = buckets();//获取原有的筒子
bucket_t *newBuckets = allocateBuckets(newCapacity);//开辟新筒子
setBucketsAndMask(newBuckets, newCapacity - 1);//插入空筒子和(新的容量-1)
if (freeOld) {//释放旧筒子
collect_free(oldBuckets, oldCapacity);
}
}
void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
_bucketsAndMaybeMask.store((uintptr_t)newBuckets, memory_order_release);//储存newBuckets 和 memory_order_release
_maybeMask.store(newMask, memory_order_release);
_occupied = 0;//设置 _occupied = 0
}
复制代码
缓存插入总结
- 首次插入会直接开辟筒子,分配容量,设置占用;
- 进入
insert
会先增加occupied
,用occupied+1 <= 容量的3/4
,如果>3/4
就扩容为原来的2倍
; - 插入时根据
hash
算法找出插入的起始位置,然后循环计算二次哈希
,直到二次哈希
和首次哈希
一致时,进行坏缓存
处理;
缓存流程图
看着这张图我只想问一句,还有谁
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END