前言
日常的iOS开发过程中,经常会用到缓存,但是什么样的缓存才能被叫做优秀的缓存,或者说优秀的缓存应该具备哪些特质?YYCache我认为是一个比较优秀的缓存,代码逻辑清晰,注释详尽,加上自身不算太大的代码量使得其阅读非常简单,更可贵的是它的性能还很高。
YYCache简介
我们先来简单看一下 YYCache 的代码结构,YYCache 是由 YYMemoryCache 与 YYDiskCache 两部分组成的,其中 YYMemoryCache 作为高速内存缓存,而 YYDiskCache 则作为低速磁盘缓存。
通常一个缓存是由内存缓存和磁盘缓存组成,内存缓存提供容量小但高速的存取功能,磁盘缓存提供大容量但低速的持久化存储。
@interface YYCache : NSObject
/** 缓存名称 */
@property (copy, readonly) NSString *name;
/** memoryCache*/
@property (strong, readonly) YYMemoryCache *memoryCache;
/** diskCache*/
@property (strong, readonly) YYDiskCache *diskCache;
/**判断key是否存在*/
- (BOOL)containsObjectForKey:(NSString *)key;
/**判断key是否存在,并执行block*/
- (void)containsObjectForKey:(NSString *)key withBlock:(nullable void(^)(NSString *key, BOOL contains))block;
/**获取key值对应的对象 会阻塞调用的进程*/
- (nullable id)objectForKey:(NSString *)key;
/** 获取key值对应的对象,并执行block*/
- (void)objectForKey:(NSString *)key withBlock:(nullable void(^)(NSString *key, id object))block;
/** 对某个key设置对象,阻塞线程*/
- (void)setObject:(nullable id)object forKey:(NSString *)key;
/** 设置key的对象,线程会立即返回,设置成功后回调block*/
- (void)setObject:(nullable id)object forKey:(NSString *)key withBlock:(nullable void(^)(void))block;
/**删除key对应的对象 阻塞线程 */
- (void)removeObjectForKey:(NSString *)key;
/**删除key对应的object 线程会立即返回,删除成功后回调block*/
- (void)removeObjectForKey:(NSString *)key withBlock:(nullable void(^)(NSString *key))block;
/**清空缓存*/
- (void)removeAllObjects;
/** 清空缓存, 线程会立即返回,清空成功后回调block */
- (void)removeAllObjectsWithBlock:(void(^)(void))block;
/**清空缓存, 线程会立即返回,后台线程执行block*/
- (void)removeAllObjectsWithProgressBlock:(nullable void(^)(int removedCount, int totalCount))progress endBlock:(nullable void(^)(BOOL error))end;
上边整理了几个常用的方法,做了简单的中文注释,从代码中我们可以看到 YYCache 中持有 YYMemoryCache 与 YYDiskCache,并且对外提供了一些接口。这些接口基本都是基于 Key 和 Value 设计的,类似于 iOS 原生的字典类接口(增删改查)
YYMemoryCache
YYMemoryCache 是一个高速的内存缓存,用于存储键值对。它与 NSDictionary 相反,Key 被保留并且不复制。API 和性能类似于 NSCache,所有方法都是线程安全的。
YYMemoryCache 使用 LRU(least-recently-used) 算法来驱逐对象。介绍一下LRU:
LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。最常见的实现是使用一个链表保存缓存数据,详细算法实现如下:
1. 新数据插入到链表头部;
2. 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;
3. 当链表满的时候,将链表尾部的数据丢弃。
分析
【命中率】
当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。
【复杂度】
实现简单。
【代价】
命中时需要遍历链表,找到命中的数据块索引,然后需要将数据移到头部。
YYMemoryCache是线程安全的
@implementation YYMemoryCache {
pthread_mutex_t _lock; // 线程锁,旨在保证 YYMemoryCache 线程安全
_YYLinkedMap *_lru; // _YYLinkedMap,YYMemoryCache 通过它间接操作缓存对象
dispatch_queue_t _queue; // 串行队列,用于 YYMemoryCache 的 trim 操作
}
没错,YYMemoryCache使用 pthread_mutex线程锁来确保线程安全。最初YYMemoryCache 这里使用的锁是 OSSpinLock 自旋锁,后面有人在 Github 向作者提 issue 反馈 OSSpinLock 不安全,经过作者的确认(详见 不再安全的 OSSpinLock)最后选择用 pthread_mutex 替代 OSSpinLock。
具体来说,如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,它会处于 spin lock 的忙等状态从而占用大量 CPU。此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成、无法释放 lock。这并不只是理论上的问题,libobjc 已经遇到了很多次这个问题了,于是苹果的工程师停用了 OSSpinLock。
_YYLinkedMap 与 _LinkedMapNode
YYMemoryCache 无法直接操作缓存,而是通过内部的 _YYLinkedMapNode 与 _YYLinkedMap 来的操作缓存对象。这两个类对于上文中提到的 LRU 缓存算法的理解至关重要。
@interface _YYLinkedMapNode : NSObject {
@package
__unsafe_unretained _YYLinkedMapNode *_prev; // __unsafe_unretained 是为了性能优化,节点被 _YYLinkedMap 的 _dic 强引用
__unsafe_unretained _YYLinkedMapNode *_next; // __unsafe_unretained 是为了性能优化,节点被 _YYLinkedMap 的 _dic 强引用
id _key;
id _value;
NSUInteger _cost; // 记录开销,对应 YYMemoryCache 提供的 cost 控制
NSTimeInterval _time;// 记录时间,对应 YYMemoryCache 提供的 age 控制
}
@end
@interface _YYLinkedMap : NSObject {
@package
CFMutableDictionaryRef _dic; // // 不要直接设置该对象
NSUInteger _totalCost;
NSUInteger _totalCount;
_YYLinkedMapNode *_head; // MRU, 最常用节点,不要直接修改它
_YYLinkedMapNode *_tail; // LRU, 最常用节点,不要直接修改它
BOOL _releaseOnMainThread; // 对应 YYMemoryCache 的 releaseOnMainThread
BOOL _releaseAsynchronously; // 对应 YYMemoryCache 的 releaseAsynchronously
- (void)insertNodeAtHead:(_YYLinkedMapNode *)node;
- (void)bringNodeToHead:(_YYLinkedMapNode *)node;
- (void)removeNode:(_YYLinkedMapNode *)node;
- (_YYLinkedMapNode *)removeTailNode;
- (void)removeAll;
}
对数据结构与算法不陌生的同学,应该一眼就看的出来 _YYLinkedMapNode 与 _YYLinkedMap 这的本质。其实就是双向链表节点和双向链表。
_YYLinkedMapNode 作为双向链表节点,除了基本的 _prev、_next,还有键值缓存基本的 _key 与 _value,我们可以把 _YYLinkedMapNode 理解为 YYMemoryCache 中的一个缓存对象。_YYLinkedMap 作为由 _YYLinkedMapNode 节点组成的双向链表,使用 CFMutableDictionaryRef _dic 字典存储 _YYLinkedMapNode。这样在确保 _YYLinkedMapNode 被强引用的同时,能够利用字典的 Hash 快速定位用户要访问的缓存对象,这样既符合了键值缓存的概念又省去了自己实现的麻烦。总得来说 YYMemoryCache 是通过使用 _YYLinkedMap双向链表来操作 _YYLinkedMapNode 缓存对象节点的。
YYDiskCache简介
YYDiskCache 是一个线程安全的磁盘缓存,用于存储由 SQLite 和文件系统支持的键值对(类似于 NSURLCache 的磁盘缓存)。
YYDiskCache 具有以下功能:
通过 LRU 算法来删除对象。
它可以被配置为当没有可用的磁盘空间时自动驱逐缓存对象。
它可以自动抉择每个缓存对象的存储类型(sqlite/file)以便提供更好的性能表现。
@interface YYDiskCache : NSObject
#pragma mark - Attribute
@property (nullable, copy) NSString *name; // 缓存名称,默认为 nil
@property (readonly) NSString *path; // 缓存路径
@property (readonly) NSUInteger inlineThreshold; // 阈值,大于阈值则存储类型为 file;否则存储类型为 sqlite
@property (nullable, copy) NSData *(^customArchiveBlock)(id object); // 用来替换 NSKeyedArchiver,你可以使用该代码块以支持没有 conform `NSCoding` 协议的对象
@property (nullable, copy) id (^customUnarchiveBlock)(NSData *data); // 用来替换 NSKeyedUnarchiver,你可以使用该代码块以支持没有 conform `NSCoding` 协议的对象
@property (nullable, copy) NSString *(^customFileNameBlock)(NSString *key); // 当一个对象将以 file 的形式保存时,该代码块用来生成指定文件名。如果为 nil,则默认使用 md5(key) 作为文件名
#pragma mark - Limit
@property NSUInteger countLimit; // 缓存对象数量限制,默认无限制,超过限制则会在后台逐出一些对象以满足限制
@property NSUInteger costLimit; // 缓存开销数量限制,默认无限制,超过限制则会在后台逐出一些对象以满足限制
@property NSTimeInterval ageLimit; // 缓存时间限制,默认无限制,超过限制则会在后台逐出一些对象以满足限制
@property NSUInteger freeDiskSpaceLimit; // 缓存应该保留的最小可用磁盘空间(以字节为单位),默认无限制,超过限制则会在后台逐出一些对象以满足限制
@property NSTimeInterval autoTrimInterval; // 缓存自动清理时间间隔,默认 60s
@property BOOL errorLogsEnabled; // 是否开启错误日志
#pragma mark - Initializer
- (nullable instancetype)initWithPath:(NSString *)path
inlineThreshold:(NSUInteger)threshold NS_DESIGNATED_INITIALIZER;
- (BOOL)containsObjectForKey:(NSString *)key;
- (nullable id)objectForKey:(NSString *)key;
- (void)setObject:(nullable id)object forKey:(NSString *)key;
- (void)removeObjectForKey:(NSString *)key;
- (void)removeAllObjects;
- (NSInteger)totalCount;
- (NSInteger)totalCost;
#pragma mark - Trim
- (void)trimToCount:(NSUInteger)count;
- (void)trimToCost:(NSUInteger)cost;
- (void)trimToAge:(NSTimeInterval)age;
#pragma mark - Extended Data
+ (nullable NSData *)getExtendedDataFromObject:(id)object;
+ (void)setExtendedData:(nullable NSData *)extendedData toObject:(id)object;
@end
YYDiskCache 是基于 sqlite 和 file 来做的磁盘缓存,我们的缓存对象可以自由的选择存储类型,下面简单对比一下:
sqlite: 对于小数据(例如 NSNumber)的存取效率明显高于 file。
file: 对于较大数据(例如高质量图片)的存取效率优于 sqlite。
所以 YYDiskCache 使用两者配合,灵活的存储以提高性能。
YYDiskCache 内部是基于一个单例 NSMapTable 管理,
NSMapTable 是类似于字典的集合,但具有更广泛的可用内存语义。NSMapTable 是 iOS6 之后引入的类,它基于 NSDictionary 建模,但是具有以下差异:
键/值可以选择 “weakly” 持有,以便于在回收其中一个对象时删除对应条目。
它可以包含任意指针(其内容不被约束为对象)。
您可以将 NSMapTable 实例配置为对任意指针进行操作,而不仅仅是对象
每当一个 YYDiskCache 被初始化时,其实会先到 NSMapTable 中获取对应 path 的 YYDiskCache 实例,如果获取不到才会去真正的初始化一个 YYDiskCache 实例,并且将其引用在 NSMapTable 中,这样做也会提升不少性能。
- (instancetype)initWithPath:(NSString *)path
inlineThreshold:(NSUInteger)threshold {
//初始化判断忽略
// 先从 NSMapTable 单例中根据 path 获取 YYDiskCache 实例,如果获取到就直接返回该实例
YYDiskCache *globalCache = _YYDiskCacheGetGlobal(path);
if (globalCache) return globalCache;
// 没有获取到则初始化一个 YYDiskCache 实例
// 要想初始化一个 YYDiskCache 首先要初始化一个 YYKVStorage
YYKVStorage *kv = [[YYKVStorage alloc] initWithPath:path type:type];
if (!kv) return nil;
// 根据刚才得到的 kv 和 path 入参初始化一个 YYDiskCache 实例,代码太长省略
...
// 开启递归清理,会根据 _autoTrimInterval 对 YYDiskCache trim
[self _trimRecursively];
// 向 NSMapTable 单例注册新生成的 YYDiskCache 实例
_YYDiskCacheSetGlobal(self);
// App 生命周期通知相关代码,省略
...
return self;
}
dispatch_semaphore 是信号量,但当信号总量设为 1 时也可以当作锁来。在没有等待情况出现时,它的性能比 pthread_mutex 还要高,但一旦有等待情况出现时,性能就会下降许多。相对于 OSSpinLock 来说,它的优势在于等待时不会消耗 CPU 资源。对磁盘缓存来说,它比较合适。
YYKVStorageItem 与 YYKVStorage
在上边的代码中,我们看到了YYKVStorage,YYDiskCache是通过YYKVStorage来操作缓存对象(sqlite/file),YYKVStorage 和 YYMemoryCache 中的双向链表 _YYLinkedMap扮演的角色是一样的,而对应于 _YYLinkedMap 中的节点 _YYLinkedMapNode,YYKVStorage 中也有一个类 YYKVStorageItem 充当着与缓存对象的角色。
/**
用于YYStorage存储键值对和属性信息
通常情况下,我们不应该直接使用这个类。
*/
@interface YYKVStorageItem : NSObject
@property (nonatomic, strong) NSString *key; ///< key
@property (nonatomic, strong) NSData *value; ///< value
@property (nullable, nonatomic, strong) NSString *filename; ///< filename (nil if inline)
@property (nonatomic) int size; ///< value's size in bytes
@property (nonatomic) int modTime; ///< modification unix timestamp
@property (nonatomic) int accessTime; ///< last access unix timestamp
@property (nullable, nonatomic, strong) NSData *extendedData; ///< extended data (nil if no extended data)
@end
/**
YYKVStorage 是基于 sqlite 和file的键值存储。
通常情况下,我们不应该直接使用这个类。
@warning
这个类的实例是 *非* 线程安全的,你需要确保
只有一个线程可以同时访问该实例。如果你真的
需要在多线程中处理大量的数据,应该分割数据
到多个 KVStorage 实例(分片)。
*/
@interface YYKVStorage : NSObject
#pragma mark - Attribute
@property (nonatomic, readonly) NSString *path; /// storage 路径
@property (nonatomic, readonly) YYKVStorageType type; /// storage 类型
@property (nonatomic) BOOL errorLogsEnabled; /// 是否开启错误日志
#pragma mark - Initializer
- (nullable instancetype)initWithPath:(NSString *)path type:(YYKVStorageType)type NS_DESIGNATED_INITIALIZER;
#pragma mark - Save Items
- (BOOL)saveItem:(YYKVStorageItem *)item;
...
#pragma mark - Remove Items
- (BOOL)removeItemForKey:(NSString *)key;
...
#pragma mark - Get Items
- (nullable YYKVStorageItem *)getItemForKey:(NSString *)key;
...
#pragma mark - Get Storage Status
- (BOOL)itemExistsForKey:(NSString *)key;
- (int)getItemsCount;
- (int)getItemsSize;
@end
这里我们看一下YYKVStorageType,这个枚举决定着 YYKVStorage 的存储类型
typedef NS_ENUM(NSUInteger, YYKVStorageType) {
/// The `value` is stored as a file in file system.
YYKVStorageTypeFile = 0,
/// The `value` is stored in sqlite with blob type.
YYKVStorageTypeSQLite = 1,
/// The `value` is stored in file system or sqlite based on your choice.
YYKVStorageTypeMixed = 2,
};
再看YYKVStorage代码的同时,发现一个细节
CFMutableDictionaryRef _dbStmtCache;
是 YYKVStorage 中的私有成员,它是一个可变字典充当着 sqlite3_stmt 缓存的角色。
- (sqlite3_stmt *)_dbPrepareStmt:(NSString *)sql {
if (![self _dbCheck] || sql.length == 0 || !_dbStmtCache) return NULL;
// 先尝试从 _dbStmtCache 根据入参 sql 取出已缓存 sqlite3_stmt
sqlite3_stmt *stmt = (sqlite3_stmt *)CFDictionaryGetValue(_dbStmtCache, (__bridge const void *)(sql));
if (!stmt) {
// 如果没有缓存再从新生成一个 sqlite3_stmt
int result = sqlite3_prepare_v2(_db, sql.UTF8String, -1, &stmt, NULL);
// 生成结果异常则根据错误日志开启标识打印日志
if (result != SQLITE_OK) {
if (_errorLogsEnabled) NSLog(@"%s line:%d sqlite stmt prepare error (%d): %s", __FUNCTION__, __LINE__, result, sqlite3_errmsg(_db));
return NULL;
}
// 生成成功则放入 _dbStmtCache 缓存
CFDictionarySetValue(_dbStmtCache, (__bridge const void *)(sql), stmt);
} else {
sqlite3_reset(stmt);
}
return stmt;
}
这样就可以省去一些重复生成 sqlite3_stmt 的开销。