iOS底层-类的三顾茅庐(三)

前言

上文讲解完了类对象的结构体objc_class用来存储类信息的成员bits,整个结构还剩下方法的缓存cache,放在压轴来讲解。

// 简化版
struct objc_class : objc_object {
    // 类对象指针,Class大小是8字节
    Class ISA;
    // 父类对象指针,大小同上8字节
    Class superclass;
    // 方法缓存
    cache_t cache; // formerly cache pointer and vtable
    // 类存储的数据
    class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags
}

cache的探索

cache字面意思是缓存,接下来探索它的数据结构。

拿到类对象地址后,根据结构体可知,平移16字节得到cache

image-20220504161311874

查看源码的cache_t数据结构:

struct cache_t {
private:
    // 8字节
    explicit_atomic<uintptr_t> _bucketsAndMaybeMask;
    union {
        struct {
            explicit_atomic<mask_t>    _maybeMask; // 4字节
#if __LP64__
            uint16_t                   _flags; // 2字节
#endif
            uint16_t                   _occupied; // 2字节
        };
        // 8字节
        explicit_atomic<preopt_cache_t *> _originalPreoptCache;
    };
}

explicit_atomic原子性,保证线程安全;_bucketsAndMaybeMask成员变量占8字节;

还有一个联合体,并且联合体里还有一个结构体;通过计算最大也是占8字节;

结构和LLDB输出对应上了。这些内容又是什么意思呢?先放着。既然是缓存,就需要插入数据。在结构体内找到insert方法,参数有SELIMP,这两者决定了一个方法。参数3是方法的接收者。

void insert(SEL sel, IMP imp, id receiver);

来到方法内部,下面的循环就是在操作bucket_t *b这个数据;

image-20220504165031553

那就要看看数据结构bucket_t了。

方法缓存的结构体

查看bucket_t结构体,也有impsel

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
    ...
}

回到循环,查看set方法。这是一个函数模板:

// <原子操作,是否需要编码>
template<Atomicity atomicity, IMPEncoding impEncoding>

判断impEncoding:

image-20220504165247635

查看encodeImp,通过注释可知这是方法签名用的。

image-20220504165423165

回到set方法,这段是判断原子操作:

image-20220504165646081

这个store就是往内存写入数据,load是读取。set方法就是把newIMPnewSel写入内存,简单概括就是保存方法。

当循环往*b插入数据的时候,插入开始位置通过cache_hash算出来。

image-20220504170147693

cache_hash代码:

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);
}

cache_hash(sel, m);入参sel是方法名,那和参数m相关的capacity是什么呢?

回到主方法,通过函数capacity()得到oldCapacity,意思是旧的buckets()容器长度(下文扩容部分会说明),并赋值给capacity

capacity()代码:从成员变量_maybeMask读取mask()

capacity = mask()+1 等价于mask()= capacity - 1

unsigned cache_t::capacity() const
{
    return mask() ? mask() + 1 : 0; 
}
// mask()
mask_t cache_t::mask() const
{
    return _maybeMask.load(memory_order_relaxed);
}

capacity() 函数的作用就是获取当前容器能够缓存方法的最大个数,也就是容器的⻓度。那么入参m就是长度 - 1。

回到hash算法,

image-20220504172600287

sel被转换成uintptr_t,本质是数字。不过这个数字比较大。

#ifndef __has_attribute
typedef unsigned long           uintptr_t;
#else
typedef unsigned long           uintptr_t;
#endif /* __has_attribute */

value & mask的时候, 最大也就等于mask(主方法里的入参m),也就是 buckets()容器长度 - 1;

// 例如 mask = 6 = 0110
0110 & 11111111111 = 0110, // 与运算,0与上任何数都是0;

bucket_t是散列表,理解为往数组里插入数据。再好的hash算法都会有冲突,也就是2个不同的方法得到相同的内存地址;

image-20220505151448685

所以系统用了两个判断:1.sel有没有值(0代表未使用),2.未使用,存进去之后是否相等;

解决哈希冲突

如果都不满足,就要解决哈希冲突:cache_next方法

// CACHE_END_MARKER:缓存结束标记,值跟随架构变化。
#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
#error unexpected configuration
#endif

CACHE_END_MARKER = 1时,方法相当于把i增大,往之后的地址里存(开放地址法),且得到的i不等于begin

while (fastpath((i = cache_next(i, m)) != begin))

综上所述,cache应该就是方法的缓存

接着验证。将几个属性都打印一下;结果都不是…没有一个成员带有selimp信息;

image-20220504185614561

不要慌,找cache_t提供的方法。这不就是返回刚才插入的那些bucket_t吗?

image-20220504185853257

这就打印看看:

image-20220504190008806

接着从bucket_t结构体里找到imp()方法:第一个入参base不知道啥,先传nil试试:

image-20220504200507357

拿到imp之后;同理能找到sel

既然bucket_t是数组,那么存放的可能不止一个方法。通过指针地址+1获得下一个元素地址:由于是哈希表,可能存在nil,于是地址+2得到了respondsToSelector方法地址。

image-20220505134812648

越界的情景:sel已经是null了,value居然有值,这应该是用到其他地方的内存。

image-20220505135745597

没调用过这些方法,为什么会有?

调用方法testInstancePrint之后,重新获取buckets,发现方法丢失了…

image-20220505141543859

按理说testInstancePrint方法缓存进cache里了,首地址的方法怎么也不应该是空的。这就涉及到缓存扩容了。

cache的扩容

回到insert方法:

image-20220504203847608

这两个if是判断初始化等。接着是occupied()

image-20220504204155741

方法内部:

mask_t cache_t::occupied() const
{
    return _occupied;
}

返回成员变量_occupied,初始0;

image-20220504204109538

那么newOccupied = 0 + 1 = 1;

接下来,第一次没有缓存,必定会进入if判断里。初始值就是INIT_CACHE_SIZE

image-20220504204623995

这个初始值是1左移INIT_CACHE_SIZE_LOG2位数得到的。

image-20220504204751748

CACHE_END_MARKER之前也见过。

image-20220504205011471

接着执行reallocate(oldCapacity, capacity, /* freeOld */**false**);,方法内部:

ALWAYS_INLINE
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);
    }
}

头2行就是获取老bucket_t,生成新bucket_t

setBucketsAndMask方法:

image-20220504210300207

arm是32位,arm64才是64位;也就是arm64下,啥也没干。方法本意是给成员变量赋值。第一次来由于没有旧bucket_t,所以freeold = false;不会释放旧bucket_t

小结一下:

  • occupied() 函数的作用就是获取当前容器已经缓存的方法的个数。
  • INIT_CACHE_SIZEarm64架构下为2,在x86_64架构下为4。
  • 那么当cache中缓存方法的容器为空时,在arm64架构下初始化容器的⻓度为2,在x84_64架构下初始化容器的⻓度为4。

那么下一步:

image-20220504210731982

这个cache_fill_ratio方法又得回到前面的这张图:

image-20220504205011471

函数在x86_64架构下为容器⻓度capacity的3/4,在arm64架构下为7/8。

那么这个分支的判断就是:在x86_64架构下,实际存储的方法的个数小于等于容器的总容量的3/4再减1时,啥也不干。在arm64架构下,实际存储的方法的个数小于等于容器的总容量的 7/8时,啥也不干。

由此可以猜测,fastpath代表大概率会执行到,slowpath代表小概率会执行到。

CACHE_ALLOW_FULL_UTILIZATIONarm64架构下等于1,会多出以下分支:

image-20220504211505550

FULL_UTILIZATION_CACHE_SIZEarm64架构下等于8;

image-20220504211914451

逻辑就是:在arm64架构下,当容器的⻓度小于或等于8时 && 实际存储的方法的个数小于或等于容器的⻓度的时候,又啥也不干。

最终else分支:

image-20220504212111978

2倍扩容,且不超过MAX_CACHE_SIZE(容器的最大⻓度为 1<<16,见前一张图)。这里reallocate(oldCapacity, capacity, true);传了true,方法就会释放旧bucket_t。里面的内容就不存在了,这就解释了扩容后,之前缓存的方法不存在了。

综合上面的代码的出来的结论就是:

  • arm64结构,也就是真机环境下,缓存方法的容器初始⻓度2,大于7/8扩容。注意,当容器的⻓度小于8时,只有满容量了才可能大于7/8,所以系统在容量小于8的情况下,是存满才扩容。
  • x86_64架构下,缓存方法的容器初始⻓度4,大于等于3/4扩容。容器只能存储(容器⻓度 * 3/4 - 1)个方法。

接下来的部分代码就是插入数据。

缓存的插入

回到开始时testInstancePrint方法找不到的问题;testInstancePrintresponseToSelectorclass方法之前调用;class来的时候,因为扩容,旧的bucket_t被释放了;前面的方法位置就变了。

验证:既然要往cache里插入数据,必然会调用insert方法;修改代码打印方法名:

void cache_t::insert(SEL sel, IMP imp, id receiver)
{
    printf("%s\n", sel_getName(sel));
    ...
}

运行方法之后,打印一下此时的class,这时候才插入了2个方法;

image-20220505103236989

既然cache占16字节,如果方法太多了呢?因为存放的只是首地址,具体内容在buckets()里。

image-20220505105517842

通过掩码返回容器首地址:

struct bucket_t *cache_t::buckets() const
{
    uintptr_t addr = _bucketsAndMaybeMask.load(memory_order_relaxed);
    return (bucket_t *)(addr & bucketsMask);
}
// bucketsMask
static constexpr uintptr_t bucketsMask = ~0ul;

扩容测试

模仿类和cache的数据结构,方便写代码读取:


#import <UIKit/UIKit.h>
#import "AppDelegate.h"
#import <objc/message.h>
#import "FFGoods.h"

typedef uint32_t mask_t;  // x86_64 & arm64 asm are less efficient

// preopt_cache_entry_t
struct ff_preopt_cache_entry_t {
    uint32_t sel_offs;
    uint32_t imp_offs;
};

//preopt_cache_t
struct ff_preopt_cache_t {
    int32_t  fallback_class_offset;
    union {
        struct {
            uint16_t shift       :  5;
            uint16_t mask        : 11;
        };
        uint16_t hash_params;
    };
    uint16_t occupied    : 14;
    uint16_t has_inlines :  1;
    uint16_t bit_one     :  1;
    struct ff_preopt_cache_entry_t entries;
    
    inline int capacity() const {
        return mask + 1;
    }
};

// bucket_t
struct ff_bucket_t {
    IMP _imp;
    SEL _sel;
};

// cache_t
struct ff_cache_t {
    uintptr_t _bucketsAndMaybeMask; // 8
    struct ff_preopt_cache_t _originalPreoptCache; // 8
    
    // _bucketsAndMaybeMask is a buckets_t pointer in the low 48 bits
    // _maybeMask is unused, the mask is stored in the top 16 bits.

    // How much the mask is shifted by.
    static constexpr uintptr_t maskShift = 48;

    // Additional bits after the mask which must be zero. msgSend
    // takes advantage of these additional bits to construct the value
    // `mask << 4` from `_maskAndBuckets` in a single instruction.
    static constexpr uintptr_t maskZeroBits = 4;

    // The largest mask value we can store.
    static constexpr uintptr_t maxMask = ((uintptr_t)1 << (64 - maskShift)) - 1;
    
    // The mask applied to `_maskAndBuckets` to retrieve the buckets pointer.
    static constexpr uintptr_t bucketsMask = ((uintptr_t)1 << (maskShift - maskZeroBits)) - 1;
    
    static constexpr uintptr_t preoptBucketsMarker = 1ul;

    // 63..60: hash_mask_shift
    // 59..55: hash_shift
    // 54.. 1: buckets ptr + auth
    //      0: always 1
    static constexpr uintptr_t preoptBucketsMask = 0x007ffffffffffffe;
    
    ff_bucket_t *buckets() {
        return (ff_bucket_t *)(_bucketsAndMaybeMask & bucketsMask);
    }
    
    uint32_t mask() const {
        return _bucketsAndMaybeMask >> maskShift;
    }
    
};

// class_data_bits_t
struct ff_class_data_bits_t {
    uintptr_t objc_class;
};

// objc_class
struct ff_objc_class {
    Class isa;
    Class superclass;
    struct ff_cache_t cache;
    struct ff_class_data_bits_t bits;
};

测试思路:

给自定义的类生成30个方法,模拟调用;

扩容后第一次插入方法,数量只有1。打印此时的长度。

void test(Class cls) {
    
    // 将cls的类型转换成自定义的源码ff_objc_class类型,方便后续操作
    struct ff_objc_class *pClass = (__bridge struct ff_objc_class *)(cls);
    
    struct ff_cache_t cache = pClass->cache;
    struct ff_preopt_cache_t origin = cache._originalPreoptCache;
    uintptr_t mask = cache.mask();
    // 扩容后第一次插入方法数量只有1
    if (origin.occupied == 1) {
        NSLog(@"buckets已缓存方法的个数 = %u, buckets的长度 = %lu", origin.occupied, mask + 1);
    }
}

运行:

image-20220505160345788

可以看到几次触发扩容的log。

总结

方法的缓存基于不同架构,缓存策略是不一样的。

  • bucket_t结构体存储方法必备的selimp,并用数组容器存储。在cache_t结构体中,通过bucket()方法返回元素首地址。容器初始长度在arm64架构下为2,在x84_64架构下为4。
  • 扩容条件:在arm64架构下容量小于8,存满才扩容,大于8时,数量大于7/8扩容。在x86_64架构下,都是大于等于3/4。
  • 扩容按照2倍原大小进行,最大⻓度为 1<<16 = 0x10000。扩容之后,之前的方法缓存被清空(内存被释放)。
  • 为什么要释放旧的内存 ? 扩容是按照2倍进行的,如果不释放,随着扩容次数增加,遗留的无用内存也不少。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值