YYKit 系列源码剖析文章:node
YYCache 做为当下 iOS 圈最流行的缓存框架,有着优越的性能和绝佳的设计。笔者花了些时间对其“解剖”了一番,发现了不少有意思的东西,因此写下本文分享一下。算法
考虑到篇幅,笔者对于源码的解析不会过多的涉及 API 使用和一些基础知识,更多的是剖析做者 ibireme 的设计思惟和重要技术实现细节。sql
YYCache 主要分为两部分:内存缓存和磁盘缓存(对应 YYMemoryCache
和 YYDiskCache
)。在平常开发业务使用中,可能是直接操做 YYCache 类,该类是对内存缓存功能和磁盘缓存功能的一个简单封装。数据库
源码基于 1.0.4 版本。缓存
总览 API ,会发现一些见名知意的方法:安全
- (nullable id)objectForKey:(id)key;
- (void)setObject:(nullable id)object forKey:(id)key;
- (void)trimToCount:(NSUInteger)count;
- (void)trimToCost:(NSUInteger)cost;
- (void)trimToAge:(NSTimeInterval)age;
......
复制代码
能够看出,该类主要包含读写功能和修剪功能(修剪是为了控制内存缓存的大小等)。固然,还有其余一些自定义方法,好比释放操做的线程选择、内存警告和进入后台时是否清除内存缓存等。性能优化
对该类的基本功能有了了解以后,就能够直接切实现源码了。bash
既然有修剪缓存的功能,必然涉及到一个缓存淘汰算法,YYMemoryCache 和 YYDiskCache 都是实现的 LRU (least-recently-used) ,即最近最少使用淘汰算法。数据结构
在 YYMemoryCache.m 文件中,有以下的代码:多线程
@interface _YYLinkedMapNode : NSObject {
@package
__unsafe_unretained _YYLinkedMapNode *_prev; // retained by dic
__unsafe_unretained _YYLinkedMapNode *_next; // retained by dic
id _key;
id _value;
NSUInteger _cost;
NSTimeInterval _time;
}
@interface _YYLinkedMap : NSObject {
@package
CFMutableDictionaryRef _dic; // do not set object directly
NSUInteger _totalCost;
NSUInteger _totalCount;
_YYLinkedMapNode *_head; // MRU, do not change it directly
_YYLinkedMapNode *_tail; // LRU, do not change it directly
BOOL _releaseOnMainThread;
BOOL _releaseAsynchronously;
}
复制代码
熟悉链表的朋友应该一眼就看出来猫腻,做者是使用的一个双向链表+散列容器来实现 LRU 的。
_prev
和_next
)是为了快速找到前驱和后继节点。__unsafe_unretained
而不使用__weak
。虽然二者都不会持有指针所指向的对象,可是在指向对象释放时,前者并不会自动置空指针,造成野指针,不过通过笔者后面的阅读,发现做者避免了野指针的出现;并且从性能层面看(做者原话):访问具备 __weak 属性的变量时,实际上会调用objc_loadWeak()
和objc_storeWeak()
来完成,这也会带来很大的开销,因此要避免使用__weak
属性。_key
和_value
就是框架使用者想要存储的键值对,能够看出做者的设计是一个键值对对应一个节点(_YYLinkedMapNode
)。_cost
和_time
表示该节点的内存大小和最后访问的时间。_head
和_tail
),保证双端查询的效率。_totalCost
和_totalCount
记录最大内存占用限制和数量限制。_releaseOnMainThread
和_releaseAsynchronously
分别表示在主线程释放和在异步线程释放,它们的实现后文会讲到。_dic
变量是 OC 开发中经常使用的散列容器,全部节点都会在_dic
中以 key-value 的形式存在,保证常数级查询效率。既然是 LRU 算法,怎么能只有数据结构,往下面看 _YYLinkedMap 类实现了以下算法(嗯,挺常规的节点操做,代码质量挺高的,就不说明实现了):
- (void)insertNodeAtHead:(_YYLinkedMapNode *)node;
- (void)bringNodeToHead:(_YYLinkedMapNode *)node;
- (void)removeNode:(_YYLinkedMapNode *)node;
- (_YYLinkedMapNode *)removeTailNode;
- (void)removeAll;
复制代码
如今 LRU 的数据结构和操做算法实现都有了,就能够看具体的业务了。
##(2)修剪内存的逻辑
正如一开始贴的 API ,该类有三种修剪内存的依据:根据缓存的内存块数量、根据占用内存大小、根据是不是最近使用。它们的实现逻辑几乎同样,这里就其中一个为例子(代码有删减):
- (void)_trimToAge:(NSTimeInterval)ageLimit {
......
NSMutableArray *holder = [NSMutableArray new];
//1 迭代部分
while (!finish) {
if (pthread_mutex_trylock(&_lock) == 0) {
if (_lru->_tail && (now - _lru->_tail->_time) > ageLimit) {
_YYLinkedMapNode *node = [_lru removeTailNode];
if (node) [holder addObject:node];
} else {
finish = YES;
}
pthread_mutex_unlock(&_lock);
} else {
usleep(10 * 1000); //10 ms
}
}
//2 释放部分
if (holder.count) {
dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
dispatch_async(queue, ^{
[holder count]; // release in queue
});
}
}
复制代码
这里有几个重要技术点,颇有意思。
经过一个 while 循环不断释放尾节点removeTailNode
,直到知足参数ageLimit
对时间的要求,而该链表的排序规则是:最近使用的内存块会移动到链表头部,也就保证了删除的内存永远是最不常使用的(后面会看到如何实现排序的)。
不妨思考这样一个问题:为什么要使用pthread_mutex_trylock()
方法尝试获取锁,而获取失败事后作了一个线程挂起操做usleep()
?
**优先级反转:**好比两个线程 A 和 B,优先级 A < B。当 A 获取锁访问共享资源时,B 尝试获取锁,那么 B 就会进入忙等状态,忙等时间越长对 CPU 资源的占用越大;而因为 A 的优先级低于 B,A 没法与高优先级的线程争夺 CPU 资源,从而致使任务迟迟完成不了。解决优先级反转的方法有“优先级天花板”和“优先级继承”,它们的核心操做都是提高当前正在访问共享资源的线程的优先级。
**历史状况:**在老版本的代码中,做者是使用的OSSpinLock
自旋锁来保证线程安全,然后来因为OSSpinLock
的 bug 问题(存在潜在的优先级反转BUG),做者将其替换成了pthread_mutex_t
互斥锁。
笔者的理解: 自动的递归修剪逻辑是这样的:
- (void)_trimInBackground {
dispatch_async(_queue, ^{
[self _trimToCost:self->_costLimit];
[self _trimToCount:self->_countLimit];
[self _trimToAge:self->_ageLimit];
});
}
复制代码
而_queue
是一个串行队列:
_queue = dispatch_queue_create("com.ibireme.cache.memory", DISPATCH_QUEUE_SERIAL);
复制代码
能够明确的是,自动修剪过程不存在线程安全问题,固然框架还暴露了修剪内存的方法给外部使用,那么当外部在多线程调用修剪内存方法就可能会出现线程安全问题。
这里作了一个 10ms 的挂起操做而后循环尝试,直接舍弃了互斥锁的空转期,但这样也避免了多线程访问下过多的空转占用过多的 CPU 资源。做者这样处理极可能加长了修剪内存的时间,可是却避免了极限状况下空转对 CPU 的占用。
显然,做者是指望使用者在后台线程修剪内存(最好使用者不去显式的调用修剪内存方法)。
这里做者使用了一个容器将要释放的节点装起来,而后在某个队列(默认是非主队列)里面调用了一下该容器的方法。虽然看代码可能不理解,可是做者写了一句注释release in queue
:某个对象的方法最后在某个线程调用,这个对象就会在当前线程释放。很明显,这里是做者将节点的释放放其余线程,从而减轻主线程的资源开销。
##(3)检查内存是否超限的定时任务 有这样一段代码:
- (void)_trimRecursively {
__weak typeof(self) _self = self;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_autoTrimInterval * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
__strong typeof(_self) self = _self;
if (!self) return;
[self _trimInBackground];
[self _trimRecursively];
});
}
复制代码
能够看到,做者是使用一个递归+延时来实现定时任务的,这里能够自定义检测的时间间隔。
##(4)进入后台和内存警告的处理 在该类初始化时,做者写了内存警告和进入后台两个监听:
[[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];
}
复制代码
使用者还能够经过闭包实时监听。
##(5)读数据
- (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;
}
复制代码
逻辑很简单,关键的一步是 node->_time = CACurrentMediaTime()
和 [_lru bringNodeToHead:node]
;即更新这块内存的时间,而后将该节点移动到链表头部,实现了基于时间的优先级排序,为 LRU 的实现提供了可靠的数据结构基础。
##(6)写数据 代码有删减,解析写在代码中:
- (void)setObject:(id)object forKey:(id)key withCost:(NSUInteger)cost {
......
pthread_mutex_lock(&_lock);
_YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key));
NSTimeInterval now = CACurrentMediaTime();
if (node) {
//1 若缓存中有:修改node的变量,将该节点移动到头部
......
[_lru bringNodeToHead:node];
} else {
//2 若缓存中没有,建立一个内存,将该节点插入到头部
node = [_YYLinkedMapNode new];
......
[_lru insertNodeAtHead:node];
}
//3 判断是否须要修剪内存占用,若须要:异步修剪,保证写入的性能
if (_lru->_totalCost > _costLimit) {
dispatch_async(_queue, ^{
[self trimToCost:_costLimit];
});
}
//4 判断是否须要修剪内存块数量,若须要:默认在非主队列释放无用内存,保证写入的性能
if (_lru->_totalCount > _countLimit) {
_YYLinkedMapNode *node = [_lru removeTailNode];
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);
}
复制代码
在暴露给用户的 API 中,磁盘缓存的功能和内存缓存很像,一样有读写数据和修剪数据等功能。
YYDiskCache
的磁盘缓存处理性能很是优越,做者测试了数据库和文件存储的读写效率:iPhone 6 64G 下,SQLite 写入性能比直接写文件要高,但读取性能取决于数据大小:当单条数据小于 20K 时,数据越小 SQLite 读取性能越高;单条数据大于 20K 时,直接写为文件速度会更快一些。(更详细的说明看文末连接)
因此做者对磁盘缓存的处理方式为 SQLite 结合文件存储的方式。
磁盘缓存的核心类是YYKVStorage
,注意该类是非线程安全的,它主要封装了 SQLite 数据库的操做和文件存储操做。
后文的剖析大部分的代码都是在YYKVStorage
文件中。
##(1)磁盘缓存的文件结构 首先,须要了解一下做者设计的在磁盘中的文件结构(在YYKVStorage.m
中做者的注释):
/*
File:
/path/
/manifest.sqlite
/manifest.sqlite-shm
/manifest.sqlite-wal
/data/
/e10adc3949ba59abbe56e057f20f883e
/e10adc3949ba59abbe56e057f20f883e
/trash/
/unused_file_or_folder
SQL:
create table if not exists manifest (
key text,
filename text,
size integer,
inline_data blob,
modification_time integer,
last_access_time integer,
extended_data blob,
primary key(key)
);
create index if not exists last_access_time_idx on manifest(last_access_time);
*/
复制代码
path 是一个初始化时使用的变量,不一样的 path 对应不一样的数据库。在 path 下面有 sqlite 数据库相关的三个文件,以及两个目录(/data 和 /trash),这两个目录就是文件存储方便直接读取的地方,也就是为了实现上文说的在高于某个临界值时直接读取文件比从数据库读取快的理论。
在数据库中,建了一个表,表的结构如上代码所示:
这一个重点问题,就像以前说的,在某个临界值时,直接读取文件的效率要高于从数据库读取,第一反应多是写文件和写数据库分离,也就是上面的结构中,manifest.sqlite 数据库文件和 /data 文件夹内容无关联,让 /data 去存储高于临界值的数据,让 sqlite 去存储低于临界值的数据。
然而这样会带来两个问题:
为了完美处理该问题,做者将它们结合了起来,全部关于用户存储数据的相关信息都会放在数据库中(即刚才说的那个table中),而待存储数据的二进制文件,却根据状况分别处理:要么存在数据库表的 inline_data 下,要么直接存储在 /data 文件夹下。
如此一来,一切问题迎刃而解,下文根据源码进行验证和探究。
##(2)数据库表的OC模型体现 固然,为了让接口可读性更高,做者写了一个对应数据库表的模型,做为使用者实际业务使用的类:
@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 复制代码
该类的属性和数据库表的键一一对应。
##(3)数据库的操做封装
对于 sqlite 的封装比较常规,做者的容错处理作得很好,下面就一些重点地方作一些讲解,对数据库操做感兴趣的朋友能够直接去看源码。
YYKVStorage 类有这样一个变量:CFMutableDictionaryRef _dbStmtCache;
经过 sql 生成 sqlite3_stmt 的封装方法是这样的:
- (sqlite3_stmt *)_dbPrepareStmt:(NSString *)sql {
if (![self _dbCheck] || sql.length == 0 || !_dbStmtCache) return NULL;
sqlite3_stmt *stmt = (sqlite3_stmt *)CFDictionaryGetValue(_dbStmtCache, (__bridge const void *)(sql));
if (!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;
}
CFDictionarySetValue(_dbStmtCache, (__bridge const void *)(sql), stmt);
} else {
sqlite3_reset(stmt);
}
return stmt;
}
复制代码
做者使用了一个 hash 容器来缓存 stmt, 每次根据 sql 生成 stmt 时,若已经存在缓存就执行一次 sqlite3_reset(stmt);
让 stmt 回到初始状态。
如此一来,提升了数据库读写的效率,是一个小 tip。
数据库操做,仍然有根据占用内存大小、最后访问时间、内存块数量进行修剪内存的方法,下面就根据最后访问时间进行修剪方法作为例子:
- (BOOL)_dbDeleteItemsWithTimeEarlierThan:(int)time {
NSString *sql = @"delete from manifest where last_access_time < ?1;";
sqlite3_stmt *stmt = [self _dbPrepareStmt:sql];
if (!stmt) return NO;
sqlite3_bind_int(stmt, 1, time);
int result = sqlite3_step(stmt);
if (result != SQLITE_DONE) {
if (_errorLogsEnabled) NSLog(@"%s line:%d sqlite delete error (%d): %s", __FUNCTION__, __LINE__, result, sqlite3_errmsg(_db));
return NO;
}
return YES;
}
复制代码
能够看到,做者利用 sql 语句,很轻松的实现了内存的修剪。
写入时,做者根据是否有 filename 判断是否须要将写入的数据二进制存入数据库(代码有删减):
- (BOOL)_dbSaveWithKey:(NSString *)key value:(NSData *)value fileName:(NSString *)fileName extendedData:(NSData *)extendedData {
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;
......
if (fileName.length == 0) {
sqlite3_bind_blob(stmt, 4, value.bytes, (int)value.length, 0);
} else {
sqlite3_bind_blob(stmt, 4, NULL, 0, 0);
}
....
}
复制代码
若存在 filename ,虽然不会写入数据库,可是会直接写入 /data 文件夹,这个逻辑是在本类的 public 方法中作的。
##(4)文件操做的封装 主要是 NSFileManager 相关方法的基本使用,比较独特的是,做者使用了一个“垃圾箱”,也就是磁盘文件存储结构中的 /trash 目录。
能够看到两个方法:
- (BOOL)_fileMoveAllToTrash {
CFUUIDRef uuidRef = CFUUIDCreate(NULL);
CFStringRef uuid = CFUUIDCreateString(NULL, uuidRef);
CFRelease(uuidRef);
NSString *tmpPath = [_trashPath stringByAppendingPathComponent:(__bridge NSString *)(uuid)];
BOOL suc = [[NSFileManager defaultManager] moveItemAtPath:_dataPath toPath:tmpPath error:nil];
if (suc) {
suc = [[NSFileManager defaultManager] createDirectoryAtPath:_dataPath withIntermediateDirectories:YES attributes:nil error:NULL];
}
CFRelease(uuid);
return suc;
}
- (void)_fileEmptyTrashInBackground {
NSString *trashPath = _trashPath;
dispatch_queue_t queue = _trashQueue;
dispatch_async(queue, ^{
NSFileManager *manager = [NSFileManager new];
NSArray *directoryContents = [manager contentsOfDirectoryAtPath:trashPath error:NULL];
for (NSString *path in directoryContents) {
NSString *fullPath = [trashPath stringByAppendingPathComponent:path];
[manager removeItemAtPath:fullPath error:NULL];
}
});
}
复制代码
上面个方法是将 /data 目录下的文件移动到 /trash 目录下,下面个方法是将 /trash 目录下的文件在异步线程清理掉。
**笔者的理解:**很容易想到,删除文件是一个比较耗时的操做,因此做者把它放到了一个专门的队列处理。而删除的文件用一个专门的路径 /trash 放置,避免了写入数据和删除数据之间发生冲突。试想,若删除的逻辑和写入的逻辑都是对 /data 目录进行操做,而删除逻辑比较耗时,那么就会很容易出现误删等状况。
##(5)YYDiskCache 对 YYKVStorage 的二次封装 对于 YYKVStorage 类的公有方法,笔者不作解析,就是对数据库操做和写文件操做的一个结合封装,很简单一看便知。
做者不提倡直接使用非线程安全的 YYKVStorage 类,因此封装了一个线程安全的 YYDiskCache 类便于你们使用。
因此,YYDiskCache 类中主要是作了一些操做磁盘缓存的线程安全机制,是基于信号量(dispatch_semaphore
)来处理的,暴露的接口中相似 YYMemoryCache 类的一系列方法。
磁盘缓存中,多了一个以下修剪方法:
- (void)_trimToFreeDiskSpace:(NSUInteger)targetFreeDiskSpace {
if (targetFreeDiskSpace == 0) return;
int64_t totalBytes = [_kv getItemsSize];
if (totalBytes <= 0) return;
int64_t diskFreeBytes = _YYDiskSpaceFree();
if (diskFreeBytes < 0) return;
int64_t needTrimBytes = targetFreeDiskSpace - diskFreeBytes;
if (needTrimBytes <= 0) return;
int64_t costLimit = totalBytes - needTrimBytes;
if (costLimit < 0) costLimit = 0;
[self _trimToCost:(int)costLimit];
}
复制代码
根据剩余的磁盘空间的限制进行修剪,做者确实想得很周到。_YYDiskSpaceFree()
是做者写的一个 c 方法,用于获取剩余磁盘空间。
- (NSString *)_filenameForKey:(NSString *)key {
NSString *filename = nil;
if (_customFileNameBlock) filename = _customFileNameBlock(key);
if (!filename) filename = _YYNSStringMD5(key);
return filename;
}
复制代码
filename 是做者根据使用者传入的 key 作一次 MD5 加密所得的字符串,因此不要误觉得文件名就是你传入的 key (_YYNSStringMD5()
是做者写的一个加密方法)。固然,框架提供了一个 _customFileNameBlock
容许你自定义文件名。
能够看到诸如此类的设计:
- (BOOL)containsObjectForKey:(NSString *)key {
if (!key) return NO;
Lock();
BOOL contains = [_kv itemExistsForKey:key];
Unlock();
return contains;
}
- (void)containsObjectForKey:(NSString *)key withBlock:(void(^)(NSString *key, BOOL contains))block {
if (!block) return;
__weak typeof(self) _self = self;
dispatch_async(_queue, ^{
__strong typeof(_self) self = _self;
BOOL contains = [self containsObjectForKey:key];
block(key, contains);
});
}
复制代码
因为可能存储的文件过大,在读写时会占用过多的资源,因此做者对于这些操做都分别提供了同步和异步的接口,可谓很是人性化,这也是接口设计的一些值得学习的地方。
实际上上文的剖析已经囊括了 YYCache 框架的核心了。YYCache 类主要是对内存缓存和磁盘缓存的结合封装,代码很简单,有一点须要提出来:
- (void)objectForKey:(NSString *)key withBlock:(void (^)(NSString *key, id<NSCoding> object))block {
if (!block) return;
id<NSCoding> object = [_memoryCache objectForKey:key];
if (object) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
block(key, object);
});
} else {
[_diskCache objectForKey:key withBlock:^(NSString *key, id<NSCoding> object) {
if (object && ![_memoryCache objectForKey:key]) {
[_memoryCache setObject:object forKey:key];
}
block(key, object);
}];
}
}
复制代码
优先查找内存缓存_memoryCache
中的数据,若查不到,就查询磁盘缓存_diskCache
,查询磁盘缓存成功,将数据同步到内存缓存中,方便下次查找。
这么作的理由很简单:根据机械原理,较大的存储设备要比较小的存储设备运行得慢,而快速设备的造价远高于低速设备。因此内存缓存的读写速度远高于磁盘缓存。这也是开发中缓存设计的核心问题,咱们既要保证缓存读写的效率,又要考虑到空间占用,其实又回到了空间和时间的权衡问题了。
YYCache 核心逻辑思路、接口设计、代码组织架构、容错处理、性能优化、内存管理、线程安全这些方面都作得很好很极致,阅读起来很是舒服。
阅读开源框架,第一步必定是通读一下 API 了解该框架是干什么的,而后采用“分治”的思路逐个击破,类比“归并算法”:先拆开再合并,切勿想一口吃成胖子,特别是对于某些“重量级”框架。
但愿读者朋友们阅读事后有所收获😁。
参考文献:做者 ibireme 的博客 YYCache 设计思路