2020WWDC 链接视频内容
2020WWDC对类结构的优化, 2021的改动大部分都是音视频, AR以及Swift所以大方向基本上已经确定, 是时候对Swift亲儿子进行了解了
油管链接
官网链接
作者ben来自于runtime团队, 此次更新极大的提高了内存的使用效率.
- 首先,你可能不需要做任何改动, 你的app也会变得更快.
- 学习如何防止别人在你的代码库上工作时, 访问他们不应该访问的东西
- 本次涉及三个改动
- (1): 数据结构的改变
- (2): 方法列表的变化
- (3): tagged pointer的变化
WWDC 内存优化
目前遗留的问题
- copy和strong修饰符的区别(objc_setProperty和内存平移, objc_getProperty都在什么情况下会调用)
- alloc的objc_alloc, objc_opt_class和objc_opt_isKindOfClass的符号查找
- 为什么第一次加载的时候firstSubclass=nil, 关于类的加载是懒加载形式后续篇章会聊到. 在执行或者调用了LGTeacher之后, 就会有firstSubclass=LGTeacher的赋值.
cache_t流程图分析
cache_t结构
之前的类结构里面讨论了, isa, superClass, class_data_bits_t就剩下cache_t没有探究, 从字面意思看, 我们可以看出cache_t是缓存, 但是却并不知道缓存的是什么. lldb调试一下, 然后看一下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;
};
void incrementOccupied();
void setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask);
void reallocate(mask_t oldCapacity, mask_t newCapacity, bool freeOld);
void collect_free(bucket_t *oldBuckets, mask_t oldCapacity);
static bucket_t *emptyBuckets();
static bucket_t *allocateBuckets(mask_t newCapacity);
static bucket_t *emptyBucketsForCapacity(mask_t capacity, bool allocate = true);
static struct bucket_t * endMarker(struct bucket_t *b, uint32_t cap);
void bad_cache(id receiver, SEL sel) __attribute__((noreturn, cold));
public:
void insert(SEL sel, IMP imp, id receiver);
void copyCacheNolock(objc_imp_cache_entry *buffer, int len);
void destroy();
void eraseNolock(const char *func);
static void init();
static void collectNolock(bool collectALot);
static size_t bytesForCapacity(uint32_t cap);
};
在上面的结构中, 我看到了一些比较刺眼的, 比如copyCache像极了扩容方法的名字, insert插入, destroy销毁, eraseNolock清空. 然后我们分别点击进去查看:
void cache_t::insert(SEL sel, IMP imp, id receiver)
{
.....
//在这里我们看到了一个buckets
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);
}
//添加
void cache_t::copyCacheNolock(objc_imp_cache_entry *buffer, int len)
{
//移除多余的
bucket_t *buckets = this->buckets();
uintptr_t count = capacity();
for (uintptr_t index = 0; index < count && wpos < len; index++) {
if (buckets[index].sel()) {
buffer[wpos].imp = buckets[index].imp(buckets, cls());
buffer[wpos].sel = buckets[index].sel();
wpos++;
}
}
}
//销毁
void cache_t::destroy()
{
....
if (canBeFreed()) {
if (PrintCaches) recordDeadCache(capacity());
free(buckets());
}
}
在上面的方法中, 所有的缓存操作都是针对buckets()来进行的, 所以我们看一下buckets()是什么. 其实我们直接看destory是最直观的, 在销毁的时候销毁的对象一定是核心的占用内存的缓存对象.
struct cache_t {
public:
struct bucket_t *buckets() const;
}
struct bucket_t *cache_t::buckets() const
{
uintptr_t addr = _bucketsAndMaybeMask.load(memory_order_relaxed);
return (bucket_t *)(addr & bucketsMask);
}
可以看出来buckets()是一个指针常量指向对象是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
....
目前我们跑的是模拟器架构, 所以是_sel在上, imp在下. 手机arm64e或者arm64都是_imp在头, 可能是考虑到使用查询更加方便的设计. 注释也写了, IMP在前对于arm64e是极好的, 对于arm64也不差. 肯定是通过了性能测试才有了这样的转变.
所以到现在为止, 我们知道了, cache_t里面存储的单元最小节点为bucket_t, bucket_t里面是_sel和_imp, 所以cache_t的核心是存储的方法.
cache_t -> buckets -> bucket_t -> (_sel和_imp)
LLDB验证存储的方法
未进行alloc调用之前进行调试LGPerson
(lldb) p/x LGPerson.class
(Class) $1 = 0x00000001000084e0
(lldb) p (cache_t *)0x00000001000084f0
(cache_t *) $2 = 0x00000001000084f0
(lldb) p *$2
(cache_t) $3 = {
_bucketsAndMaybeMask = {
std::__1::atomic<unsigned long> = {
Value = 4298453728
}
}
= {
= {
_maybeMask = {
std::__1::atomic<unsigned int> = {
Value = 0
}
}
_flags = 32816
_occupied = 0
}
_originalPreoptCache = {
std::__1::atomic<preopt_cache_t *> = {
Value = 0x0000803000000000
}
}
}
}
(lldb) p $3.buckets()
(bucket_t *) $4 = 0x00000001003532e0
(lldb) p *$4
(bucket_t) $5 = {
_sel = {
std::__1::atomic<objc_selector *> = (null) {
Value = (null)
}
}
_imp = {
std::__1::atomic<unsigned long> = {
Value = 0
}
}
}
在执行了init方法之后_bucketsAndMaybeMask, _maybeMask和_occupied的值都发生了变化:
并且在cache_t中只打印出来了一个init方法, 因为cache_t是数组所以存储的是数组的地址. 继续调用test1, 如下图:
继续往下看, 打印出buckets:
最终我在如下地址, 数组的第六个位置和第七个位置找到了两个方法:
但是之前的init又消失不见了, 缺多出来了一个class方法和刚刚执行的test1方法, 所以这个时候在我们刚刚过滤中的方法里面, 插入数量大于容量的时候会扩容, 只有copy层面方法或者destory和eraseNolock可以对方法进行移除. 否则init也不会消失:
insert
void cache_t::insert(SEL sel, IMP imp, id receiver)
{
//操作上锁, 一般对数据进行修改之前都会进行上锁, 这个锁内部是一个os_unfair_lock的锁, 用于替代OSSpinLock, iOS10以后出现的锁, 解决了优先级反转的问题, 和互斥锁差不多, 没有忙等状态, 是挂起等待.
runtimeLock.assertLocked();
// Never cache before +initialize is done
// 如果没有+initialize之前不能完成缓存
// slowpath就是结果大概率是0, 但是主要还是看()内部为YES还是NO, 结果和()内部一致
if (slowpath(!cls()->isInitialized())) {
return;
}
//如果含有缓存优化, 内部返回flase
// 根据CONFIG_USE_PREOPT_CACHES判断, 模拟器为false
if (isConstantOptimizedCache()) {
_objc_fatal("cache_t::insert() called with a preoptimized cache for %s",
cls()->nameForLogging());
}
//大概就是线程任务失败了会走这里
#if DEBUG_TASK_THREADS
return _collecting_in_critical();
#else
//CONFIG_USE_CACHE_LOCK默认为NO
#if CONFIG_USE_CACHE_LOCK
mutex_locker_t lock(cacheUpdateLock);
#endif
//sel不为nil,cls必须加载过
ASSERT(sel != 0 && cls()->isInitialized());
// Use the cache as-is if until we exceed our expected fill ratio.
// 按原样使用缓存,直到我们超过填充率(也就是阈值, 就相当于扩容时候的一个扩容因子)。
// newOccupied为新的数量大小_occupied成员
mask_t newOccupied = occupied() + 1;
// oldCapacity为旧的成员, return mask() ? mask()+1 : 0;
unsigned oldCapacity = capacity(), capacity = oldCapacity;
// 如果是空缓存, 判断条件为_occupied==0&&buckets为空
if (slowpath(isConstantEmptyCache())) {
// Cache is read-only. Replace it.
// 缓存是只读的, 替换掉它
// 如果oldCapacity没有, capacity赋值为(1 << INIT_CACHE_SIZE_LOG2)=4, INIT_CACHE_SIZE_LOG2在__arm__ || __x86_64__ || __i386__结构下为2
if (!capacity) capacity = INIT_CACHE_SIZE;
//然后reallocate从新开辟内存, 清空旧的内存
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.
// 缓存小于3/4或者7/8的时候, 正常执行
}
#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扩容为2倍
capacity = capacity ? capacity * 2 : INIT_CACHE_SIZE;
// 不可以超过最大扩容量(1 << MAX_CACHE_SIZE_LOG2), 1 << 16 = 2^16
if (capacity > MAX_CACHE_SIZE) {
capacity = MAX_CACHE_SIZE;
}
// 重新开辟内存, 并且清理旧数据
reallocate(oldCapacity, capacity, true);
}
// 取出buckets首地址
// uintptr_t addr = _bucketsAndMaybeMask.load(memory_order_relaxed); 等同于_bucketsAndMaybeMask, 可验证
bucket_t *b = buckets();
// m = capacity - 1, m还不知道是什么, 现在只知道m=capacity-1, capacity为扩容后的新容量或者初始化的容量或者0
mask_t m = capacity - 1;
// begin , cache_hash是一个内联好书,sel地址&上了m.
// uintptr_t value = (uintptr_t)sel;
// return (mask_t)(value & mask);
mask_t begin = cache_hash(sel, m);
// 循环的初始地址为i = begin
mask_t i = begin;
// Scan for the first unused slot and insert there.
// There is guaranteed to be an empty slot.
// 扫描第一个未使用的位置并插入
// 保证有一个位置
do {
//如果是空, 进行赋值, 0可以看做0x0
if (fastpath(b[i].sel() == 0)) {
//_occupied++;
incrementOccupied();
//buckets赋值
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;
}
//i不等于起始位置, 就一直进行(i+1) & mask; arm64是i ? i-1 : mask;
// 等于模拟器环境是从hash的begin位置进行+1在哈希, 真机环境是其实位置减1直到起始位置结束
} while (fastpath((i = cache_next(i, m)) != begin));
//走到这里证明缓存出错了, 打印出错缓存
bad_cache(receiver, (SEL)sel);
#endif // !DEBUG_TASK_THREADS
//容量方法
unsigned cache_t::capacity() const
{
return mask() ? mask()+1 : 0;
}
//_maybeMask调用load
mask_t cache_t::mask() const
{
return _maybeMask.load(memory_order_relaxed);
}
}
上面的注释中, 我们可以看出capacity最大不超过2^16, 所以看图:
联合位域来看, 模拟器环境下, 刚好高位16位为_occupied实际缓存的方法数量, 低位_maybeMask为低32位是缓存的总容量.
在什么时候调用了insert
上面其实主要就是看了insert, 但是我们也不知道是在什么时候调用insert, 源码中打个断点, 看一下流程:
lookUpImpOrForward这个方法非常熟悉, 在消息转发流程里面是非常常用的, 名字翻译过来就是 寻找imp或者转发. 在这里面调用到了log_and_fill_cache, log_and_fill_cache里面调用了 cls->cache.insert(sel, imp, receiver); 所以其实就可以猜测, 在寻找消息imp实现的过程中, 就已经对imp进行了缓存.
不管我们调用方法, NSObject的API还是runtime其实最终走的全部都是消息的发送, objc_msgSend, 但是objc_msgSend在源码中实现都是汇编, 在cahce.mm的头文件注释中找到这样的代码.
所以, 暂时可以得到objc_msgSend调用到了cache_getImp, cache_getImp(cls(), sel)中必然先从cache找imp,
- 找到: 不处理
- 找不到: 从Class的方法列表中查询到method_t, 然后给cache中insert一份, 然后返回imp.
总结:
cache_t, 在真机和模拟器下执行的一些方法和细节都不一样, 存放的数据同一指针上的位置也不一样, 不同的架构就走不同的宏定义, 只要看懂了一种流程其他的也是看得懂的. 重点来说cache_t就是缓存方法的, 和普通的缓存思路一样.
- (1)先查缓存, 因为缓存中的方法是根据hash计算出下标 , 在阈值一定的情况下, 缓存中查找imp复杂度为O(1).
- (2)如果缓存没有, 那么就在objc_class的methods()中进行查询, methods是一个数组, 可以通过遍历的方式进行查询, 那么就是O(n)的复杂度.
- (2.1)查到了就cache中添加一份
- (2.2)查不到就证明错误, 进入消息转发的一个流程.
比较不一样的是, cache_t在扩容之后不会进行上一段缓存方法的内存拷贝, 在扩容之后只会加入最新使用的方法. 正常的缓存在扩容之后是会把之前的缓存数据进行一个拼接的. collectNolock方法其实就是缓存大小是否清空的一下垃圾处理机制.
- (2.2)查不到就证明错误, 进入消息转发的一个流程.
下一篇我们可以继续看objc_msgSend是否真正的如我们所看到的那样, 调用了这么一个方法, 其实最暴力的就是汇编一眼.
- objc_msgSend中调用了_objc_msgSend_uncached
- _objc_msgSend_uncached调用了lookUpImpOrForward
- lookUpImpOrForward调用了cache_getImp, log_and_fill_cache
- cache_getImp, log_and_fill_cache中调用了cache的insert.
所以和我们想象的基本一致, 最终在在lookUpImpOrForward中先getIMP没有得到的情况下, 调用了log_and_fill_cache, log_and_fill_cache中调用了cache的insert.