從 YYCache 源碼 Get 到如何設計一個優秀的緩存

前言

iOS 開發中總會用到各類緩存,可是各位有沒有考慮過什麼樣的緩存才能被叫作優秀的緩存,或者說優秀的緩存應該具有哪些特質?html

閉上眼睛,想想若是面試官讓你設計一個緩存你會怎麼回答?node

本文將結合 YYCache 的源碼逐步帶你們找到答案。ios

YYCache 是一個線程安全的高性能鍵值緩存(該項目是 YYKit 組件之一)。YYKit 是在 2015 年發佈到 Github 的,因爲其代碼質量很高,在短期內就收穫了大量的 Star(目前已經 1w+ Star 了),並且在 iOS 各大社區反響普遍,Google 一下也是漫天讚歎。git

YYKit 做者是 @ibireme,原名郭曜源(猜想 YY 前綴來源於曜源?),是我我的很是喜歡的國人開發者(何止喜歡,簡直是迷弟😘)。github

YYCache 的代碼邏輯清晰,註釋詳盡,加上自身不算太大的代碼量使得其閱讀很是簡單,更加難能難得的是它的性能還很是高。web

我對它的評價是小而美,這種小而美的緩存源碼對於咱們今天的主題太合適不過了(本文中 YYCache 源碼版本爲 v1.0.4)。面試

索引

  • YYCache 簡介
  • YYMemoryCache 細節剖析
  • YYDiskCache 細節剖析
  • 優秀的緩存應該具有哪些特質
  • 總結

YYCache 簡介

簡單把 YYCache 從頭至尾擼了一遍,最大的感觸就是代碼風格乾淨整潔,代碼思路清晰明瞭。算法

因爲代碼總體閱讀難度不是很是大,本文不會去逐字逐句的解讀源碼,而是提煉 YYCache 做爲一個小而美的緩存實現了哪些緩存該具有的特質,而且分析實現細節。sql

咱們先來簡單看一下 YYCache 的代碼結構,YYCache 是由 YYMemoryCache 與 YYDiskCache 兩部分組成的,其中 YYMemoryCache 做爲高速內存緩存,而 YYDiskCache 則做爲低速磁盤緩存。swift

一般一個緩存是由內存緩存和磁盤緩存組成,內存緩存提供容量小但高速的存取功能,磁盤緩存提供大容量但低速的持久化存儲。

@interface YYCache : NSObject

@property (copy, readonly) NSString *name;
@property (strong, readonly) YYMemoryCache *memoryCache;
@property (strong, readonly) YYDiskCache *diskCache;

- (BOOL)containsObjectForKey:(NSString *)key;
- (nullable id<NSCoding>)objectForKey:(NSString *)key;
- (void)setObject:(nullable id<NSCoding>)object forKey:(NSString *)key;
- (void)removeObjectForKey:(NSString *)key;

@end
複製代碼

上面的代碼我作了簡化,只保留了最基本的代碼(我認爲做者在最初設計 YYCache 雛形時極可能也只是提供了這些基本的接口),其餘的接口只是經過調用基本的接口再附加對應處理代碼而成。

Note: 其實源碼中做者用了一些技巧性的宏,例如 NS_ASSUME_NONNULL_BEGINNS_ASSUME_NONNULL_END 來經過編譯器層檢測入參是否爲空並給予警告,參見 Nullability and Objective-C

相似上述的編碼技巧還有不少,我並不是不想與你們分享我 get 到的這些編碼技巧,只是以爲它與本文的主題彷佛不太相符。我準備在以後專門寫一篇文章來與你們分享我在閱讀各大源碼庫過程當中 get 到的編碼技巧(感興趣的話能夠 關注我)。

從代碼中咱們能夠看到 YYCache 中持有 YYMemoryCache 與 YYDiskCache,而且對外提供了一些接口。這些接口基本都是基於 Key 和 Value 設計的,相似於 iOS 原生的字典類接口(增刪改查)。

YYMemoryCache 細節剖析

YYMemoryCache 是一個高速的內存緩存,用於存儲鍵值對。它與 NSDictionary 相反,Key 被保留而且不復制。API 和性能相似於 NSCache,全部方法都是線程安全的。

YYMemoryCache 對象與 NSCache 的不一樣之處在於:

  • YYMemoryCache 使用 LRU(least-recently-used) 算法來驅逐對象;NSCache 的驅逐方式是非肯定性的。
  • YYMemoryCache 提供 age、cost、count 三種方式控制緩存;NSCache 的控制方式是不精確的。
  • YYMemoryCache 能夠配置爲在收到內存警告或者 App 進入後臺時自動逐出對象。

Note: YYMemoryCache 中的 Access Methods 消耗時長一般是穩定的 (O(1))

@interface YYMemoryCache : NSObject

#pragma mark - Attribute
@property (nullable, copy) NSString *name; // 緩存名稱,默認爲 nil
@property (readonly) NSUInteger totalCount; // 緩存對象總數
@property (readonly) NSUInteger totalCost; // 緩存對象總開銷


#pragma mark - Limit
@property NSUInteger countLimit; // 緩存對象數量限制,默認無限制,超過限制則會在後臺逐出一些對象以知足限制
@property NSUInteger costLimit; // 緩存開銷數量限制,默認無限制,超過限制則會在後臺逐出一些對象以知足限制
@property NSTimeInterval ageLimit; // 緩存時間限制,默認無限制,超過限制則會在後臺逐出一些對象以知足限制

@property NSTimeInterval autoTrimInterval; // 緩存自動清理時間間隔,默認 5s

@property BOOL shouldRemoveAllObjectsOnMemoryWarning; // 是否應該在收到內存警告時刪除全部緩存內對象
@property BOOL shouldRemoveAllObjectsWhenEnteringBackground; // 是否應該在 App 進入後臺時刪除全部緩存內對象

@property (nullable, copy) void(^didReceiveMemoryWarningBlock)(YYMemoryCache *cache); // 我認爲這是一個 hook,便於咱們在收到內存警告時自定義處理緩存
@property (nullable, copy) void(^didEnterBackgroundBlock)(YYMemoryCache *cache); // 我認爲這是一個 hook,便於咱們在收到 App 進入後臺時自定義處理緩存

@property BOOL releaseOnMainThread; // 是否在主線程釋放對象,默認 NO,有些對象(例如 UIView/CALayer)應該在主線程釋放
@property BOOL releaseAsynchronously; // 是否異步釋放對象,默認 YES

- (BOOL)containsObjectForKey:(id)key;

- (nullable id)objectForKey:(id)key;

- (void)setObject:(nullable id)object forKey:(id)key;
- (void)setObject:(nullable id)object forKey:(id)key withCost:(NSUInteger)cost;
- (void)removeObjectForKey:(id)key;
- (void)removeAllObjects;


#pragma mark - Trim
- (void)trimToCount:(NSUInteger)count; // 用 LRU 算法刪除對象,直到 totalCount <= count
- (void)trimToCost:(NSUInteger)cost; // 用 LRU 算法刪除對象,直到 totalCost <= cost
- (void)trimToAge:(NSTimeInterval)age; // 用 LRU 算法刪除對象,直到全部到期對象所有被刪除

@end
複製代碼

YYMemoryCache 的定義代碼比較簡單~ 該有的註釋我已經加到了上面,這裏 LRU 算法的實現我準備單獨拎出來放到後面和(_YYLinkedMapNode_YYLinkedMap)一塊兒講。咱們這裏只須要再關注一下 YYMemoryCache 是如何作到線程安全的。

YYMemoryCache 是如何作到線程安全的

@implementation YYMemoryCache {
    pthread_mutex_t _lock; // 線程鎖,旨在保證 YYMemoryCache 線程安全
    _YYLinkedMap *_lru; // _YYLinkedMap,YYMemoryCache 經過它間接操做緩存對象
    dispatch_queue_t _queue; // 串行隊列,用於 YYMemoryCache 的 trim 操做
}
複製代碼

沒錯,這裏 ibireme 選擇使用 pthread_mutex 線程鎖來確保 YYMemoryCache 的線程安全。

有趣的是,這裏 ibireme 使用 pthread_mutex 是有一段小故事的。在最初 YYMemoryCache 這裏使用的鎖是 OSSpinLock 自旋鎖(詳見 YYCache 設計思路 備註-關於鎖),後面有人在 Github 向做者提 issue 反饋 OSSpinLock 不安全,通過做者的確認(詳見 再也不安全的 OSSpinLock)最後選擇用 pthread_mutex 替代 OSSpinLock

上面是 ibireme 在確認 OSSpinLock 再也不安全以後爲了尋找替代方案作的簡單性能測試,對比了一下幾種可以替代 OSSpinLock 鎖的性能。在 再也不安全的 OSSpinLock 文末的評論中,我找到了做者使用 pthread_mutex 的緣由。

ibireme: 蘋果員工說 libobjc 裏 spinlock 是用了一些私有方法 (mach_thread_switch),貢獻出了高線程的優先來避免優先級反轉的問題,可是我翻了下 libdispatch 的源碼卻是沒發現相關邏輯,也多是我忽略了什麼。在個人一些測試中,OSSpinLockdispatch_semaphore 都不會產生特別明顯的死鎖,因此我也沒法肯定用 dispatch_semaphore 代替 OSSpinLock 是否正確。可以確定的是,用 pthread_mutex 是安全的。

_YYLinkedMapNode_YYLinkedMap

上文介紹了 YYMemoryCache,其實 YYMemoryCache 並不直接操做緩存對象,而是經過內部的 _YYLinkedMapNode_YYLinkedMap 來間接的操做緩存對象。這兩個類對於上文中提到的 LRU 緩存算法的理解相當重要,因此我把他們倆單獨拎出來放在這裏詳細解讀一下。

/** _YYLinkedMap 中的一個節點。 一般狀況下咱們不該該使用這個類。 */
@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


/** YYMemoryCache 內的一個鏈表。 _YYLinkedMap 不是一個線程安全的類,並且它也不對參數作校驗。 一般狀況下咱們不該該使用這個類。 */
@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;

@end

複製代碼

爲了方便你們閱讀,我標註了必要的中文註釋。其實對數據結構與算法不陌生的同窗應該一眼就看的出來 _YYLinkedMapNode_YYLinkedMap 這倆貨的本質。沒錯,丫就是雙向鏈表節點和雙向鏈表。

_YYLinkedMapNode 做爲雙向鏈表節點,除了基本的 _prev_next,還有鍵值緩存基本的 _key_value咱們能夠把 _YYLinkedMapNode 理解爲 YYMemoryCache 中的一個緩存對象

_YYLinkedMap 做爲由 _YYLinkedMapNode 節點組成的雙向鏈表,使用 CFMutableDictionaryRef _dic 字典存儲 _YYLinkedMapNode。這樣在確保 _YYLinkedMapNode 被強引用的同時,可以利用字典的 Hash 快速定位用戶要訪問的緩存對象,這樣既符合了鍵值緩存的概念又省去了本身實現的麻煩(笑)。

嘛~ 總得來講 YYMemoryCache 是經過使用 _YYLinkedMap 雙向鏈表來操做 _YYLinkedMapNode 緩存對象節點的。

LRU(least-recently-used) 算法的實現

上文咱們認清了 _YYLinkedMap_YYLinkedMapNode 本質上就是雙向鏈表和鏈表節點,這裏咱們簡單講一下 YYMemoryCache 是如何利用雙向鏈表實現 LRU(least-recently-used) 算法的。

緩存替換策略

首先 LRU 是緩存替換策略(Cache replacement policies)的一種,還有不少緩存替換策略諸如:

  • First In First Out (FIFO)
  • Last In First Out (LIFO)
  • Time aware Least Recently Used (TLRU)
  • Most Recently Used (MRU)
  • Pseudo-LRU (PLRU)
  • Random Replacement (RR)
  • Segmented LRU (SLRU)
  • Least-Frequently Used (LFU)
  • Least Frequent Recently Used (LFRU)
  • LFU with Dynamic Aging (LFUDA)
  • Low Inter-reference Recency Set (LIRS)
  • Adaptive Replacement Cache (ARC)
  • Clock with Adaptive Replacement (CAR)
  • Multi Queue (MQ) caching algorithm|Multi Queue (MQ)
  • Pannier: Container-based caching algorithm for compound objects

是否是被唬到了?不要擔憂,我這裏會表述的儘可能易懂。

緩存命中率

爲何有這麼多緩存替換策略,或者說搞這麼多名堂到底是爲了什麼呢?

答案是提升緩存命中率,那麼何謂緩存命中率呢?

Google 一下天然是有很多解釋,不過不少都是 web 相關的,並且不說人話(很難理解),我我的很是討厭各類不說人話的「高深」抽象概念。

這裏抖了好幾抖膽纔敢談一下我對於緩存命中率的理解(限於 YYCache 和 iOS 開發)。

  • 緩存命中 = 用戶要訪問的緩存對象在高速緩存中,咱們直接在高速緩存中經過 Hash 將其找到並返回給用戶。
  • 緩存命中率 = 用戶要訪問的緩存對象在高速緩存中被咱們訪問到的機率。

既然談到了本身的理解,我索性說個夠。

  • 緩存丟失 = 因爲高速緩存數量有限(佔據內存等緣由),因此用戶要訪問的緩存對象頗有可能被咱們從有限的高速緩存中淘汰掉了,咱們可能會將其存儲於低速的磁盤緩存中(若是磁盤緩存還有資源的話),那麼就要從磁盤緩存中獲取該緩存對象以返回給用戶,這種狀況我理解爲(高速)緩存未命中,即緩存丟失(並非真的被咱們丟掉了,但確定是被咱們從高速緩存淘汰掉了)。

緩存命中是 cache-hit,那麼若是你玩遊戲,能夠理解爲此次 hit miss 了(笑,有人找我開黑嗎)。

LRU

首先來說一下 LRU 的概念讓你們有一個基本的認識。LRU(least-recently-used) 翻譯過來是「最近最少使用」,顧名思義這種緩存替換策略是基於用戶最近訪問過的緩存對象而創建。

我認爲 LRU 緩存替換策略的核心思想在於:LRU 認爲用戶最新使用(訪問)過的緩存對象爲高頻緩存對象,即用戶極可能還會再次使用(訪問)該緩存對象;而反之,用戶好久以前使用(訪問)過的緩存對象(期間一直沒有再次訪問)爲低頻緩存對象,即用戶極可能不會再去使用(訪問)該緩存對象,一般在資源不足時會先去釋放低頻緩存對象。

_YYLinkedMapNode_YYLinkedMap 實現 LRU

YYCache 做者經過 _YYLinkedMapNode_YYLinkedMap 雙向鏈表實現 LRU 緩存替換策略的思路其實很簡捷清晰,咱們一步一步來看。

雙向鏈表中有頭結點和尾節點:

  • 頭結點 = 鏈表中用戶最近一次使用(訪問)的緩存對象節點,MRU。
  • 尾節點 = 鏈表中用戶已經好久沒有再次使用(訪問)的緩存對象節點,LRU。

如何讓頭結點和尾節點指向咱們想指向的緩存對象節點?咱們結合代碼來看:

  • 在用戶使用(訪問)時更新緩存節點信息,並將其移動至雙向鏈表頭結點。
- (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);
    // 返回找到的緩存節點 value
    return node ? node->_value : nil;
}
複製代碼
  • 在用戶設置緩存對象時,判斷入參 key 對應的緩存對象節點是否存在?存在則更新緩存對象節點並將節點移動至鏈表頭結點;不存在則根據入參生成新的緩存對象節點並插入鏈表表頭。
- (void)setObject:(id)object forKey:(id)key withCost:(NSUInteger)cost {
    // 判斷入參,省略
    ...
    pthread_mutex_lock(&_lock);
    // 判斷入參 key 對應的緩存對象節點是否存在
    _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key));
    NSTimeInterval now = CACurrentMediaTime();
    if (node) {
        // 存在則更新緩存對象節點並將節點移動至鏈表頭結點
        _lru->_totalCost -= node->_cost;
        _lru->_totalCost += cost;
        node->_cost = cost;
        node->_time = now;
        node->_value = object;
        [_lru bringNodeToHead:node];
    } else {
        // 不存在則根據入參生成新的緩存對象節點並插入鏈表表頭
        node = [_YYLinkedMapNode new];
        node->_cost = cost;
        node->_time = now;
        node->_key = key;
        node->_value = object;
        [_lru insertNodeAtHead:node];
    }
    // 判斷插入、更新節點以後是否超過了限制 cost、count,若是超過則 trim,省略
    ...
    pthread_mutex_unlock(&_lock);
}
複製代碼
  • 在資源不足時,從雙線鏈表的尾節點(LRU)開始清理緩存,釋放資源。
// 這裏拿 count 資源舉例,cost、age 本身觸類旁通
- (void)_trimToCount:(NSUInteger)countLimit {
    // 判斷 countLimit 爲 0,則所有清空緩存,省略
    // 判斷 _lru->_totalCount <= countLimit,沒有超出資源限制則不做處理,省略
    ...
    
    NSMutableArray *holder = [NSMutableArray new];
    while (!finish) {
        if (pthread_mutex_trylock(&_lock) == 0) {
            if (_lru->_totalCount > countLimit) {
                // 從雙線鏈表的尾節點(LRU)開始清理緩存,釋放資源
                _YYLinkedMapNode *node = [_lru removeTailNode];
                if (node) [holder addObject:node];
            } else {
                finish = YES;
            }
            pthread_mutex_unlock(&_lock);
        } else {
            // 使用 usleep 以微秒爲單位掛起線程,在短期間隔掛起線程
            // 對比 sleep 用 usleep 能更好的利用 CPU 時間
            usleep(10 * 1000); //10 ms
        }
    }
    
    // 判斷是否須要在主線程釋放,採起釋放緩存對象操做
    if (holder.count) {
        dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
        dispatch_async(queue, ^{
            // 異步釋放,咱們單獨拎出來說
            [holder count]; // release in queue
        });
    }
}
複製代碼

嘛~ 是否是感受敲簡單?上面代碼去掉了可能會分散你們注意力的代碼,咱們這裏僅僅討論 LRU 的實現,其他部分的具體實現源碼也很是簡單,我以爲不必貼出來單獨講解,感興趣的同窗能夠本身去 YYCache 下載源碼查閱。

異步釋放技巧

關於上面的異步釋放緩存對象的代碼,我以爲仍是有必要單獨拎出來說一下的:

dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
dispatch_async(queue, ^{
    // 異步釋放,咱們單獨拎出來說
    [holder count]; // release in queue
});
複製代碼

這個技巧 ibireme 在他的另外一篇文章 iOS 保持界面流暢的技巧 中有說起:

Note: 對象的銷燬雖然消耗資源很少,但累積起來也是不容忽視的。一般當容器類持有大量對象時,其銷燬時的資源消耗就很是明顯。一樣的,若是對象能夠放到後臺線程去釋放,那就挪到後臺線程去。這裏有個小 Tip:把對象捕獲到 block 中,而後扔到後臺隊列去隨便發送個消息以免編譯器警告,就可讓對象在後臺線程銷燬了。

而上面代碼中的 YYMemoryCacheGetReleaseQueue 這個隊列源碼爲:

// 靜態內聯 dispatch_queue_t
static inline dispatch_queue_t YYMemoryCacheGetReleaseQueue() {
    return dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
}
複製代碼

在源碼中能夠看到 YYMemoryCacheGetReleaseQueue 是一個低優先級 DISPATCH_QUEUE_PRIORITY_LOW 隊列,猜想這樣設計的緣由是可讓 iOS 在系統相對空閒時再來異步釋放緩存對象。

YYDiskCache 細節剖析

YYDiskCache 是一個線程安全的磁盤緩存,用於存儲由 SQLite 和文件系統支持的鍵值對(相似於 NSURLCache 的磁盤緩存)。

YYDiskCache 具備如下功能:

  • 它使用 LRU(least-recently-used) 來刪除對象。
  • 支持按 cost,count 和 age 進行控制。
  • 它能夠被配置爲當沒有可用的磁盤空間時自動驅逐緩存對象。
  • 它能夠自動抉擇每一個緩存對象的存儲類型(sqlite/file)以便提供更好的性能表現。

Note: 您能夠編譯最新版本的 sqlite 並忽略 iOS 系統中的 libsqlite3.dylib 來得到 2x〜4x 的速度提高。

@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<NSCoding>)objectForKey:(NSString *)key;

- (void)setObject:(nullable id<NSCoding>)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 結構與 YYMemoryCache 相似,因爲不少接口都是基於基本的接口作了擴展所得,這裏貼的代碼省略了一些接口。代碼仍是一如既往的乾淨簡潔,相信各位都能看懂。

YYDiskCache 是基於 sqlite 和 file 來作的磁盤緩存,咱們的緩存對象能夠自由的選擇存儲類型,下面簡單對比一下:

  • sqlite: 對於小數據(例如 NSNumber)的存取效率明顯高於 file。
  • file: 對於較大數據(例如高質量圖片)的存取效率優於 sqlite。

因此 YYDiskCache 使用二者配合,靈活的存儲以提升性能。

NSMapTable

NSMapTable 是相似於字典的集合,但具備更普遍的可用內存語義。NSMapTable 是 iOS6 以後引入的類,它基於 NSDictionary 建模,可是具備如下差別:

  • 鍵/值能夠選擇 「weakly」 持有,以便於在回收其中一個對象時刪除對應條目。
  • 它能夠包含任意指針(其內容不被約束爲對象)。
  • 您能夠將 NSMapTable 實例配置爲對任意指針進行操做,而不只僅是對象。

Note: 配置映射表時,請注意,只有 NSMapTableOptions 中列出的選項才能保證其他的 API 可以正常工做,包括複製,歸檔和快速枚舉。 雖然其餘 NSPointerFunctions 選項用於某些配置,例如持有任意指針,但並非全部選項的組合都有效。使用某些組合,NSMapTableOptions 可能沒法正常工做,甚至可能沒法正確初始化。

更多信息詳見 NSMapTable 官方文檔

須要特殊說明的是,YYDiskCache 內部是基於一個單例 NSMapTable 管理的,這點有別於 YYMemoryCache。

static NSMapTable *_globalInstances; // 引用管理全部的 YYDiskCache 實例
static dispatch_semaphore_t _globalInstancesLock; // YYDiskCache 使用 dispatch_semaphore 保障 NSMapTable 線程安全

static void _YYDiskCacheInitGlobal() {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _globalInstancesLock = dispatch_semaphore_create(1);
        _globalInstances = [[NSMapTable alloc] initWithKeyOptions:NSPointerFunctionsStrongMemory valueOptions:NSPointerFunctionsWeakMemory capacity:0];
    });
}

static YYDiskCache *_YYDiskCacheGetGlobal(NSString *path) {
    if (path.length == 0) return nil;
    _YYDiskCacheInitGlobal();
    dispatch_semaphore_wait(_globalInstancesLock, DISPATCH_TIME_FOREVER);
    id cache = [_globalInstances objectForKey:path];
    dispatch_semaphore_signal(_globalInstancesLock);
    return cache;
}

static void _YYDiskCacheSetGlobal(YYDiskCache *cache) {
    if (cache.path.length == 0) return;
    _YYDiskCacheInitGlobal();
    dispatch_semaphore_wait(_globalInstancesLock, DISPATCH_TIME_FOREVER);
    [_globalInstances setObject:cache forKey:cache.path];
    dispatch_semaphore_signal(_globalInstancesLock);
}
複製代碼

每當一個 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;
}
複製代碼

我在 YYCache 設計思路 中找到了做者使用 dispatch_semaphore 做爲 YYDiskCache 鎖的緣由:

dispatch_semaphore 是信號量,但當信號總量設爲 1 時也能夠看成鎖來。在沒有等待狀況出現時,它的性能比 pthread_mutex 還要高,但一旦有等待狀況出現時,性能就會降低許多。相對於 OSSpinLock 來講,它的優點在於等待時不會消耗 CPU 資源。對磁盤緩存來講,它比較合適。

YYKVStorageItem 與 YYKVStorage

剛纔在 YYDiskCache 的初始化源碼中,咱們不難發現一個類 YYKVStorage。與 YYMemoryCache 相對應的,YYDiskCache 也不會直接操做緩存對象(sqlite/file),而是經過 YYKVStorage 來間接的操做緩存對象。

從這一點上不難發現,YYKVStorage 等價於 YYMemoryCache 中的雙向鏈表 _YYLinkedMap,而對應於 _YYLinkedMap 中的節點 _YYLinkedMapNode,YYKVStorage 中也有一個類 YYKVStorageItem 充當着與緩存對象一對一的角色。

// YYKVStorageItem 是 YYKVStorage 中用來存儲鍵值對和元數據的類
// 一般狀況下,咱們不該該直接使用這個類
@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 和文件系統的鍵值存儲。 一般狀況下,咱們不該該直接使用這個類。 @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 的存儲類型。

YYKVStorageType

/** 存儲類型,指示「YYKVStorageItem.value」存儲在哪裏。 @discussion 一般,將數據寫入 sqlite 比外部文件更快,可是   讀取性能取決於數據大小。在個人測試(環境 iPhone 6 64G),   當數據較大(超過 20KB)時從外部文件讀取數據比 sqlite 更快。 */
typedef NS_ENUM(NSUInteger, YYKVStorageType) {
    YYKVStorageTypeFile = 0, // value 以文件的形式存儲於文件系統
    YYKVStorageTypeSQLite = 1, // value 以二進制形式存儲於 sqlite
    YYKVStorageTypeMixed = 2, // value 將根據你的選擇基於上面兩種形式混合存儲
};
複製代碼

在 YYKVStorageType 的註釋中標記了做者寫 YYCache 時作出的測試結論,你們也能夠基於本身的環境去測試驗證做者的說法(這一點是能夠討論的,咱們能夠根據本身的測試來設置 YYDiskCache 中的 inlineThreshold 閾值)。

若是想要了解更多的信息能夠點擊 Internal Versus External BLOBs in SQLite 查閱 SQLite 官方文檔。

YYKVStorage 性能優化細節

上文說到 YYKVStorage 能夠基於 SQLite 和文件系統作磁盤存儲,這裏再提一些我閱讀源碼發現到的有趣細節:

@implementation YYKVStorage {
	...
	CFMutableDictionaryRef _dbStmtCache; // 焦點集中在這裏
	...
}
複製代碼

能夠看到 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 的開銷。

sqlite3_stmt: 該對象的實例表示已經編譯成二進制形式並準備執行的單個 SQL 語句。

更多關於 SQLite 的信息請點擊 SQLite 官方文檔 查閱。

優秀的緩存應該具有哪些特質

嘛~ 咱們回到文章最初提到的問題,優秀的緩存應該具有哪些特質?

若是跟着文章一步步讀到這裏,相信很容易舉出如下幾點:

  • 內存緩存和磁盤緩存
  • 線程安全
  • 緩存控制
  • 緩存替換策略
  • 緩存命中率
  • 性能

咱們簡單的總結一下 YYCache 源碼中是如何體現這些特質的。

內存緩存和磁盤緩存

YYCache 是由內存緩存 YYMemoryCache 與磁盤緩存 YYDiskCache 相互配合組成的,內存緩存提供容量小但高速的存取功能,磁盤緩存提供大容量但低速的持久化存儲。這樣的設計支持用戶在緩存不一樣對象時都可以有很好的體驗。

在 YYCache 中使用接口訪問緩存對象時,會先去嘗試從內存緩存 YYMemoryCache 中訪問,若是訪問不到(沒有使用該 key 緩存過對象或者該對象已經從容量有限的 YYMemoryCache 中淘汰掉)纔會去從 YYDiskCache 訪問,若是訪問到(表示以前確實使用該 key 緩存過對象,該對象已經從容量有限的 YYMemoryCache 中淘汰掉成立)會先在 YYMemoryCache 中更新一次該緩存對象的訪問信息以後才返回給接口。

線程安全

若是說 YYCache 這個類是一個純邏輯層的緩存類(指 YYCache 的接口實現所有是調用其餘類完成),那麼 YYMemoryCache 與 YYDiskCache 仍是作了一些事情的(並無 YYCache 當甩手掌櫃那麼輕鬆),其中最顯而易見的就是 YYMemoryCache 與 YYDiskCache 爲 YYCache 保證了線程安全。

YYMemoryCache 使用了 pthread_mutex 線程鎖來確保線程安全,而 YYDiskCache 則選擇了更適合它的 dispatch_semaphore,上文已經給出了做者選擇這些鎖的緣由。

緩存控制

YYCache 提供了三種控制維度,分別是:cost、count、age。這已經知足了絕大多數開發者的需求,咱們在本身設計緩存時也能夠根據本身的使用環境提供合適的控制方式。

緩存替換策略

在上文解析 YYCache 源碼的時候,介紹了緩存替換策略的概念而且列舉了不少經典的策略。YYCache 使用了雙向鏈表(_YYLinkedMapNode_YYLinkedMap)實現了 LRU(least-recently-used) 策略,旨在提升 YYCache 的緩存命中率。

緩存命中率

這一律念是在上文解析 _YYLinkedMapNode_YYLinkedMap 小節介紹的,咱們在本身設計緩存時不必定非要使用 LRU 策略,能夠根據咱們的實際使用環境選擇最適合咱們本身的緩存替換策略。

性能

其實性能這個東西是隱而不見的,又是處處可見的(笑)。它從咱們最開始設計一個緩存架構時就被帶入,一直到咱們具體的實現細節中慢慢成形,最後成爲了咱們設計出來的緩存優秀與否的決定性因素。

上文中剖析了太多 YYCache 中對於性能提高的實現細節:

  • 異步釋放緩存對象
  • 鎖的選擇
  • 使用 NSMapTable 單例管理的 YYDiskCache
  • YYKVStorage 中的 _dbStmtCache
  • 甚至使用 CoreFoundation 來換取微乎其微的性能提高

看到這裏是否是恍然大悟,性能是怎麼來的?就是這樣對於每個細節的極致追求一點一滴聚沙成塔摳出來的。

總結

  • 文章系統的解讀了 YYCache 源碼,相信可讓各位讀者對 YYCache 的總體架構有一個清晰的認識。
  • 文章結合做者 YYCache 設計思路 中的內容對 YYCache 具體功能點實現源碼作了深刻剖析,再用我本身的理解表述出來,但願能夠對讀者理解 YYCache 中具體功能的實現提供幫助。
  • 根據我本身的源碼理解,把我認爲作的不錯的提高性能的源碼細節單獨拎出來作出詳細分析。
  • 總結概括出「一個優秀緩存須要具有哪些特質?」這一問題的答案,但願你們在面試中若是被問及「如何設計一個緩存」這類問題時能夠遊刃有餘。額,至少能夠爲你們提供一些回答思路,拋磚引玉(笑)。

文章寫得比較用心(是我我的的原創文章,轉載請註明 lision.me/),若是發現錯誤會優先在個人 我的博客 中更新,也推薦你們去那裏與我交流(嘛~ 貌似我尚未開放評論😓)。

但願個人文章能爲你帶來價值~ 也但願能夠動動手指幫我分享出去😁


補充~ 我建了一個技術交流微信羣,想在裏面認識更多的朋友!若是各位同窗對文章有什麼疑問或者工做之中遇到一些小問題均可以在羣裏找到我或者其餘羣友交流討論,期待你的加入喲~

Emmmmm..因爲微信羣人數過百致使不能夠掃碼入羣,因此請掃描上面的二維碼關注公衆號進羣。

相關文章
相關標籤/搜索