YYCache学习缓存设计

前言

日常的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 的开销。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,311评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,339评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,671评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,252评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,253评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,031评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,340评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,973评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,466评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,937评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,039评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,701评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,254评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,259评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,497评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,786评论 2 345

推荐阅读更多精彩内容