OC类的原理之cache分析

859 阅读6分钟
  • 前面两篇文章分析了类的结构和数据存储,在类的结构中还有一个非常重要的变量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定义可以看出,cachecache_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数据

@interface LGPerson : NSObject
- (void)say1;
- (void)say2;
- (void)say3;
- (void)say4;
- (void)say5;
- (void)say6;
- (void)say7;
@end
  • 通过LLDB查看cache

image.png

  • 打印LGPerson类的内存地址
  • 打印cache内存的首地址
    • objc_class的定义可以看出,cachecache_t类型
    • cache在结构体objc_class中偏移16字节
    • 因此可以通过LGPerson类的内存地址+0x10得到cache的首地址
  • 查看cache内存中存储的数据

调用方法看cache数据变化

  • 调用方法:[p say1];

image.png

  • 通过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
}
  • 从上面的实现可以看出,主要三个重点:
    • 参数
    • 容量
    • 插入

观察参数

  • 首先可以看到参数为SELIMPid类型
    • SEL为方法编号
    • IMP为方法实现
    • id为消息接收者
  • 这里插入的是SELIMP数据

容量处理

  • occupied为当前cache中方法总数,首次进来时,值为0newOccupied值为1
  • capacity为当前cache能存放方法的容量,首次进来时,值为0
  • if分支:isConstantEmptyCache的作用是判断缓存内容是否为空,首次进来执行此分支内容
    • capacity赋值,这里使用Mac工程调试,赋值INIT_CACHE_SIZE,即为4
      • INIT_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)
    • newOccupiedoccupied+1,即为插入方法后cachebucktes内存空间中的保存的方法总数
    • CACHE_END_MARKER:这里使用的是Mac工程,为x86_64架构,其值为1
    • cache_fill_ratio(capacity):返回capacity * 3 / 4
    • 因此这里的判断条件是:插入方法后缓存中存放方法的总数+1小于等于缓存总容量的3/4,即执行此分支
      • 不做任何操作
  • CACHE_ALLOW_FULL_UTILIZATIONMac工程未定义这个值,不执行此分支
  • else分支:
    • 计算缓存容量,进行扩容
    • 如果capacity值为0,赋值为INIT_CACHE_SIZE,这里等于4,否则进行原来的2倍扩容
    • 最大容量处理:判断capacity是否超过了最大容量MAX_CACHE_SIZE,如果超过,则赋值为最大容量
    • 调用reallocate重新开辟内存空间

插入数据

  • 调用buckets()返回bucket_t *类型数据
    • 这里的bucket_t *即为存放bucket_t类型数据的连续内存空间
    • bucket_t是一个结构体,成员为SELIMP,这个就是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保存SELIMP
        • 如果有存储数据,继续执行
      • 判断下标为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的值
  • 如果freeOldtrue,清理旧的缓存数据

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中保存的就是SELIMP
  • 注意:这里的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调试,打印$4bucket_t类型,存放的sel的地址为:0x0000000100003f70
    • 通过菜单栏的Debug -> Debug Workflow -> View Memory,可以查看内存存储的值
    • 可以看到0x0000000100003f70存储的就是sel对应的值say1

image.png image.png image.png

  • value进行mask,即相当于取余操作
    • 这里的mask即为insert流程计算的m值,即为缓存容量capacity的值减1
      • 假设capacity的值为4,则mask3
      • mask的二进制形式:0b00000000000000000000000000000011
      • valuemask进行与操作,即保留value的最后两位数据,其他位清0
      • 所以value的最小值为0,最大值为3,即相当于对value4取余操作
      • 这里直接进行位运算比取余运算效率更高
  • 总结: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],为什么结果不一样呢?

image.png

分析验证

  • 从上面的分析过程可以知道,cache中的数据修改是因为调用了insert方法
  • 因此我们可以在insert方法中打印日志,查看具体调用了什么方法
  • insert中添加如下代码
void cache_t::insert(SEL sel, IMP imp, id receiver)
{
    printf("========:%s, %p, %p\n", (char *)sel, imp, receiver);
 	// ...其他代码不变,这里省略   
}
  • 代码调用:可以看出,只调用了对象psay1方法,_maybeMask的值为3

image.png

  • LLDB调用:可以看出,在调用对象psay1方法之前,分别调用了对象prespondsToSelector:class方法,_maybeMask的值为7

image.png

  • 结论:
    • 代码调用,只调用了say1方法
    • LLDB调试中调用,在调用say1方法前,分别调用了respondsToSelector:方法和class方法

深入探究

respondsToSelector:方法的调用

  • 上面分析了LLDB调试环境中调用方法首先调用了respondsToSelector:方法和class方法,那么为什么会调用这两个方法呢?
  • 由于这是在LLDB调试环境中,所以考虑到可以看下lldb源码
  • lldb源码可以在llvm源码中找到,可以使用VSCode打开lldb源码
  • lldb源码中搜索respondsToSelector:

image.png

  • 主要代码:
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