- 前面两篇文章分析了类的结构和数据存储,在类的结构中还有一个非常重要的变量
cache,这就是类的缓存。 - 这里可能会有一些疑问:
cache的作用是什么?cache在底层是如何处理的?
- 带着这些疑问,我们一起分析下
cache的底层实现
cache的结构
objc_class定义:
struct objc_class : objc_object {
// Class ISA;
Class superclass;
cache_t cache; // formerly cache pointer and vtable
class_data_bits_t bits;
}
- 从
objc_class定义可以看出,cache为cache_t类型,cache_t的定义:
struct cache_t {
private:
explicit_atomic<uintptr_t> _bucketsAndMaybeMask;
union {
struct {
explicit_atomic<mask_t> _maybeMask;
#if __LP64__
uint16_t _flags;
#endif
uint16_t _occupied;
};
explicit_atomic<preopt_cache_t *> _originalPreoptCache;
};
};
LLDB查看cache数据
LGPerson定义:(本文源码地址)
@interface LGPerson : NSObject
- (void)say1;
- (void)say2;
- (void)say3;
- (void)say4;
- (void)say5;
- (void)say6;
- (void)say7;
@end
- 通过
LLDB查看cache
- 打印
LGPerson类的内存地址 - 打印
cache内存的首地址- 从
objc_class的定义可以看出,cache是cache_t类型 cache在结构体objc_class中偏移16字节- 因此可以通过
LGPerson类的内存地址+0x10得到cache的首地址
- 从
- 查看
cache内存中存储的数据
调用方法看cache数据变化
- 调用方法:
[p say1];
- 通过
p *$1再次查看cache中的数据,发现_maybeMask和_occupied的值都发生了变化 - 思考:
_maybeMask和_occupied是什么?_maybeMask和_occupied的值是什么时候发生变化的?
cache的insert分析
- 在
cache_t的定义中,可以看到有个insert方法,猜测可能是插入数据,具体定义:
#if __arm__ || __x86_64__ || __i386__
#define CACHE_END_MARKER 1
#elif __arm64__ && !__LP64__
#define CACHE_END_MARKER 0
#elif __arm64__ && __LP64__
#define CACHE_END_MARKER 0
#define CACHE_ALLOW_FULL_UTILIZATION 1
#else
#error unknown architecture
#endif
void cache_t::insert(SEL sel, IMP imp, id receiver)
{
// ...
// Use the cache as-is if until we exceed our expected fill ratio.
mask_t newOccupied = occupied() + 1;
unsigned oldCapacity = capacity(), capacity = oldCapacity;
if (slowpath(isConstantEmptyCache())) {
// Cache is read-only. Replace it.
if (!capacity) capacity = INIT_CACHE_SIZE;
reallocate(oldCapacity, capacity, /* freeOld */false);
}
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.
}
#if CACHE_ALLOW_FULL_UTILIZATION
else if (capacity <= FULL_UTILIZATION_CACHE_SIZE && newOccupied + CACHE_END_MARKER <= capacity) {
// Allow 100% cache utilization for small buckets. Use it as-is.
}
#endif
else {
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;
mask_t begin = cache_hash(sel, m);
mask_t i = begin;
// Scan for the first unused slot and insert there.
// There is guaranteed to be an empty slot.
do {
if (fastpath(b[i].sel() == 0)) {
incrementOccupied();
b[i].set<Atomic, Encoded>(b, sel, imp, cls());
return;
}
if (b[i].sel() == sel) {
// The entry was added to the cache by some other thread
// before we grabbed the cacheUpdateLock.
return;
}
} while (fastpath((i = cache_next(i, m)) != begin));
bad_cache(receiver, (SEL)sel);
#endif // !DEBUG_TASK_THREADS
}
- 从上面的实现可以看出,主要三个重点:
- 参数
- 容量
- 插入
观察参数
- 首先可以看到参数为
SEL、IMP和id类型SEL为方法编号IMP为方法实现id为消息接收者
- 这里插入的是
SEL和IMP数据
容量处理
occupied为当前cache中方法总数,首次进来时,值为0,newOccupied值为1capacity为当前cache能存放方法的容量,首次进来时,值为0if分支:isConstantEmptyCache的作用是判断缓存内容是否为空,首次进来执行此分支内容- 对
capacity赋值,这里使用Mac工程调试,赋值INIT_CACHE_SIZE,即为4INIT_CACHE_SIZE = (1 << INIT_CACHE_SIZE_LOG2),INIT_CACHE_SIZE_LOG2 = 2,
- 调用
reallocate重新开辟缓存空间,并清理之前的缓存,即之前缓存的方法会被清理oldCapacity为旧数据占内存的容量,用于清理旧的内存空间capacity为新数据占内存的容量,用于开辟新的内存空间- 第三个参数为
freeOld,标记是否需要清理旧的内存空间,首次进入传false,旧内存为空,无需清理
- 对
else if分支:newOccupied + CACHE_END_MARKER <= cache_fill_ratio(capacity)newOccupied为occupied+1,即为插入方法后cache的bucktes内存空间中的保存的方法总数CACHE_END_MARKER:这里使用的是Mac工程,为x86_64架构,其值为1cache_fill_ratio(capacity):返回capacity * 3 / 4- 因此这里的判断条件是:插入方法后缓存中存放方法的总数
+1小于等于缓存总容量的3/4,即执行此分支- 不做任何操作
CACHE_ALLOW_FULL_UTILIZATION:Mac工程未定义这个值,不执行此分支else分支:- 计算缓存容量,进行扩容
- 如果
capacity值为0,赋值为INIT_CACHE_SIZE,这里等于4,否则进行原来的2倍扩容 - 最大容量处理:判断
capacity是否超过了最大容量MAX_CACHE_SIZE,如果超过,则赋值为最大容量 - 调用
reallocate重新开辟内存空间
插入数据
- 调用
buckets()返回bucket_t *类型数据- 这里的
bucket_t *即为存放bucket_t类型数据的连续内存空间 bucket_t是一个结构体,成员为SEL和IMP,这个就是cache中存储的重要数据- 上面提到的
capacity即为此缓存的容量
- 这里的
- 计算
m值,为capacity-1,实际上相当于掩码,用于计算数据插入bucket_t *中的下标 - 获取
begin值,即为插入数据的起始位置- 通过
cache_hash(sel, m)获取起始位置,此函数的分析可参考下面的补充
- 通过
- 通过do...while循环找到合适的位置,插入数据,具体流程:
- 首先
i为上面获取到的起始位置begin - 执行
do代码块- 首先判断
bucket_t *的下标为i的位置是否存储了数据- 如果未存储,调用
incrementOccupied();,即记录缓存方法的总数加1,并调用bucket_t::set保存SEL和IMP - 如果有存储数据,继续执行
- 如果未存储,调用
- 判断下标为
i的位置存储的数据和要插入的数据是否相等- 如果相等,直接返回
- 如果不相等,继续执行
- 首先判断
- 进行
while条件判断:- 通过
cache_next获取下一个下标位置,并赋值给i - 判断
i是否与起始位置begin相等- 如果不相等,继续执行
do代码块 - 如果相等,跳出
do...while循环,执行下面代码
- 如果不相等,继续执行
- 通过
- 首先
- 如果没有找到合适的位置插入数据,就会进入错误处理
补充:insert流程重点函数分析
occupied()分析
insert中首先调用了occupied(),具体定义:
mask_t cache_t::occupied() const
{
return _occupied;
}
- 这里直接返回了
_occupied的值
capacity()分析
insert中首先调用了capacity(),具体定义:
unsigned cache_t::capacity() const
{
return mask() ? mask()+1 : 0;
}
- 这里又调用了
mask(),具体定义:
mask_t cache_t::mask() const
{
return _maybeMask.load(memory_order_relaxed);
}
- 这里的作用是从内存中读取
_maybeMask的值 - 从上面的
LLDB调试可以看出,调用方法前,_maybeMask的值为0,因此这里capacity()的值为0
reallocate()分析
- 接下来会调用
reallocate重开开辟空间,具体定义:
void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity, bool freeOld)
{
bucket_t *oldBuckets = buckets();
bucket_t *newBuckets = allocateBuckets(newCapacity);
// Cache's old contents are not propagated.
// This is thought to save cache memory at the cost of extra cache fills.
// fixme re-measure this
ASSERT(newCapacity > 0);
ASSERT((uintptr_t)(mask_t)(newCapacity-1) == newCapacity-1);
setBucketsAndMask(newBuckets, newCapacity - 1);
if (freeOld) {
collect_free(oldBuckets, oldCapacity);
}
}
- 作用是:开辟一开新的内存空间,大小为
newCapacity - 调用
setBucketsAndMask,保存了_bucketsAndMaybeMask和_maybeMask的值 - 如果
freeOld为true,清理旧的缓存数据
buckets()分析
struct bucket_t *cache_t::buckets() const
{
uintptr_t addr = _bucketsAndMaybeMask.load(memory_order_relaxed);
return (bucket_t *)(addr & bucketsMask);
}
- 首先读取
_bucketsAndMaybeMask的数据 - 然后通过
bucketsMask,最终返回bucket_t *类型数据 - 这个实际上就是存放
bucket_t类型数据的连续存储空间
bucket_t分析(重要)
struct bucket_t {
explicit_atomic<SEL> _sel;
explicit_atomic<uintptr_t> _imp;
// ...
};
- 这里可以清晰看到,
bucket_t中保存的就是SEL和IMP - 注意:这里的
IMP取值需要转换一下,在bucket_t::set分析中有介绍
setBucketsAndMask分析
void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
_bucketsAndMaybeMask.store((uintptr_t)newBuckets, memory_order_release);
_maybeMask.store(newMask, memory_order_release);
_occupied = 0;
}
- 可以看出,这里保存了
_bucketsAndMaybeMask和_maybeMask的值
cache_hash分析(重要)
#if defined(__arm64__) && TARGET_OS_IOS && !TARGET_OS_SIMULATOR && !TARGET_OS_MACCATALYST
#define CONFIG_USE_PREOPT_CACHES 1
#else
#define CONFIG_USE_PREOPT_CACHES 0
#endif
typedef unsigned long uintptr_t;
static inline mask_t cache_hash(SEL sel, mask_t mask)
{
uintptr_t value = (uintptr_t)sel;
#if CONFIG_USE_PREOPT_CACHES
value ^= value >> 7;
#endif
return (mask_t)(value & mask);
}
- 本文调试使用的是
Mac工程,即x86_64架构,因此CONFIG_USE_PREOPT_CACHES的值为0 - 将
sel转为uintprt_t类型SEL类型占8字节- 实际上就是一个内存地址,内存里面存放的值为方法名称字符串
- 下面
LLDB调试,打印$4为bucket_t类型,存放的sel的地址为:0x0000000100003f70 - 通过菜单栏的
Debug -> Debug Workflow -> View Memory,可以查看内存存储的值 - 可以看到
0x0000000100003f70存储的就是sel对应的值say1
- 对
value进行mask,即相当于取余操作- 这里的
mask即为insert流程计算的m值,即为缓存容量capacity的值减1- 假设
capacity的值为4,则mask为3 - 则
mask的二进制形式:0b00000000000000000000000000000011 value和mask进行与操作,即保留value的最后两位数据,其他位清0- 所以
value的最小值为0,最大值为3,即相当于对value除4取余操作 - 这里直接进行位运算比取余运算效率更高
- 假设
- 这里的
- 总结:
cache_hash的作用就是使用sel的内存地址和缓存总容量-1的值进行&计算缓存数据的起始位置
incrementOccupied()分析
void cache_t::incrementOccupied()
{
_occupied++;
}
- 此方法的作用就是对
_occupied进行+1操作,从insert的流程分析可以知道,_occupied即为当前缓存中存储的方法总数
bucket_t::set分析(重要)
- 这里调试使用的是
Mac工程,x86_64架构,其他架构实现方式类似
template<Atomicity atomicity, IMPEncoding impEncoding>
void bucket_t::set(bucket_t *base, SEL newSel, IMP newImp, Class cls)
{
// ...
uintptr_t newIMP = (impEncoding == Encoded
? encodeImp(base, newImp, newSel, cls)
: (uintptr_t)newImp);
// ...
_imp.store(newIMP, memory_order_relaxed);
// ...
_sel.store(newSel, memory_order_relaxed);
// ...
}
uintptr_t encodeImp(UNUSED_WITHOUT_PTRAUTH bucket_t *base, IMP newImp, UNUSED_WITHOUT_PTRAUTH SEL newSel, Class cls) const {
if (!newImp) return 0;
#if CACHE_IMP_ENCODING == CACHE_IMP_ENCODING_PTRAUTH
return (uintptr_t)
ptrauth_auth_and_resign(newImp,
ptrauth_key_function_pointer, 0,
ptrauth_key_process_dependent_code,
modifierForSEL(base, newSel, cls));
#elif CACHE_IMP_ENCODING == CACHE_IMP_ENCODING_ISA_XOR
return (uintptr_t)newImp ^ (uintptr_t)cls;
#elif CACHE_IMP_ENCODING == CACHE_IMP_ENCODING_NONE
return (uintptr_t)newImp;
#else
#error Unknown method cache IMP encoding.
#endif
}
#if __PTRAUTH_INTRINSICS__
// Always use ptrauth when it's supported.
#define CACHE_IMP_ENCODING CACHE_IMP_ENCODING_PTRAUTH
#elif defined(__arm__)
// 32-bit ARM uses no encoding.
#define CACHE_IMP_ENCODING CACHE_IMP_ENCODING_NONE
#else
// Everything else uses ISA ^ IMP.
#define CACHE_IMP_ENCODING CACHE_IMP_ENCODING_ISA_XOR
#endif
- 存储数据时调用:
b[i].set<Atomic, Encoded>(b, sel, imp, cls()); - 可以看出这里传入的
impEncoding的值为Encoded - 因此会执行
encodeImp(base, newImp, newSel, cls)- 从
CACHE_IMP_ENCODING的宏定义可以看出,这里的值为CACHE_IMP_ENCODING_ISA_XOR__PTRAUTH_INTRINSICS__:开启了指针身份验证(iPhone X系列以后的真机),使用CACHE_IMP_ENCODING_PTRAUTH__arm__:arm架构,使用CACHE_IMP_ENCODING_NONE- 其他:使用
CACHE_IMP_ENCODING_NONE - 因为调试环境是
Mac项目,所以使用CACHE_IMP_ENCODING_ISA_XOR
- 因此,这里调用:
(uintptr_t)newImp ^ (uintptr_t)cls - 即存储的实际上是进行
^ (uintptr_t)cls计算后的值 - 所以,之后取值时需要做同样的
^ (uintptr_t)cls操作,才能取到真正的IMP
- 从
cache_next分析(重要)
#if __arm__ || __x86_64__ || __i386__
#define CACHE_END_MARKER 1
#elif __arm64__ && !__LP64__
#define CACHE_END_MARKER 0
#elif __arm64__ && __LP64__
#define CACHE_END_MARKER 0
#define CACHE_ALLOW_FULL_UTILIZATION 1
#else
#error unknown architecture
#endif
#if CACHE_END_MARKER
static inline mask_t cache_next(mask_t i, mask_t mask) {
return (i+1) & mask;
}
#elif __arm64__
static inline mask_t cache_next(mask_t i, mask_t mask) {
return i ? i-1 : mask;
}
#else
- 本文调试使用的是
Mac工程,即x86_64架构,因此CACHE_END_MARKER的值为1 - 从上面实现可以看出:
Mac(x86_64)或模拟器(i386):- 执行
(i+1) & mask,即向后查找,找到最后再从0开始,直到找到insert中计算的begin起始位置
- 执行
- 真机
(arm64):- 执行
i ? i-1 : mask,即向前查找,找到0后再从最后往前找,直到找到insert中计算的begin起始位置
- 执行
补充:LLDB调试遇到的问题
问题描述
- 在探索
cache的过程中,你可能会发现这样的问题:- 创建对象后,在代码中调用
[p say1],然后在lldb中打印_maybeMask的值为3 - 创建对象后,在
LLDB中调用[p say1],然后在打印_maybeMask的值为7 - 同样都是调用了
[p say1],为什么结果不一样呢?
- 创建对象后,在代码中调用
分析验证
- 从上面的分析过程可以知道,
cache中的数据修改是因为调用了insert方法 - 因此我们可以在
insert方法中打印日志,查看具体调用了什么方法 - 在
insert中添加如下代码
void cache_t::insert(SEL sel, IMP imp, id receiver)
{
printf("========:%s, %p, %p\n", (char *)sel, imp, receiver);
// ...其他代码不变,这里省略
}
- 代码调用:可以看出,只调用了对象
p的say1方法,_maybeMask的值为3
LLDB调用:可以看出,在调用对象p的say1方法之前,分别调用了对象p的respondsToSelector:和class方法,_maybeMask的值为7
- 结论:
- 代码调用,只调用了
say1方法 LLDB调试中调用,在调用say1方法前,分别调用了respondsToSelector:方法和class方法
- 代码调用,只调用了
深入探究
respondsToSelector:方法的调用
- 上面分析了
LLDB调试环境中调用方法首先调用了respondsToSelector:方法和class方法,那么为什么会调用这两个方法呢? - 由于这是在
LLDB调试环境中,所以考虑到可以看下lldb源码 lldb源码可以在llvm源码中找到,可以使用VSCode打开lldb源码- 在
lldb源码中搜索respondsToSelector:
- 主要代码:
if ($__lldb_arg_obj == (void *)0)
return; // nil is ok
if (!gdb_object_getClass($__lldb_arg_obj)) {
*((volatile int *)0) = 'ocgc';
} else if ($__lldb_arg_selector != (void *)0) {
signed char $responds = (signed char)
[(id)$__lldb_arg_obj respondsToSelector:
(void *) $__lldb_arg_selector];
if ($responds == (signed char) 0)
*((volatile int *)0) = 'ocgc';
}
- 第一个
if:判断对象是否为nil,如果是nil则直接返回 - 第二个
if:判断对象对应的类是否存在,这里肯定存在,因此执行else if分支 else if:判断sel是否为nil:- 如果不为
nil:则调用对象的respondsToSelector:方法,并将sel作为参数传递 - 如果为
nil:什么也不做
- 如果不为
- 到这里我们已经看到了
respondsToSelector:方法的调用,那么class是什么时候调用的呢?
class方法的调用
- 我们可以看下
objc源码中respondsToSelector:方法的实现:(这里调用的是对象方法)
- (BOOL)respondsToSelector:(SEL)sel {
return class_respondsToSelector_inst(self, sel, [self class]);
}
- 从
respondsToSelector:的定义可以看出:- 第三个参数调用了
[self class] - 这里的
self即为调用respondsToSelector:的对象,即为调用say1方法的对象p
- 第三个参数调用了
- 到这里就应该非常明确了为什么会调用
respondsToSelector:方法和class方法了
总结
LLDB调试环境中调用对象方法say1,会先调用respondsToSelector:方法- 在调用
respondsToSelector:的过程中会调用[self class] - 这两个方法调用完再调用刚开始的对象方法
say1