iOS 底层探究:cache_t分析

515 阅读6分钟

这是我参与8月更文挑战的第7天,活动详情查看:8月更文挑战

在之前的文章中,我们讲到了NSObject的父类是objc_class,而它包含以下信息

    Class ISA;
    Class superclass;
    cache_t cache;             // formerly cache pointer and vtable
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags

今天我们来探索一下cache_t

1.知识准备

1.1数组

数组是用于储存多个相同类型数据的集合。主要有以下优缺点:

  • 优点:访问某个下标的内容很方便,速度快
  • 缺点:数组中进行插入、删除等操作比较繁琐耗时

1.2链表

链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。主要有以下优缺点:

  • 优点:插入或者删除某个节点的元素很简单方便
  • 缺点:查找某个位置节点的元素时需要挨个访问,比较耗时

1.3哈希表

哈希表是根据关键码值而直接进行访问的数据结构。主要有以下优缺点:

  • 优点:1、访问某个元素速度很快。 2、插入删除操作也很方便
  • 缺点:需要经过一系列运算比较复杂

2.cache的数据结构

类的结构:在objc_class结构体中,由isasuperclasscachebits组成。isasuperclass都是结构体指针,各占8字节。故此,使用内存平移:首地址+16字节,即可探索cache的数据结构体。

2.1探索objc源码

找到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; 
    }; 
    ... 
};
  • _bucketsAndMaybeMask:泛型,传入uintptr_t类型,占8字节
  • union:联合体,包含一个结构体和一个结构体指针_originalPreoptCache
  • struct:包含_maybeMask_flags_occupied三个成员变量,和_originalPreoptCache互斥 我们找到了cache_t的数据结构,但他的作用还不得而知 通过cache_t的各自方法,可以看出它在围绕bucket_t进行增删改查 找到bucket_t的定义
struct bucket_t { 
private: 
    // IMP-first is better for arm64e ptrauth and no worse for arm64. 
    // SEL-first is better for armv7* and i386 and x86_64. 
#if __arm64__ 
    explicit_atomic<uintptr_t> _imp; 
    explicit_atomic<SEL> _sel; 
#else 
    explicit_atomic<SEL> _sel; 
    explicit_atomic<uintptr_t> _imp; 
#endif 
    ... 
};
  • bucket_t中包含selimp
  • 不同架构,selimp的顺序不一样 通过selimp不难看出,在cache_t中缓存的应该是方法

2.2cache_t结构图

image.png

3.cache底层原理

3.1 insert函数

cache_t结构体中,找到insert函数

struct cache_t { 
    ... 
    void insert(SEL sel, IMP imp, id receiver); 
    ... 
};

3.2 创建bucket

insert函数,当缓存列表为空时

INIT_CACHE_SIZE_LOG2 = 2, 
INIT_CACHE_SIZE = (1 << INIT_CACHE_SIZE_LOG2), 
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); 
}
  • newOccupied:已有缓存的大小+1
  • capacity:值为4(1 << 2),缓存列表的初始容量
  • reallocate函数,首次创建,freeOld传入false reallocate函数,创建buckets存储桶,调用setBucketsAndMask函数
bucket_t *newBuckets = allocateBuckets(newCapacity); 
setBucketsAndMask(newBuckets, newCapacity - 1);

setBucketsAndMask函数,不同架构下代码不一样,以当前运行的非真机代码为例

void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask) 
{ 
#ifdef __arm__ 
    // ensure other threads see buckets contents before buckets pointer 
    mega_barrier(); 
    _bucketsAndMaybeMask.store((uintptr_t)newBuckets, memory_order_relaxed); 
    // ensure other threads see new buckets before new mask 
    mega_barrier(); 
    _maybeMask.store(newMask, memory_order_relaxed); 
    _occupied = 0; 
#elif __x86_64__ || i386 
    // ensure other threads see buckets contents before buckets pointer
    _bucketsAndMaybeMask.store((uintptr_t)newBuckets, memory_order_release); 
    // ensure other threads see new buckets before new mask
    _maybeMask.store(newMask, memory_order_release); 
    _occupied = 0; 
#else 
#error Don't know how to do setBucketsAndMask on this architecture. 
#endif 
}
  • 传入的newMask为缓存列表的容量-1,用作掩码
  • buckets存储桶,存储到_bucketsAndMaybeMask中。强转uintptr_t类型,只存储结构体指针,即:buckets首地址
  • newMask掩码,存储到_maybeMask
  • _occupied设置为0,因为buckets存储桶目前还是空的

3.3扩容

如果newOccupied + 1小于等于75%,不需要扩容

#define CACHE_END_MARKER 1 
if (fastpath(newOccupied + CACHE_END_MARKER <= cache_fill_ratio(capacity))) {
    // Cache is less than 3/4 or 7/8 full. Use it as-is. 
} 
// Historical fill ratio of 75% (since the new objc runtime was introduced). 
static inline mask_t cache_fill_ratio(mask_t capacity) { 
    return capacity * 3 / 4; 
}
  • CACHE_END_MARKER:系统插入的结束标记,边界作用 超过75%,进行2倍扩容
MAX_CACHE_SIZE_LOG2 = 16, 
MAX_CACHE_SIZE = (1 << MAX_CACHE_SIZE_LOG2), 
capacity = capacity ? capacity * 2 : INIT_CACHE_SIZE; 
if (capacity > MAX_CACHE_SIZE) { 
    capacity = MAX_CACHE_SIZE; 
} 
reallocate(oldCapacity, capacity, true);
  • capacity进行2倍扩容,但不能超过65536
  • 调用reallocate函数,扩容时freeOld传入true reallocate函数,当freeOld传入true
bucket_t *oldBuckets = buckets(); 
bucket_t *newBuckets = allocateBuckets(newCapacity);
setBucketsAndMask(newBuckets, newCapacity - 1); 
if (freeOld) { 
    collect_free(oldBuckets, oldCapacity); 
}
  • 创建buckets存储桶,代替原有buckets,新的buckets容量为扩容后的大小
  • 释放原有的buckets
  • 原有buckets中的方法缓存,全部清除

3.4计算下标

insert函数,调用哈希函数,计算sel的下标

mask_t m = capacity - 1; 
mask_t begin = cache_hash(sel, m); 
mask_t i = begin;
  • capacity - 1作为哈希函数的掩码,用于计算下标

3.5写入缓存

insert函数,得到buckets存储桶

bucket_t *b = buckets();

buckets函数,进行&运算,返回bucket_t类型的结构体脂针,即:buckets首地址

static constexpr uintptr_t bucketsMask = ~0ul; 
struct bucket_t *cache_t::buckets() const 
{ 
    uintptr_t addr = _bucketsAndMaybeMask.load(memory_order_relaxed); 
    return (bucket_t *)(addr & bucketsMask); 
}
  • 不同架构下,bucketsMask的值不一样
  • ~0ul0b1111111111111111111111111111111111111111111111111111111111111111
  • &运算:如果两个相应的二进制位都为1,则该位的结果值为1
  • 所以addr & ~0Ul,结果还是addr 使用下标获取bucket,相当于内存平移。如果bucket中不存在sel,写入缓存
if (fastpath(b[i].sel() == 0)) { 
    incrementOccupied(); 
    b[i].set<Atomic, Encoded>(b, sel, imp, cls()); 
    return; 
}
  • incrementOccupied函数,对_occupied进行++
  • set函数,将selimp写入bucket 如果存在sel,并且和当前sel相同,直接return
if (b[i].sel() == sel) { 
    // The entry was added to the cache by some other thread 
    // before we grabbed the cacheUpdateLock. 
    return; 
}

否则,表示哈希冲突

3.6 防止哈希冲突

cache_next函数,不同框架下算法不一样,以当前运行的非真机代码为例:

static inline mask_t cache_next(mask_t i, mask_t mask) { 
    return (i+1) & mask; 
}
  • 在产生冲突的下标基础上,先进行+1,再和mask进行&运算 在do...while中,调用cache_next函数,直到解决哈希冲突为止
do { 
    ... 
} while (fastpath((i = cache_next(i, m)) != begin));

结论:

  • capacity:缓存列表的容量
  • occupied:已有缓存的大小
  • maybeMask:使用capacity-1的值作为掩码,在哈希算法、哈希冲突中,用于计算下标
  • 写入缓存时,如果写入缓存后的大小+边界超过容量的75%,进行扩容
    • 扩容:创建新的存储桶,释放原有空间
    • 原有存储桶中的方法缓存全部清除
    • 先进行2倍扩容,再写入缓存
  • 使用哈希函数计算下标,使用下标找到bucket
  • 判断bucket中的sel,不存在则写入
  • 如果存在sel,并且和当前sel相同,直接return
  • 哈希冲突
    • 不同框架,算法不一样
    • 在产生冲突的下标基础上,先进行+1,再和mask进行&运算
    • do...while中,直到解决哈希冲突为止

3.7 为什么使用3/4扩容

哈希表具有两个影响其性能的参数:初始容量和负载因子

  • 初始容量时哈希表中存储桶的数量,初始容量知识创建哈希表时的容量
  • 负载因子是在自动增加其哈希表容量之前,允许哈希表获得的满意度的度量 当哈希表中的条目数超过负载因子和当前容量的乘积时,哈希表将会被重新哈希。即:内部数据结构将被重建。因此哈希表的存储桶大约为两倍 负载因子定义为3/4,在时间和空间成本之间提供了一个很好的折中方案
  • 假如负载因子定为1,那么只有当元素填满时才会扩容。虽然可以最大程度的提高空间利用率,但是会增加哈希冲突,因此查询效率会变得低下。所以当加载因子比较大的时候:节省空间资源,增加查找成本
  • 假如负载因子定为0.5,到达空间一般的时候就会去扩容。虽然说负载因子比较小可以最大可能的降低哈希冲突,但空间浪费会比较大。所以当加载因子比较小的时候:节省时间资源,耗费空间资源

4 流程图

image.png