YYCache 的源码解析
基本使用
创建
创建 Cache 实例,之后的所有操作都是通过它。
1 | YYCache *cache = [YYCache cacheWithName:@"cacheName"]; |
使用方法一览
之前创建的 YYCache *cache
的实例方法。包含了读取、写入,移除的所有相关方法
1 | //是否包含某缓存,无回调 |
结构
YYCache
:最外层接口,调用了YYMemoryCache与YYDiskCache的相关方法。YYMemoryCache
:负责内存缓存_YYLinkedMap
:内存缓存进行 LRU 所使用的双向链表类_YYLinkedMapNode
:是_YYLinkedMap
使用的节点类。YYDiskCache
:负责磁盘缓存YYKVStorage
:YYDiskCache
的实现类,用于管理磁盘缓存。YYKVStorageItem
:YYKVStorage
内部用于封装某个缓存的类。
源码分析
YYCache
初始化
1 | - (instancetype)initWithName:(NSString *)name { |
是否存在
1 | - (BOOL)containsObjectForKey:(NSString *)key { |
获取缓存
1 | - (id<NSCoding>)objectForKey:(NSString *)key { |
设置缓存
1 | - (void)setObject:(id<NSCoding>)object forKey:(NSString *)key { |
移除缓存
1 | // 先移除内存缓存,再移除磁盘缓存 |
YYMemoryCache
概览
初始化
1 | @implementation YYMemoryCache { |
方法
YYMemoryCache
包含两类方法,一类是操作内存缓存的方法,一类是缓存自动清理的方法:
1 |
|
_YYLinkedMap
概览
_YYLinkedMap
是保存内存缓存的实例。内置了一个字典和一个双向链表用于保存数据,两者分工不同。字典是用来快速存取节点的,双向链表则是用来进行缓存淘汰的,双向链表有助于快速移动节点。
1 | @interface _YYLinkedMap : NSObject { |
方法实现
_YYLinkedMap
中的方法都是用来操作节点的,稍微熟悉数据结构的人都能看懂。这里不详细分析,只举两个例子。
插入节点
插入节点需要先把键值对存到字典中,然后将节点插入链表的头部,是一个操作链表的基本操作。
1 | - (void)insertNodeAtHead:(_YYLinkedMapNode *)node { |
移除节点
移除节点要先把保存的键值对移除,然后减少缓存中的总开销。
1 | - (void)removeNode:(_YYLinkedMapNode *)node { |
缓存策略
YYMemoryCache
的操作类似于 NSCache
,它们的不同之处在于:
YYMemoryCache
使用 LRU(least-recently-used) 算法来清理使用频率较低的缓存;NSCache
的淘汰方式不确定YYMemoryCache
使用三个维度 count(缓存数量),cost(开销),age(距上一次的访问时间)控制缓存清除;NSCache
不确定YYMemoryCache
可以配置收到内存警告或者进入后台时自动清除缓存。
LRU 算法的思想就是优先保存最近用过的数据。将最近使用的节点放到链表的头部,并且优先淘汰链表尾部的数据。
方法实现
缓存相关方法
缓存相关方法比较类似,我们这里只举两个例子
获取缓存对象
1 | - (id)objectForKey:(id)key { |
获取对象以及对链表进行操作的时候注意要加锁
设置缓存对象
1 | - (void)setObject:(id)object forKey:(id)key withCost:(NSUInteger)cost { |
这里有一个关于淘汰的缓存再哪里释放的技巧,即最后在哪个线程调用,就在哪个线程释放。
缓存清理相关方法
缓存清理包含两种情况。一种是主动添加缓存之后的主动触发,还有一种是周期性的后台触发。创建 YYCache
时调用的 _trimRecursively
方法就会触发周期性的后台触发:
1 | //YYMemoryCache.m |
缓存清理有三个维度包括 age, count, cost。它们的实现也非常相似,这里只列举其中一个说明:
cost 清理
1 | - (void)_trimToCost:(NSUInteger)costLimit { |
优于 OSSpinLock 的优先级反转问题,不建议使用。因此,作者使用 tryLock 的方式尝试获得锁。
YYDiscCache
YYDiscCache
和 YYMemoryCache
的操作非常相似。不同之处在于,YYMemoryCache
将结果保存在内存中的字典中,用双向链表体现操作时间的远近。而 YYDiscCache
将结果保存在数据库中,通过表中最近更新时间体现操作时间的远近。
初始化
1 | - (instancetype)initWithPath:(NSString *)path { |
初始化方法。根据阈值来设置存储的 type。分为三种 type:
- YYKVStorageTypeFile: 阈值为0的时候,纯文件形式的存储。
- YYKVStorageTypeSQLite: 阈值为无穷大的时候,纯数据库的存储
- YYKVStorageTypeMixed: 阈值为特定值的时候,混合存储。大于阈值的时候用文件存储,小于阈值的时候使用数据库存储。一般情况下,都是数据库存储。
初始化的过程中,在初始化 YYKVStorage
的时候,开启了数据库。同时还创建了 dispatch_semaphore
形式的锁,以及全局处理队列。
方法
缓存策略和内存缓存相似。同样的,各个磁盘缓存的方法与内存缓存的方法类似。所以这里还是只分析一个存储的方法。
YYDiskCache
中的方法,在传入要保存的键值对后,先将值归档为 NSData
,然后判断其和阈值的大小,如果超过了阈值并且 type 不是只能使用数据库存储,那么生成一个文件名,之后就会以这个文件名存储。
1 | // YYDiskCache.m |
生成文件名的方式是通过把 key 通过 MD5 转为 16 位的 hash 值。
1 | - (NSString *)_filenameForKey:(NSString *)key { |
真正存储的方法是在 YYStorage
中。如果有文件名,那么优先存文件,如果不成功再尝试存入数据库。
1 | // YYStorage.m |
1 | // 将data写到文件 |
存入数据库中调用的方法中,先在缓存中查找 sqlite3_stmt
。然后绑定参数,执行 sql。
1 | - (BOOL)_dbSaveWithKey:(NSString *)key value:(NSData *)value fileName:(NSString *)fileName extendedData:(NSData *)extendedData { |
几个问题
为什么 YYMemoryCache
使用互斥锁 pthread_mutex
,而 YYDiskCache
使用信号量 dispatch_semaphore
作者通过 pthread_mutex
的 pthread_mutex_trylock()
和忙等 usleep()
来替代 OSSpinLock
,这是因为使用互斥锁,没有拿到锁的线程会被挂起。当释放锁激活其他线程的时候,就唤醒挂起的线程。需要上下文切换,信号发送等开销,效率低于自旋锁。所以使用这种方式替代自旋锁。
因为 YYDiskCache
在写入比较大的缓存时,可能会有比较长的等待时间,而dispatch_semaphore
在这个时候是不消耗CPU资源的,所以比较适合。
为什么 YYMemoryCache
使用双向链表
双向链表的优势在于在头尾插入或者删除的时候时间复杂度最低。而缓存的存入和清理需要频繁的头部插入,尾部删除。所以使用双向链表最为合适。
YYMemoryCache
内容使用什么数据结构保存数据的
通过一个双向链表和一个字典保存。字典的作用是 O(1) 的时间内找到数据,链表的作用是为了进行 LRU 的缓存清理。
如何执行定时任务来定时清理缓存
作者使用的是 dispatch_after
的延时 + 递归的方式进行定时执行操作,而不是使用 NSTimer 的 repeat
子线程释放
一个对象最后在哪个线程中使用,那么最终就会在哪个线程的 runloop 中销毁。
1 | _YYLinkedMapNode *node = [_lru removeTailNode]; |
对象的销毁虽然消耗资源不多,但累积起来也是不容忽视的。通常当容器类持有大量对象时,其销毁时的资源消耗就非常明显。同样的,如果对象可以放到后台线程去释放,那就挪到后台线程去。这里有个小 Tips:把对象捕获到 block 中,然后扔到后台队列去随便发送个消息以避免编译器警告,就可以让对象在后台线程销毁了。
思考题
- YYMemoryCache 是如何实现的?
- YYMemoryCache 从哪些维度维护?
- YYMemoryCache 和 NSCache 有什么区别?
- 如何使用互斥锁实现一个自旋锁?
- 为什么 Memory 使用自旋锁,Disk 使用信号量?
- Disk 的缓存策略是怎样的?