内容简介:上一篇讲解了 YYCache 的使用方法,架构与内存缓存的设计。这一篇讲解磁盘缓存的设计与缓存组件的设计思路。YYDiskCache 负责处理容量大,相对低速的磁盘缓存。线程安全,支持异步操作。作为 YYCache 的第二级缓存,它与第一级缓存它与YYMemoryCache不同点是:
上一篇讲解了 YYCache 的使用方法,架构与内存缓存的设计。这一篇讲解磁盘缓存的设计与缓存组件的设计思路。
一. YYDiskCache
YYDiskCache 负责处理容量大,相对低速的磁盘缓存。线程安全,支持异步操作。作为 YYCache 的第二级缓存,它与第一级缓存 YYMemoryCache
的相同点是:
-
都具有查询,写入,读取,删除缓存的接口。
-
不直接操作缓存,也是间接地通过另一个类(YYKVStorage)来操作缓存。
-
它使用
LRU
算法来清理缓存。 -
支持按
cost
,count
和age
这三个维度来清理不符合标准的缓存。
它与YYMemoryCache不同点是:
-
根据缓存数据的大小来采取不同的形式的缓存:
-
数据库 sqlite: 针对小容量缓存,缓存的 data 和元数据都保存在数据库里。
-
文件+数据库的形式: 针对大容量缓存,缓存的 data 写在文件系统里,其元数据保存在数据库里。
除了 cost,count 和 age 三个维度之外,还添加了一个磁盘容量的维度。
这里需要说明的是:
对于上面的第一条:我看源码的时候只看出来有这两种缓存形式,但是从内部的缓存 type 枚举来看,其实是分为三种的:
typedef NS_ENUM(NSUInteger, YYKVStorageType) { YYKVStorageTypeFile = 0, YYKVStorageTypeSQLite = 1, YYKVStorageTypeMixed = 2, };
也就是说我只找到了第二,第三种缓存形式,而第一种纯粹的文件存储( YYKVStorageTypeFile
)形式的实现我没有找到:当 type 为
YYKVStorageTypeFile
和 YYKVStorageTypeMixed
的时候的缓存实现都是一致的:都是讲 data 存在文件里,将元数据放在数据库里面。
在 YYDiskCache 的初始化方法里,没有发现正确的将缓存类型设置为 YYKVStorageTypeFile 的方法:
//YYDiskCache.m - (instancetype)init { @throw [NSException exceptionWithName:@"YYDiskCache init error" reason:@"YYDiskCache must be initialized with a path. Use 'initWithPath:' or 'initWithPath:inlineThreshold:' instead." userInfo:nil]; return [self initWithPath:@"" inlineThreshold:0]; } - (instancetype)initWithPath:(NSString *)path { return [self initWithPath:path inlineThreshold:1024 * 20]; // 20KB } - (instancetype)initWithPath:(NSString *)path inlineThreshold:(NSUInteger)threshold { ... YYKVStorageType type; if (threshold == 0) { type = YYKVStorageTypeFile; } else if (threshold == NSUIntegerMax) { type = YYKVStorageTypeSQLite; } else { type = YYKVStorageTypeMixed; } ... }
从上面的代码可以看出来,当给指定初始化方法 initWithPath:inlineThreshold:
的第二个参数传入 0 的时候,缓存类型才是 YYKVStorageTypeFile。而且比较常用的初始化方法 initWithPath:
的实现里,是将 20kb 传入了指定初始化方法里,结果就是将 type 设置成了 YYKVStorageTypeMixed。
而且我也想不出如果只有文件形式的缓存的话,其元数据如何保存。如果有读者知道的话,麻烦告知一下,非常感谢了~~
在本文暂时对于上面提到的”文件+数据库的形式”在下文统一说成文件缓存了。
在接口的设计上,YYDiskCache 与 YYMemoryCache 是高度一致的,只不过因为有些时候大文件的访问可能会比较耗时,所以框架作者在保留了与 YYMemoryCache 一样的接口的基础上,还在原来的基础上添加了 block 回调,避免阻塞线程。来看一下 YYDiskCache 的接口(省略了注释):
//YYDiskCache.h - (BOOL)containsObjectForKey:(NSString *)key; - (void)containsObjectForKey:(NSString *)key withBlock:(void(^)(NSString *key, BOOL contains))block; - (nullable id<NSCoding>)objectForKey:(NSString *)key; - (void)objectForKey:(NSString *)key withBlock:(void(^)(NSString *key, id<NSCoding> _Nullable object))block; - (void)setObject:(nullable id<NSCoding>)object forKey:(NSString *)key; - (void)setObject:(nullable id<NSCoding>)object forKey:(NSString *)key withBlock:(void(^)(void))block; - (void)removeObjectForKey:(NSString *)key; - (void)removeObjectForKey:(NSString *)key withBlock:(void(^)(NSString *key))block; - (void)removeAllObjects; - (void)removeAllObjectsWithBlock:(void(^)(void))block; - (void)removeAllObjectsWithProgressBlock:(nullable void(^)(int removedCount, int totalCount))progress endBlock:(nullable void(^)(BOOL error))end; - (NSInteger)totalCount; - (void)totalCountWithBlock:(void(^)(NSInteger totalCount))block; - (NSInteger)totalCost; - (void)totalCostWithBlock:(void(^)(NSInteger totalCost))block; #pragma mark - Trim - (void)trimToCount:(NSUInteger)count; - (void)trimToCount:(NSUInteger)count withBlock:(void(^)(void))block; - (void)trimToCost:(NSUInteger)cost; - (void)trimToCost:(NSUInteger)cost withBlock:(void(^)(void))block; - (void)trimToAge:(NSTimeInterval)age; - (void)trimToAge:(NSTimeInterval)age withBlock:(void(^)(void))block;
从上面的接口代码可以看出,YYDiskCache 与 YYMemoryCache 在接口设计上是非常相似的。但是,YYDiskCache 有一个非常重要的属性,它 作为用 sqlite 做缓存还是用文件做缓存的分水岭 :
//YYDiskCache.h @property (readonly) NSUInteger inlineThreshold;
这个属性的默认值是 20480byte
,也就是 20kb。即是说,如果缓存数据的长度大于这个值,就使用文件存储;如果小于这个值,就是用 sqlite 存储。来看一下这个属性是如何使用的:
首先我们会在 YYDiskCache 的指定初始化方法里看到这个属性:
//YYDiskCache.m - (instancetype)initWithPath:(NSString *)path inlineThreshold:(NSUInteger)threshold { ... _inlineThreshold = threshold; ... }
在这里将 _inlineThreshold
赋值,也是唯一一次的赋值。然后在写入缓存的操作里判断写入缓存的大小是否大于这个临界值,如果是,则使用文件缓存:
//YYDiskCache.m - (void)setObject:(id<NSCoding>)object forKey:(NSString *)key { ... NSString *filename = nil; if (_kv.type != YYKVStorageTypeSQLite) { //如果长度大临界值,则生成文件名称,使得filename不为nil if (value.length > _inlineThreshold) { filename = [self _filenameForKey:key]; } } Lock(); //在该方法内部判断filename是否为nil,如果是,则使用sqlite进行缓存;如果不是,则使用文件缓存 [_kv saveItemWithKey:key value:value filename:filename extendedData:extendedData]; Unlock(); }
现在我们知道了 YYDiskCache 相对于 YYMemoryCache 最大的不同之处是缓存类型的不同。
细心的朋友会发现上面这个写入缓存的方法 ( saveItemWithKey:value:filename:extendedData:
)实际上是属于 _kv
的。这个 _kv 就是上面提到的 YYKVStorage 的实例,它在 YYDiskCache 的初始化方法里被赋值:
//YYDiskCache.m - (instancetype)initWithPath:(NSString *)path inlineThreshold:(NSUInteger)threshold { ... YYKVStorage *kv = [[YYKVStorage alloc] initWithPath:path type:type]; if (!kv) return nil; _kv = kv; ... }
同样地,再举其他两个接口为例,内部也是调用了 _kv 的方法:
- (BOOL)containsObjectForKey:(NSString *)key { if (!key) return NO; Lock(); BOOL contains = [_kv itemExistsForKey:key]; Unlock(); return contains; } - (void)removeObjectForKey:(NSString *)key { if (!key) return; Lock(); [_kv removeItemForKey:key]; Unlock(); }
所以是时候来看一下 YYKVStorage 的接口和实现了:
YYKVStorage
YYKVStorage 实例负责保存和管理所有磁盘缓存。和 YYMemoryCache 里面的 _YYLinkedMap
将缓存封装成节点类 _YYLinkedMapNode
类似,YYKVStorage 也将某个单独的磁盘缓存封装成了一个类,这个类就是 YYKVStorageItem
,它保存了某个缓存所对应的一些信息(key、 value、文件名、大小等等):
//YYKVStorageItem.h @interface YYKVStorageItem : NSObject @property (nonatomic, strong) NSString *key; //键 @property (nonatomic, strong) NSData *value; //值 @property (nullable, nonatomic, strong) NSString *filename; //文件名 @property (nonatomic) int size; //值的大小,单位是byte @property (nonatomic) int modTime; //修改时间戳 @property (nonatomic) int accessTime; //最后访问的时间戳 @property (nullable, nonatomic, strong) NSData *extendedData; //extended data @end
既然在这里将缓存封装成了 YYKVStorageItem 实例, 那么作为缓存的管理者,YYKVStorage 就必然有操作 YYKVStorageItem 的接口了 :
//YYKVStorage.h //写入某个item - (BOOL)saveItem:(YYKVStorageItem *)item; //写入某个键值对,值为NSData对象 - (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value; //写入某个键值对,包括文件名以及data信息 - (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value filename:(nullable NSString *)filename extendedData:(nullable NSData *)extendedData; #pragma mark - Remove Items //移除某个键的item - (BOOL)removeItemForKey:(NSString *)key; //移除多个键的item - (BOOL)removeItemForKeys:(NSArray<NSString *> *)keys; //移除大于参数size的item - (BOOL)removeItemsLargerThanSize:(int)size; //移除时间早于参数时间的item - (BOOL)removeItemsEarlierThanTime:(int)time; //移除item,使得缓存总容量小于参数size - (BOOL)removeItemsToFitSize:(int)maxSize; //移除item,使得缓存数量小于参数size - (BOOL)removeItemsToFitCount:(int)maxCount; //移除所有的item - (BOOL)removeAllItems; //移除所有的item,附带进度与结束block - (void)removeAllItemsWithProgressBlock:(nullable void(^)(int removedCount, int totalCount))progress endBlock:(nullable void(^)(BOOL error))end; #pragma mark - Get Items //读取参数key对应的item - (nullable YYKVStorageItem *)getItemForKey:(NSString *)key; //读取参数key对应的data - (nullable NSData *)getItemValueForKey:(NSString *)key; //读取参数数组对应的item数组 - (nullable NSArray<YYKVStorageItem *> *)getItemForKeys:(NSArray<NSString *> *)keys; //读取参数数组对应的item字典 - (nullable NSDictionary<NSString *, NSData *> *)getItemValueForKeys:(NSArray<NSString *> *)keys;
大家最关心的应该是写入缓存的接口是如何实现的,下面重点讲一下写入缓存的接口:
//写入某个item - (BOOL)saveItem:(YYKVStorageItem *)item; //写入某个键值对,值为NSData对象 - (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value; //写入某个键值对,包括文件名以及data信息 - (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value filename:(nullable NSString *)filename extendedData:(nullable NSData *)extendedData;
这三个接口都比较类似,上面的两个方法都会调用最下面参数最多的方法。在详细讲解写入缓存的代码之前,我先讲一下写入缓存的大致逻辑,有助于让大家理解整个 YYDiskCache 写入缓存的流程:
-
首先判断传入的 key 和 value 是否符合要求,如果不符合要求,则立即返回 NO,缓存失败。
-
再判断是否
type==YYKVStorageTypeFile
并且文件名为空字符串(或nil):如果是,则立即返回 NO,缓存失败。 -
判断
filename
是否为空字符串: -
如果不为空:写入文件,并将缓存的 key,等信息写入数据库,但是不将 key 对应的 data 写入数据库。
-
如果为空:
-
如果缓存类型为 YYKVStorageTypeSQLite:将缓存文件删除
-
如果缓存类型不为 YYKVStorageTypeSQLite:则将缓存的 key 和对应的 data 等其他信息存入数据库。
- (BOOL)saveItem:(YYKVStorageItem *)item { return [self saveItemWithKey:item.key value:item.value filename:item.filename extendedData:item.extendedData]; } - (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value { return [self saveItemWithKey:key value:value filename:nil extendedData:nil]; } - (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value filename:(NSString *)filename extendedData:(NSData *)extendedData { if (key.length == 0 || value.length == 0) return NO; if (_type == YYKVStorageTypeFile && filename.length == 0) { return NO; } if (filename.length) { //如果文件名不为空字符串,说明要进行文件缓存 if (![self _fileWriteWithName:filename data:value]) { return NO; } //写入元数据 if (![self _dbSaveWithKey:key value:value fileName:filename extendedData:extendedData]) { //如果缓存信息保存失败,则删除对应的文件 [self _fileDeleteWithName:filename]; return NO; } return YES; } else { //如果文件名为空字符串,说明不要进行文件缓存 if (_type != YYKVStorageTypeSQLite) { //如果缓存类型不是数据库缓存,则查找出相应的文件名并删除 NSString *filename = [self _dbGetFilenameWithKey:key]; if (filename) { [self _fileDeleteWithName:filename]; } } // 缓存类型是数据库缓存,把元数据和value写入数据库 return [self _dbSaveWithKey:key value:value fileName:nil extendedData:extendedData]; } }
从上面的代码可以看出,在底层写入缓存的方法是 _dbSaveWithKey:value:fileName:extendedData:
,这个方法使用了两次:
-
在以文件(和数据库)存储缓存时
-
在以数据库存储缓存时
不过虽然调用了两次,我们可以从传入的参数是有差别的:第二次 filename 传了 nil。那么我们来看一下 _dbSaveWithKey:value:fileName:extendedData:
内部是如何区分有无 filename 的情况的:
-
当 filename 为空时,说明在外部没有写入该缓存的文件:则把 data 写入数据库里
当 filename 不为空时,说明在外部有写入该缓存的文件:则不把 data 也写入了数据库里
下面结合代码看一下:
//数据库存储 - (BOOL)_dbSaveWithKey:(NSString *)key value:(NSData *)value fileName:(NSString *)fileName extendedData:(NSData *)extendedData { //sql语句 NSString *sql = @"insert or replace into manifest (key, filename, size, inline_data, modification_time, last_access_time, extended_data) values (?1, ?2, ?3, ?4, ?5, ?6, ?7);"; sqlite3_stmt *stmt = [self _dbPrepareStmt:sql]; if (!stmt) return NO; int timestamp = (int)time(NULL); //key sqlite3_bind_text(stmt, 1, key.UTF8String, -1, NULL); //filename sqlite3_bind_text(stmt, 2, fileName.UTF8String, -1, NULL); //size sqlite3_bind_int(stmt, 3, (int)value.length); //inline_data if (fileName.length == 0) { //如果文件名长度==0,则将value存入数据库 sqlite3_bind_blob(stmt, 4, value.bytes, (int)value.length, 0); } else { //如果文件名长度不为0,则不将value存入数据库 sqlite3_bind_blob(stmt, 4, NULL, 0, 0); } //modification_time sqlite3_bind_int(stmt, 5, timestamp); //last_access_time sqlite3_bind_int(stmt, 6, timestamp); //extended_data sqlite3_bind_blob(stmt, 7, extendedData.bytes, (int)extendedData.length, 0); int result = sqlite3_step(stmt); if (result != SQLITE_DONE) { if (_errorLogsEnabled) NSLog(@"%s line:%d sqlite insert error (%d): %s", __FUNCTION__, __LINE__, result, sqlite3_errmsg(_db)); return NO; } return YES; }
框架作者用数据库的一条记录来保存关于某个缓存的所有信息。
而且数据库的第四个字段是保存缓存对应的 data 的,从上面的代码可以看出当 filename 为空和不为空的时候的处理的差别。
上面的 sqlite3_stmt
可以看作是一个已经把 sql 语句解析了的、用 sqlite 自己标记记录的内部数据结构。
而 sqlite3_bind_text
和 sqlite3_bind_int
是绑定函数,可以看作是将变量插入到字段的操作。
OK,现在看完了写入缓存,我们再来看一下获取缓存的操作:
//YYKVSorage.m - (YYKVStorageItem *)getItemForKey:(NSString *)key { if (key.length == 0) return nil; YYKVStorageItem *item = [self _dbGetItemWithKey:key excludeInlineData:NO]; if (item) { //更新内存访问的时间 [self _dbUpdateAccessTimeWithKey:key]; if (item.filename) { //如果有文件名,则尝试获取文件数据 item.value = [self _fileReadWithName:item.filename]; //如果此时获取文件数据失败,则删除对应的item if (!item.value) { [self _dbDeleteItemWithKey:key]; item = nil; } } } return item; }
从上面这段代码我们可以看到获取 YYKVStorageItem 的实例的方法是 _dbGetItemWithKey:excludeInlineData:
我们来看一下它的实现:
-
首先根据查找 key 的 sql 语句生成 stmt
-
然后将传入的 key 与该 stmt 进行绑定
-
最后通过这个 stmt 来查找出与该 key 对应的有关该缓存的其他数据并生成 item。
来看一下代码:
- (YYKVStorageItem *)_dbGetItemWithKey:(NSString *)key excludeInlineData:(BOOL)excludeInlineData { NSString *sql = excludeInlineData ? @"select key, filename, size, modification_time, last_access_time, extended_data from manifest where key = ?1;" : @"select key, filename, size, inline_data, modification_time, last_access_time, extended_data from manifest where key = ?1;"; sqlite3_stmt *stmt = [self _dbPrepareStmt:sql]; if (!stmt) return nil; sqlite3_bind_text(stmt, 1, key.UTF8String, -1, NULL); YYKVStorageItem *item = nil; int result = sqlite3_step(stmt); if (result == SQLITE_ROW) { //传入stmt来生成YYKVStorageItem实例 item = [self _dbGetItemFromStmt:stmt excludeInlineData:excludeInlineData]; } else { if (result != SQLITE_DONE) { if (_errorLogsEnabled) NSLog(@"%s line:%d sqlite query error (%d): %s", __FUNCTION__, __LINE__, result, sqlite3_errmsg(_db)); } } return item; }
我们可以看到最终生成 YYKVStorageItem 实例的是通过 _dbGetItemFromStmt:excludeInlineData:
来实现的:
- (YYKVStorageItem *)_dbGetItemFromStmt:(sqlite3_stmt *)stmt excludeInlineData:(BOOL)excludeInlineData { //提取数据 int i = 0; char *key = (char *)sqlite3_column_text(stmt, i++); char *filename = (char *)sqlite3_column_text(stmt, i++); int size = sqlite3_column_int(stmt, i++); //判断excludeInlineData const void *inline_data = excludeInlineData ? NULL : sqlite3_column_blob(stmt, i); int inline_data_bytes = excludeInlineData ? 0 : sqlite3_column_bytes(stmt, i++); int modification_time = sqlite3_column_int(stmt, i++); int last_access_time = sqlite3_column_int(stmt, i++); const void *extended_data = sqlite3_column_blob(stmt, i); int extended_data_bytes = sqlite3_column_bytes(stmt, i++); //将数据赋给item的属性 YYKVStorageItem *item = [YYKVStorageItem new]; if (key) item.key = [NSString stringWithUTF8String:key]; if (filename && *filename != 0) item.filename = [NSString stringWithUTF8String:filename]; item.size = size; if (inline_data_bytes > 0 && inline_data) item.value = [NSData dataWithBytes:inline_data length:inline_data_bytes]; item.modTime = modification_time; item.accessTime = last_access_time; if (extended_data_bytes > 0 && extended_data) item.extendedData = [NSData dataWithBytes:extended_data length:extended_data_bytes]; return item; }
上面这段代码分为两个部分:
-
获取数据库里每一个字段对应的数据
-
将数据赋给 YYKVStorageItem 的实例
需要注意的是:
-
字符串类型需要使用
stringWithUTF8String:
来转成 NSString 类型。 -
这里面会判断
excludeInlineData:
-
如果为 TRUE,就提取存入的 data 数据
-
如果为 FALSE,就不提取
二. 缓存组件的设计思路
保证线程安全的方案
我相信对于某个设计来说,它的产生一定是基于某种个特定问题下的某个场景的
由上文可以看出:
-
YYMemoryCache 使用了
pthread_mutex
线程锁(互斥锁)来确保线程安全 -
YYDiskCache 则选择了更适合它的
dispatch_semaphore
。
内存缓存操作的互斥锁
在 YYMemoryCache 中,是使用互斥锁来保证线程安全的。
首先在 YYMemoryCache 的初始化方法中得到了互斥锁,并在它的所有接口里都加入了互斥锁来保证线程安全,包括 setter,getter 方法和缓存操作的实现。举几个例子:
- (NSUInteger)totalCost { pthread_mutex_lock(&_lock); NSUInteger totalCost = _lru->_totalCost; pthread_mutex_unlock(&_lock); return totalCost; } - (void)setReleaseOnMainThread:(BOOL)releaseOnMainThread { pthread_mutex_lock(&_lock); _lru->_releaseOnMainThread = releaseOnMainThread; pthread_mutex_unlock(&_lock); } - (BOOL)containsObjectForKey:(id)key { if (!key) return NO; pthread_mutex_lock(&_lock); BOOL contains = CFDictionaryContainsKey(_lru->_dic, (__bridge const void *)(key)); pthread_mutex_unlock(&_lock); return contains; } - (id)objectForKey:(id)key { if (!key) return nil; pthread_mutex_lock(&_lock); _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key)); if (node) { //如果节点存在,则更新它的时间信息(最后一次访问的时间) node->_time = CACurrentMediaTime(); [_lru bringNodeToHead:node]; } pthread_mutex_unlock(&_lock); return node ? node->_value : nil; }
而且需要在 dealloc 方法中销毁这个锁头:
- (void)dealloc { ... //销毁互斥锁 pthread_mutex_destroy(&_lock); }
磁盘缓存使用信号量来代替锁
框架作者采用了信号量的方式来给。
首先在初始化的时候实例化了一个信号量:
- (instancetype)initWithPath:(NSString *)path inlineThreshold:(NSUInteger)threshold { ... _lock = dispatch_semaphore_create(1); _queue = dispatch_queue_create("com.ibireme.cache.disk", DISPATCH_QUEUE_CONCURRENT); ...
然后使用了宏来代替加锁解锁的代码:
#define Lock() dispatch_semaphore_wait(self->_lock, DISPATCH_TIME_FOREVER) #define Unlock() dispatch_semaphore_signal(self->_lock)
简单说一下信号量:
dispatch_semaphore 是 GCD 用来同步的一种方式,与他相关的共有三个函数,分别是
-
dispatch_semaphore_create
:定义信号量 -
dispatch_semaphore_signal
:使信号量+1 -
dispatch_semaphore_wait
:使信号量-1
当信号量为 0 时,就会做等待处理,这是其他线程如果访问的话就会让其等待。所以如果信号量在最开始的的时候被设置为1,那么就可以实现“锁”的功能:
-
执行某段代码之前,执行 dispatch_semaphore_wait 函数,让信号量 -1 变为 0,执行这段代码。
-
此时如果其他线程过来访问这段代码,就要让其等待。
-
当这段代码在当前线程结束以后,执行 dispatch_semaphore_signal 函数,令信号量再次 +1,那么如果有正在等待的线程就可以访问了。
需要注意的是:如果有多个线程等待,那么后来信号量恢复以后访问的顺序就是线程遇到 dispatch_semaphore_wait 的顺序。
这也就是信号量和互斥锁的一个区别:互斥量用于线程的互斥,信号线用于线程的同步。
-
互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。 但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
-
同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。也就是说使用信号量可以使多个线程有序访问某个资源。
那么问题来了:为什么内存缓存使用的是互斥锁(pthread_mutex),而磁盘缓存使用的就是信号量(dispatch_semaphore)呢?
答案在框架作者的文章 YYCache 设计思路里可以找到:
为什么内存缓存使用互斥锁(pthread_mutex)?
框架作者在最初使用的是自旋锁(OSSpinLock)作为内存缓存的线程锁,但是后来得知其不够安全,所以退而求其次,使用了pthread_mutex。
为什么磁盘缓存使用的是信号量(dispatch_semaphore)?
dispatch_semaphore
是信号量,但当信号总量设为 1 时也可以当作锁来。在没有等待情况出现时,它的性能比 pthread_mutex 还要高,但一旦有等待情况出现时,性能就会下降许多。相对于 OSSpinLock 来说,它的优势在于等待时不会消耗 CPU 资源。对磁盘缓存来说,它比较合适。
因为 YYDiskCache 在写入比较大的缓存时,可能会有比较长的等待时间,而 dispatch_semaphore 在这个时候是不消耗CPU资源的,所以比较适合。
提高缓存性能的几个尝试
选择合适的线程锁
可以参考上一部分 YYMemoryCache 和 YYDiskCache 使用的不同的锁以及原因。
选择合适的数据结构
在 YYMemoryCache 中,作者选择了双向链表来保存这些缓存节点。那么可以思考一下,为什么要用双向链表而不是单向链表或是数组呢?
-
为什么不选择单向链表:单链表的节点只知道它后面的节点(只有指向后一节点的指针),而不知道前面的。所以如果想移动其中一个节点的话,其前后的节点不好做衔接。
-
为什么不选择数组:数组中元素在内存的排列是连续的,对于寻址操作非常便利;但是对于插入,删除操作很不方便,需要整体移动,移动的元素个数越多,代价越大。而链表恰恰相反,因为其节点的关联仅仅是靠指针,所以对于插入和删除操作会很便利,而寻址操作缺比较费时。由于在LRU策略中会有非常多的移动,插入和删除节点的操作,所以使用双向链表是比较有优势的。
选择合适的线程来操作不同的任务
无论缓存的自动清理和释放,作者默认把这些任务放到子线程去做:
看一下释放所有内存缓存的操作:
- (void)removeAll { //将开销,缓存数量置为0 _totalCost = 0; _totalCount = 0; //将链表的头尾节点置空 _head = nil; _tail = nil; if (CFDictionaryGetCount(_dic) > 0) { CFMutableDictionaryRef holder = _dic; _dic = CFDictionaryCreateMutable(CFAllocatorGetDefault(), 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); //是否在子线程操作 if (_releaseAsynchronously) { dispatch_queue_t queue = _releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue(); dispatch_async(queue, ^{ CFRelease(holder); // hold and release in specified queue }); } else if (_releaseOnMainThread && !pthread_main_np()) { dispatch_async(dispatch_get_main_queue(), ^{ CFRelease(holder); // hold and release in specified queue }); } else { CFRelease(holder); } } }
这里的 YYMemoryCacheGetReleaseQueue()
使用了内联函数,返回了低优先级的并发队列。
//内联函数,返回优先级最低的全局并发队列 static inline dispatch_queue_t YYMemoryCacheGetReleaseQueue() { return dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0); }
选择底层的类
同样是字典实现,但是作者使用了更底层且快速的 CFDictionary 而没有用 NSDictionary 来实现。
其他知识点
禁用原生初始化方法并标明新定义的指定初始化方法
YYCache 有 4 个供外部调用的初始化接口,无论是对象方法还是类方法都需要传入一个字符串(名称或路径)。
而两个原生的初始化方法被框架作者禁掉了:
- (instancetype)init UNAVAILABLE_ATTRIBUTE; + (instancetype)new UNAVAILABLE_ATTRIBUTE;
如果用户使用了上面两个初始化方法就会在编译期报错。
而剩下的四个可以使用的初始化方法中,有一个是指定初始化方法,被作者用 NS_DESIGNATED_INITIALIZER
标记了。
- (nullable instancetype)initWithName:(NSString *)name; - (nullable instancetype)initWithPath:(NSString *)path NS_DESIGNATED_INITIALIZER; + (nullable instancetype)cacheWithName:(NSString *)name; + (nullable instancetype)cacheWithPath:(NSString *)path;
指定初始化方法就是所有可使用的初始化方法都必须调用的方法。更详细的介绍可以参考我的下面两篇文章:
-
iOS 代码规范中讲解“类”的这一部分。
*《Effective objc》干货三部曲(三):技巧篇中的第16条。
异步释放对象的技巧
为了异步将某个对象释放掉,可以通过在 GCD 的 block 里面给它发个消息来实现。这个技巧在该框架中很常见,举一个删除一个内存缓存的例子:
首先将这个缓存的 node 类取出,然后异步将其释放掉。
- (void)removeObjectForKey:(id)key { if (!key) return; pthread_mutex_lock(&_lock); _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key)); if (node) { [_lru removeNode:node]; if (_lru->_releaseAsynchronously) { dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue(); dispatch_async(queue, ^{ [node class]; //hold and release in queue }); } else if (_lru->_releaseOnMainThread && !pthread_main_np()) { dispatch_async(dispatch_get_main_queue(), ^{ [node class]; //hold and release in queue }); } } pthread_mutex_unlock(&_lock); }
为了释放掉这个 node 对象,在一个异步执行的(主队列或自定义队列里) block 里给其发送了 class 这个消息。不需要纠结这个消息具体是什么,他的目的是为了避免编译错误,因为我们无法在 block 里面硬生生地将某个对象写进去。
其实关于上面这一点我自己也有点拿不准,希望理解得比较透彻的同学能在下面留个言~ ^^
内存警告和进入后台的监听
YYCache 默认在收到内存警告和进入后台时,自动清除所有内存缓存。所以在 YYMemoryCache 的初始化方法里,我们可以看到这两个监听的动作:
//YYMemoryCache.m - (instancetype)init{ ... //监听app生命周期 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_appDidReceiveMemoryWarningNotification) name:UIApplicationDidReceiveMemoryWarningNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_appDidEnterBackgroundNotification) name:UIApplicationDidEnterBackgroundNotification object:nil]; ... }
然后实现监听到消息后的处理方法:
//内存警告时,删除所有内存缓存 - (void)_appDidReceiveMemoryWarningNotification { if (self.didReceiveMemoryWarningBlock) { self.didReceiveMemoryWarningBlock(self); } if (self.shouldRemoveAllObjectsOnMemoryWarning) { [self removeAllObjects]; } } //进入后台时,删除所有内存缓存 - (void)_appDidEnterBackgroundNotification { if (self.didEnterBackgroundBlock) { self.didEnterBackgroundBlock(self); } if (self.shouldRemoveAllObjectsWhenEnteringBackground) { [self removeAllObjects]; } }
判断头文件的导入
#if __has_include(<YYCache/YYCache.h>) #import <YYCache/YYMemoryCache.h> #import <YYCache/YYDiskCache.h> #import <YYCache/YYKVStorage.h> #elif __has_include(<YYWebImage/YYCache.h>) #import <YYWebImage/YYMemoryCache.h> #import <YYWebImage/YYDiskCache.h> #import <YYWebImage/YYKVStorage.h> #else #import "YYMemoryCache.h" #import "YYDiskCache.h" #import "YYKVStorage.h" #endif
在这里作者使用 __has_include
来检查 Frameworks 是否引入某个类。
因为 YYWebImage 已经集成 YYCache,所以如果导入过 YYWebImage 的话就无需重再导入 YYCache了。
最后的话
通过看该组件的源码,我收获的不仅有缓存设计的思路,还有:
-
双向链表的概念以及相关操作
-
数据库的使用
-
互斥锁,信号量的使用
-
实现线程安全的方案
-
变量,方法的命名以及接口的设计
相信读过这篇文章的你也会有一些收获~ 如果能趁热打铁,下载一个 YYCache 源码看就更好啦~
推荐阅读
以上所述就是小编给大家介绍的《YYCache 源码解析(二):磁盘缓存的设计与缓存组件设计思路》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
算法问题实战策略
[韩] 具宗万 / 崔盛一 / 人民邮电出版社 / 2015-2 / 119.00元
第一部分 开始解决问题 第二部分 算法分析 第三部分 算法设计范式 第四部分 一些著名的算法 第五部分 基本数据结构 第六部分 树 第七部分 图一起来看看 《算法问题实战策略》 这本书的介绍吧!