iOS源码阅读笔记
源码学习笔记
平台宏
|
|
Runtime
一个类最多能添加64
个分类。
、AutoreleasePoolPage
SideTableMap
、AssociationsManager
是在map_images->map_images_nolock->arr_init()
函数中初始化的;
|
|
dyld加载流程
引自飘云的dyld详解
- 第一步:设置运行环境
- 第二步:加载共享缓存
- 第三步:实例化主程序
- 第四步:加载插入的动态库
- 第五步:链接主程序
- 第六步:链接插入的动态库
- 第七步:执行弱符号绑定
- 第八步:执行初始化方法(runtime和类的加载是在这一步)
- 第九步:查找入口点并返回
叙述一下:
- 配置环境变量,开启共享缓存,接下来把主程序的可执行文件(
macho
)加载进内存,检测macho
内的magic
、cpu
等属性以及兼容性,如果通过则创建一个imageLoader
,然后插入动态库,链接主程序(加载进所有依赖库,然后rebase
修正偏移、rebind
绑定符号地址)和插入的动态库,然后执行弱符号绑定,接下来就是进入真正的初始化方法,如下图,当dyld
加载到开始链接主程序的时候,递归调用recursiveInitialization
函数;
- 这个函数第一次执行,会走到
doInitialization
->doModInitFunctions
-> libSystemInitialized
,进行libsystem
的初始化; libsystem
的初始化,它会调用起libdispatch_init
,libdispatch
的init
会调用_os_object_init
,这个函数里面调用了_objc_init
;_objc_init
中注册并保存了map_images
、load_images
、unmap_image
函数地址;- 注册完毕继续回到
recursiveInitialization
递归下一次调用,例如libobjc
,当libobjc
来到recursiveInitialization
调用时会触发libsystem
,调用到_objc_init
里注册好的回调函数进行调用,就来到了libobjc
,调用load_images
。
类的加载
read_image
: 初始化gdb_objc_realized_classes
表,容量是类总数量的4/3
倍,这个表用来存放不在共享缓存中并且非懒加载类。接下来从mach-o
中读取非懒加载类,把从mach-o
中读取的类指针转换成类名,变成可识别的类(地址 -> 类名),然后加入前面创建的gdb_objc_realized_classes
map表中(这个表的作用是在后面处理类有没有被正确的处理),同时也会加入allocatedClasses
这个set
表中(这个表是在_objc_init
中的runtime_init
函数中初始化的,存储的是已经初始化过的类)。总的来说,这一步就是把类读取到内存;- 接下来就是重点方法
realizeClassWithoutSwift
:创建rw(class_rw_t)
,把从class
中获取(ro = cls->safe_ro())
到的ro(class_ro_t)
放到rw
中(方法列表是在后面放的),递归执行realizeClassWithoutSwift
,即递归设置rw
; - 设置类、父类和元类,其实就是生成一个双向链表;
methodizeClass
:获取class
的ro
中的baseMethods
,通过prepareMethodLists
函数进行方法升序排序,然后unttachedCategories::attachToClass
,这里是重头戏,attachToClass
中会执行attachCategories
,在这里面会初始化rwe(class_rw_ext_t)
,在这过程中会把ro
中的方法列表、属性列表、协议列表都复制到rwe
中,然后接着会把category
中的方法列表、属性列表、协议列表也会吸纳进去,此时类才算加载完成;- 方法排序的这里有几个细节:在
methodizeClass
中首次从class_ro_w
中拿到basemethods
后就立即做了升序排序处理,而分类中的方法排序发生在attachCategory
方法中,也就是说他们是分开并各自独立排序的。疑问:没有整合到一起后再排序,那怎么用的二分查找?
解答:
只有类和分类都实现
load
方法,才会存在load_image
阶段分类方法整合到所属类的方法列表中的操作,也就是说只有类或者分类中实现load
方法的情况,类的方法列表和分类方法列表都是直接在编译期存放在class_ro_t
中的baseMethods
中的。那这种情况怎么能保证分类方法在原始类方法前面呢?应该是编译器自己在编译期做的处理:让分类方法地址比原始类的方法地址要低。而对于二分查找,整合后的方法列表其实是个二维数组,内部存的是排好序的一维方法列表(methodizeClass
阶段preparemethod
进行方法排序),方法查找先是顺序遍历二维数组,再在有序的一维方法列表中进行二分查找。 - 方法添加顺序:新建个数组,先把类中
class_ro_w
的basemethods
放到数组后面,然后把分类方法放到数组前面。
为什么category
会覆盖原来的方法?
在map_images
函数的 attachCategories -> attachLists
分类附加到原来的类的方法列表时,会先重新开辟一个新的数组,把原来的方法列表倒序遍历添加到新数组的后面,接着再正序遍历,把分类的方法添加到新数组的前面(方法列表的顺序与原来的顺序一致);
类和category
实现load
对加载的影响
只有类和分类都实现load
方法,才会发生在load_image
阶段分类方法整合到所属类的方法列表中的操作; 只有类或者分类中实现load
的时候,类的方法和分类方法都是直接在编译期存放到class_ro_t
中的baseMethods
中的。那这种情况怎么能保证分类方法在原始类方法前面的?这应该是编译器自己在编译期做的处理,让分类方法地址比原始类的方法地址要低(方法排序用的是升序排序)。
而对于类和分类都实现load
的场景,即在load_image
阶段把分类方法整合到类的方法列表中的情况是如何进行二分查找的呢?其实整合后的方法列表是个二维数组,内部存的是排好序的一维方法列表(methodizeClass
阶段preparemethod
进行的方法升序排序),方法查找时先是顺序遍历二维数组,再在有序的一维方法列表中进行二分查找。
综上所述,不要在类和分类中同时实现load
方法也是提升启动速度的一个点,当然,不用load
最好了。
为什么会有CleanMemory和DirtyMemory呢?
iOS
运行过程中会涉及对内存进行增删改查,为了防止对原始数据的修改,所以把原来的CleanMemory
copy
一份到rw
中。- 有了
rw
为什么还要rwe
(DirtyMemory
)?因为并不是所有的类加载进内存时都需要进行动态的插入、删除,我们添加一个属性、一个方法会对内存改动很大,对内存的占用也有一定影响,所以我们只要对类进行动态处理了(比如把category
的方法、属性、协议合并到类中),就会生成一个rwe
。
为什么执行load
方法时没有触发initialize
?
一定要明确一个概念:initialize
在首次发消息时才会触发,而load
的执行是通过函数指针的方式调用的,并没有走消息发送机制,所以也就不会触发initialize
。
如何判断一个类是否已经初始化?
|
|
参考objc_class
的结构构造一个相同的数据结构,然后模仿原实现获取到是否已经初始化的标识。可以参考SLMClassCoverage中的实现。
为什么在对象释放过程中通过weak
变量获取不到这个对象?
在关联的场景中,比如A
关联B
,B
弱持有A
,A
释放时会释放其关联的B
,导致B
的dealloc
执行,然后我们在B
的dealloc
方法中通过weak
变量读取A
,却发现获取到的是nil
(根据释放流程此时A
还没有free
掉),这是为什么?
分析如下:
读取weak
变量时执行的是objc_loadWeak
函数,内部执行大概流程为:objc_loadWeak
-> objc_loadWeakRetained
-> obj->rootTryRetain()
-> rootRetain(true, RRVariant::Fast)
,在rootRetain
中如果当前对象正在处于释放流程中,则返回nil
。具体代码如下:
|
|
既然没释放,那我们怎么拿到这个对象呢?通过unsafe_unretained
或者assign
标记就可以获取到了。
对象释放流程
调用release
-> rootRelease
,引用计数-1
,当引用计数变为0
时,就会通过objc_msgSend
调用Objective-C
对象的dealloc
方法,然后进入到objc_object::rootDealloc()
函数,函数内部会读取当前对象的isa
中存储的信息,包括是否是非指针、有没有弱引用、成员变量、关联对象、has_sidetable_rc
,如果都没有会直接释放(free
),否则会执行objc_destructInstance(obj)
,这个函数的逻辑为:先释放成员变量,接着移除关联对象,再移除弱引用,把弱引用指针置为nil
,最后再从SideTable
的RefcountMap refcnts
成员变量中 把存储当前对象引用计数的记录(key-value
)从引用计数表中移除,类似于从字典中把这条key-value
都删除(疑问:此时引用计数已经是0了,那最后这个引用计数表的处理是不是多余的,什么情况下会执行进来???)。
|
|
什么场景下才会有cxxdtor
?
|
|
在dealloc
方法中如果有对self
的引用,比如- (void)dealloc { id obj = self; }
,是不会发生引用计数+1
的,runtime
处理如下:
|
|
Weak
weak_table_t
是全局保存弱引用的哈希表,它是通过对object
地址做hash
计算,然后从8
个SideTable
数组中取出其中一张,然后再从SideTable
中读取到weak_table
。weak_table_t
是以 object
地址为 key
,以 weak_entry_t
为 value
。
weak_entry_t
是用来存储所有指向某个对象的弱引用变量的地址的,里面有个weak_referrer_t
数组,它存储的其实是弱引用的指针,即指针的指针,这么做的目的是可以把弱引用置为nil
。
weak_entry_t
中有2
种结构,当存储的弱引用数量<= 4
个的时候用的其实是个定长数组,> 4
的时候才会转为哈希数组。(这里使用哈希数组的原因应该是为了处理B弱引用A,然后B先释放了,这时那个弱引用可能也要置为nil,用hash数组的话查询速度会比较快)。往weak_entry_t
中添加弱引用变量时,即更新weak_referrer_t
采用的是定向寻址法;
往weak_table
中插入weak_entry_t
时,先是对object
地址取hash
作为它的index
,如果这个index
下的位置不为空,则通过一个算法(index = (index+1) & weak_table->mask
)重新计算生成一个新的index
再读取对应的位置,直到找到一个空位置,然后把weak_entry_t
放进去,同时更新元素数量。这种插入方式其实也是定向寻址法。
hash 函数,与 mask 做与操作,防止 index 越界;
|
|
weak_table_t
还有一个扩容和缩容的处理,当前使用容量占到总容量(mask + 1
)的 3/4
的时候会进行扩容处理,扩大到现有总容量的2
倍。 当总容量超过1024
,而实际使用的空间低于总空间的 1/16
时则会进行容量压缩,缩到现有总容量的1/8
(为什么是八分之一?是为了保证总容量是现有使用容量的2
倍)。
@synchronized原理
先从当前线程的
TLS
中尝试获取SyncData
(本身是个单向链表),如果存在并且SyncData
中的object
与传进来的object
相同,则说明找到对应的SyncData
了。更新锁数量(lockCount
),并返回SyncData
。(注意:一条线程的
TLS
中只能存唯一一个SyncData
,假如已经存在了但是object
并不与自己传进来的一致,则创建新的SyncData
后并不会更新到TLS
中,而是保存到pthread_data
中,有点先入为主的意思)从
pthread_data
中获取SyncCache
(里面存着一个SyncCacheItem
数组,SyncCacheItem
存的是SyncData
),如果存在则遍历SyncCacheItem
数组,如果cacheItem
中的syncData
中的object
与传进来的object
相同,则更新item->lockCount
,然后返回SyncData
。走到这里就说明没有从
thread cache
中找到合适的SyncData
。这时就会从全局StripMap<SyncList> sDataLists
表中读取,先通过对象object
的hash
值取出一个SyncList
,接着拿到SyncList
中的SyncData
链表,然后遍历整个链表。a. 如果发现与
object
匹配的SyncData
则更新SyncData
中的threadCount
数量,然后把找到的这个SyncData
保存到TLS
或者pthread_data
中的SyncCache
里面;b. 如果遍历到最后也没发现匹配的,则找到链表中第一个未使用(
SyncData
中的threadCount = 0
)的SyncData
,进行复用。这个SyncData
也会和上面一样进行缓存;c. 如果没找到匹配的,也没找到未使用的,则创建一个新的
SyncData
。这个新的SyncData
会先保存到SyncList
中,然后也会和上面一样保存到TLS
或者pthread_data
中一份,即新创建的有2份缓存。
Associate 原理
所有的关联对象都是由AssociationsManager
管理的,AssociationsManager
里面是由一个静态AssociationsHashMap
来存储所有的关联对象。这相当于把所有对象的关联对象都存在一个全局hashMap
里面,hashMap
的key
是这个对象的指针地址
(任意两个不同对象的指针地址一定是不同的),而这个hashMap
的value
又是一个ObjectAssociationsMap
,里面保存了关联对象的key
和对应的value
值。runtime
的销毁对象函数objc_destructInstance
里面会判断这个对象有没有关联对象,如果有,则会调用_object_remove_assocations
做关联对象的清理工作。
在set
和get
时,即对内部的map
进行操作时都会用manager
中的spinlock
(底层其实还是unfair_lock
),所以set
、get
时一般情况下是线程安全的。但是可能是为了追求性能,set
时把旧对象的释放放到了锁外,atomic get
时为了保证线程安全,会retain
一下访问对象,在锁外又autorelease
了一下,如果不执行retain
操作可能会出现数据竞争。可以参考下这篇文章: AssociatedObject 源码分析:如何实现线程安全?
Runloop
推荐:029:runloop
摘自
ibireme
的 深入理解runloop
|
|
Source0与Source1的区别
Source有两个版本:Source0 和 Source1:
Source0
只包含了一个回调(函数指针),它并不能主动触发事件。使用时,你需要先调用CFRunLoopSourceSignal(source)
,将这个Source
标记为待处理,然后手动调用CFRunLoopWakeUp(runloop)
来唤醒RunLoop
,让其处理这个事件。Source1
包含了一个mach_port
和一个回调(函数指针),被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程。
GCD
可创建的最大线程数是 255
|
|
自定义串行队列是
overcommit
的,并行队列不是overcommit
的自定义队列的目标队列在初始化时传参为
NULL
,然后会为其从_dispatch_root_queues
中获取一个根目标队列;当tq
为NULL
,即入参目标队列为DISPATCH_TARGET_QUEUE_DEFAULT
(值是NULL
) 时, 根据qos
和overcommit
从_dispatch_root_queues
全局的根队列数组中获取一个根队列作为新队列的目标队列1 2 3 4 5 6 7 8 9 10
if (!tq) { tq = _dispatch_get_root_queue( qos == DISPATCH_QOS_UNSPECIFIED ? DISPATCH_QOS_DEFAULT : qos, overcommit == _dispatch_queue_attr_overcommit_enabled)->_as_dq; if (unlikely(!tq)) { // 如果未取得目标队列则 crash DISPATCH_CLIENT_CRASH(qos, "Invalid queue attribute"); } }
dispatch_sync
a. 首先将任务加入队列
b. 在当前线程执行任务block
,没有切换线程的操作
c. 将任务移出队列
d. sync
中对串行队列的处理最终执行的是barrier
的内部函数: _dispatch_barrier_sync_f_inline
e. 会死锁的原因:执行时会检查当前队列的状态(是否正在等待),得到一个状态值,然后队列的状态值与当前所在线程的ID
(_dispatch_tid_self()
存在了dispatch_sync_context_s
的dsc_waiter
属性中)做比较,相等(线程属于队列)的话则判定为死锁。(相关处理在 __DISPATCH_WAIT_FOR_QUEUE__
函数中)
dispatch_async
a. 将异步任务(dispatch_queue 、 block
)封装为 dispatch_continuation_t
类型
b. 然后执行 _dispatch_continuation_async -> dx_push
递归重定向到根队列,接着执行_dispatch_root_queue_poke
进行出队操作,通过创建线程执行dx_invoke
进行 block
回调;
dispatch_barrier_async
a. 和dispatch_async
流程一样,只是里面有一个while
循环,等队列中的barrier
前面的任务执行完,才执行后面的;
b. 这里有个优化是:封装成 dispatch_continuation_s
结构时,会先从当前线程的TLS
中获取一下,获取不到再从堆上创建新的
dispatch_group
a. dispatch_group
内部维护着一个数值,初始值为0
,enter
时减4
,leave
时加4
https://juejin.cn/post/6902346229868019719#heading-4
b. 等待用的是while
循环,而不是信号量
dispatch_semaphore_t
a. dispatch_semaphore_wait
时里面其实是起了一个do-while
循环,不断的去查询原子变量的值,不满足条件时会一直循环,借此阻塞流程的进行。有点像dispatch_once
dispatch_group_async
内部其实是对dispatch_async
和 dispatch_group_enter / dispatch_group_leave
的封装
线程池复用原理
线程创建后从队列里取出任务执行,任务执行后使用信号量使其等待5
秒钟,如果在这期间再有GCD
任务过来,会先尝试唤醒线程,让它继续工作,否则等待超时后线程会自动结束,被系统销毁。(不是tableview
中的复用池机制)
|
|
dispatch_once
dispatch_once
函数中的token
(dispatch_once_t
) 会被强转为dispatch_once_gate_t
类型,而dispatch_once_gate_t
里面是个union
联合体类型,其中dgo_once
用来记录当前block
的执行状态,执行完后状态会被标记为DLOCK_ONCE_DONE
。
|
|
我们首先获取dgo_once
变量的值,如果是DLOCK_ONCE_DONE
,则表示已经执行过了,直接return掉;
如果是DLOCK_ONCE_UNLOCKED
状态,则表示首次执行,然后会把当前的线程id
存到dgo_once
变量中,然后开始执行block任务,结束后会把dgo_once
置为DLOCK_ONCE_DONE
;
如果有其他线程执行过来,根据dgo_once
判断,发现正在执行中,则会进入等待流程,等待其实是启了个for (;;)
无限循环,在循环中不断地通过原子操作查询dgo_once
的状态,等发现变为DLOCK_ONCE_DONE
后则退出循环。
dispatch_source_merge_data
对应的结构定义
|
|
把任务包装成dispatch_continuation_t
对象,每次dispatch_source_merge_data
时对内部变量进行原子性的ADD、OR、REPLACE
等操作,并执行dx_wakeup
函数,dx_wakeup
是个宏定义,其实调用的是_dispatch_source_wakeup
,wakeup这个函数其实是一个入队操作,但并不是每次都会进行入队(此处还未完全看明白 o(╯□╰)o ),接着会执行_dispatch_main_queue_drain -> _dispatch_continuation_pop_inline
出队操作,流程基本和dispatch_async
一致。
NSTimer
timer添加到runloop的过程:
如果是commonMode
,会被添加到runloop
持有的一个_commonModeItems
集合中, 然后调用 __CFRunLoopAddItemToCommonModes
函数,把timer
添加到runloopMode
对象持有的_timers
数组中 ,同时也会把modeName
添加到runloopTimer
的 _rlModes
中,记录runloopTimer
都能在哪种runloop mode
下执行;
如果是普通mode
,则先获取这个runloopMode
对象,把runloopMode
的name
添加到runloopTimer
持有的 _rlModes
集合中,然后调用 __CFRepositionTimerInMode
函数,把runloopTimer
插入runloopMode
持有的 _timers
数组中(如果数组中已经存在了,则先做移除操作);
上面添加完成后,会调用 __CFRepositionTimerInMode
函数,然后调用 __CFArmNextTimerInMode
,再调用 mk_timer_arm
函数把 CFRunLoopModeRef
的 _timerPort
和一个时间点注册到系统中,等待着 mach_msg
发消息唤醒休眠中的 runloop
起来执行到达时间的计时器。(macOS 和 iOS 下都是使用 mk_timer
来唤醒 runloop
);
每次计时器都会调用 __CFArmNextTimerInMode
函数注册计时器的下次执行时间(这个时间是基于本次执行的理论时间叠加得到的,而非当前的真实时间,也就是说假如本次执行滞后了,不会影响下次理论上要执行的时间点),休眠中的runloop
通过当前runloop mode
的 _timerPort
端口唤醒,然后在本次runloop
循环中在 _CFRunloopDoTimers
函数中循环调用 __CFRunLoopDoTimer
函数,执行达到触发时间的timer
的 _callout
函数。
__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__(rlt->_callout, rlt, context_info);
是执行计时器的 _callout
函数。
NSTimer 不准时问题
通过上面的 NSTimer
执行流程可看到计时器的触发回调完全依赖 runloop
的运行(macOS 和 iOS 下都是使用 mk_timer
来唤醒 runloop
),使用 NSTimer
之前必须注册到 run loop
,但是 run loop
为了节省资源并不会在非常准确的时间点调用计时器,如果一个任务执行时间较长(例如本次 run loop
循环中 source0
事件执行时间过长或者计时器自身回调执行时间过长,都会导致计时器下次正常时间点的回调被延后或者延后时间过长的话则直接忽略这次回调(计时器回调执行之前会判断当前的执行状态 !__CFRunLoopTimerIsFiring(rlt)
,如果是计时器自身回调执行时间过长导致下次回调被忽略的情况大概与此标识有关 )),那么当错过一个时间点后只能等到下一个时间点执行,并不会延后执行(NSTimer
提供了一个 tolerance
属性用于设置宽容度,即当前时间点已经过了计时器的本次触发点,但是超过的时间长度小于 tolerance
的话,那么本次计时器回调还可以正常执行,不过是不准时的延后执行。 tolerance
的值默认是 0,最大值的话是计时器间隔时间_interval
的一半,可以根据自身的情况酌情设置 tolerance
的值。
(NSTimer
不是一种实时机制,以 main run loop
来说它负责了所有的主线程事件,例如 UI
界面的操作,负责的运算使当前 run loop
持续的时间超过了计时器的间隔时间,那么计时器下一次回调就被延后,这样就造成 timer
的不准时,计时器有个属性叫做 tolerance
(宽容度),标示了当时间点到后,容许有多少最大误差。如果延后时间过长的话会直接导致计时器直接跳过本次回调。)