NSCache 源碼閱讀

NSCache 是一種可變集合,用於臨時存儲在資源不足時容易被回收的 key-value 鍵值對。NSCache 具備字典的全部功能,而且還具有以下特性:git

  • 內存不足時,NSCache 會自動清理緩存,而且提供了是否須要清理的開關和緩存清理時的回調
  • NSCache 是線程安全的
  • 區別於 NSMutableDictionary ,NSCache 不須要對 key 進行拷貝

在 SDWebImage 中就是使用 NSCache 來處理緩存的。接下來圍繞如下兩個問題去閱讀 NSCache 的源碼:github

  1. 緩存的自動清理是如何實現的?
  2. 如何保證緩存操做的線程安全?

因爲 ObjC 的 Foundation 框架開源,可是開源的GNUstep是 Cocoa 框架互換框架,雖然不能與蘋果的Cocoa實現徹底相同,可是二者的行爲和實現方式是同樣的,或者說很是類似。另外Apple 開源了 Swift 的核心庫,包含了 Swift 版本的 Foundation 框架源碼swift-corelibs-foundation。接下來就從 GNUstep 和 Swift 版本的 Foundation 框架中去探尋 NSCache 的實現。web

GNUstep中的NSCache

數據結構

@interface GS_GENERIC_CLASS(NSCache, KeyT, ValT) : NSObject
{
  @private
  NSUInteger _costLimit;    // 最大緩存開銷,默認爲0,表示無限制
  NSUInteger _totalCost;    // 緩存對象的總開銷
  NSUInteger _countLimit;   // 最大緩存數量,默認爲0,表示無限制
  id _delegate; // 代理,當緩存對象被清理或者移除時會收到通知
  BOOL _evictsObjectsWithDiscardedContent;  // 是否回收廢棄對象的標誌
  NSString *_name;  // 緩存內容名稱
  NSMapTable *_objects; // 緩存內容
  GS_GENERIC_CLASS(NSMutableArray, ValT) *_accesses;    // 緩存對象的LRU/LFU排序
  int64_t _totalAccesses;   // 緩存對象的訪問次數,用於LRU/LFU
}

- (NSUInteger) countLimit;
- (NSUInteger) totalCostLimit;
- (id) delegate;
- (BOOL) evictsObjectsWithDiscardedContent;
- (NSString*) name;
- (GS_GENERIC_TYPE(ValT)) objectForKey: (GS_GENERIC_TYPE(KeyT))key;
- (void) removeAllObjects;
- (void) removeObjectForKey: (GS_GENERIC_TYPE(KeyT))key;
- (void) setCountLimit: (NSUInteger)lim;
- (void) setDelegate: (id)del;
- (void) setEvictsObjectsWithDiscardedContent: (BOOL)b;
- (void) setName: (NSString*)cacheName;
- (void) setObject: (GS_GENERIC_TYPE(ValT))obj forKey:(GS_GENERIC_TYPE(KeyT))key cost: (NSUInteger)num;
- (void) setObject: (GS_GENERIC_TYPE(ValT))obj forKey: (GS_GENERIC_TYPE(KeyT))key;
- (void) setTotalCostLimit: (NSUInteger)lim;
@end
複製代碼

NSCache的數據結構

NSCacheDelegate 中提供了緩存即將清理的回調:算法

@protocol NSCacheDelegate

- (void) cache: (NSCache*)cache willEvictObject: (id)obj;

@end
複製代碼

GNUstep 中 NSCache 的實現將 cost、name、delegate、countlimit 都提供了 setter 和 getter 方法,而 Apple 的API中使用屬性自動實現 setter、getter 的功能簡化了這一操做。 Apple中NSCache的APIswift

NSCache 使用類型爲 NSMapTable 的 _objects 存儲緩存的內容,使用 NSMutableArray 類型的 _accesses 存儲須要在緩存淘汰算法中可能被淘汰的對象。提供了 cost 和 count 來標記緩存內容的大小,且標記了緩存訪問的總次數_totalAccesses。 在 NSMapTable 和 NSMutableArray 中存儲的是 _GSCachedObject 對象,該對象用來保存 cache 的基本信息:數組

// 緩存的對象,用於保存緩存對象的信息
@interface _GSCachedObject : NSObject
{
  @public
  id object;    // cache 內容
  NSString *key;    // cache 的 key
  int accessCount;  // cache 的訪問次數
  NSUInteger cost;  // cache 對象的開銷
  BOOL isEvictable; // cache 是否支持回收
}
@end
複製代碼

GSCachedObject的數據結構

緩存淘汰的實現

NSCache 提供了兩個添加緩存對象的方法:-setObject:forKey:cost:-setObject:forKey:,後一個方法的實現直接調用了前一個方法,傳入 cost=0 。緩存

NSCache 中緩存淘汰的時機是在添加對象時,即 -setObject:forKey:cost: 內,該方法的流程爲:安全

  • 先根據 key 查看 _objects 中是否有舊的內容,有則先刪除舊的
  • 調用緩存淘汰算法
  • 建立一個 _GSCachedObject 緩存對象,記錄 object、key、cost,若是對象實現了 NSDiscardableContent 協議,則將緩存對象添加到 _accesses 數組中,在使用緩存淘汰算法時,就能夠從 _accesses 去獲取符合清理條件的緩存對象
  • 將緩存對象添加到 _objects 中,並更新 cost
- (void) setObject: (id)obj forKey: (id)key cost: (NSUInteger)num {
    // 先根據key查看是否有舊的內容,有則先刪除舊的
   _GSCachedObject *oldObject = [_objects objectForKey: key];
   _GSCachedObject *newObject;
   
   if (nil != oldObject) {
      [self removeObjectForKey: oldObject->key];
   }
    
    // 調用緩存淘汰算法
   [self _evictObjectsToMakeSpaceForObjectWithCost: num];
    
    // 建立一個_GSCachedObject對象,記錄object、key、cost,
    newObject = [_GSCachedObject new];
    newObject->object = RETAIN(obj);
    newObject->key = RETAIN(key);
    newObject->cost = num;
    
    // 若是對象實現了NSDiscardableContent協議,則將對象添加到 _accesses 數組中,在使用緩存淘汰算法時,就能夠從 _accesses 去獲取能夠被清理的對象
   if ([obj conformsToProtocol: @protocol(NSDiscardableContent)]) {
       newObject->isEvictable = YES;
       [_accesses addObject: newObject];
    }
    
    // 添加到maptable中
   [_objects setObject: newObject forKey: key];
   RELEASE(newObject);
    
    // 更新cost
   _totalCost += num;
}
複製代碼

對於緩存淘汰的具體步驟爲:markdown

  • 依據 _totalCost_costLimit 判斷是否須要清理:只有當 cost 大於人工限制時纔會清理,即手動設置了 _costLimit,默認的 _costLimit = 0
  • spaceNeeded 標記須要釋放的空間,使用evictedKeys 數組存儲須要清理的對象的key
  • 使用迭代器遍歷 _accesses 數組 ,將知足清理條件的對象的 key 添加到 evictedKeys 數組中,清理的對象爲:標記爲可自動清理和低於平均訪問次數的對象,平均訪問次數 = (總訪問次數/緩存數量 * 0.2) + 1,即清理使用頻率較少的對象
  • 遍歷 evictedKeys 數組,使用removeObjectForKey:方法進行清理
- (void)_evictObjectsToMakeSpaceForObjectWithCost: (NSUInteger)cost {
    // 判斷是否須要清理
    // 只有當 cost 大於人工限制時纔會清理,即手動設置了 _costLimit
    // 若是 _costLimit = 0 則不進行干預
    NSUInteger spaceNeeded = 0; // 標記須要釋放的空間
    NSUInteger count = [_objects count];

    if (_costLimit > 0 && _totalCost + cost > _costLimit) {
      spaceNeeded = _totalCost + cost - _costLimit;
    }

    // 清理具體邏輯
    if (count > 0 && (spaceNeeded > 0 || count >= _countLimit)) {
      NSMutableArray *evictedKeys = nil;    // 使用數組存儲須要清理的對象,存儲的爲對象的key
      // 平均訪問次數 = (總訪問次數/緩存數量 * 0.2) + 1
      NSUInteger averageAccesses = ((_totalAccesses / (double)count) * 0.2) + 1;
      NSEnumerator *e = [_accesses objectEnumerator];
        _GSCachedObject *obj;

      if (_evictsObjectsWithDiscardedContent) {
	  evictedKeys = [[NSMutableArray alloc] init];
      }
	  
      while (nil != (obj = [e nextObject]))  {
           // 清理 設置了自動清理 而且 訪問次數小於平均訪問次數的對象
   	   if (obj->accessCount < averageAccesses && obj->isEvictable) {
                // 標識這個對象是可銷燬的,若是計數變量爲0時將會釋放這個對象
	        [obj->object discardContentIfPossible];
	        if ([obj->object isContentDiscarded]) {
                    NSUInteger cost = obj->cost;
	     	    obj->cost = 0;
                
                    // 避免後續再次被清理
                    obj->isEvictable = NO;
                
                    // 將須要被清理的對象的key 添加到清理數組中
                    if (_evictsObjectsWithDiscardedContent) {
			[evictedKeys addObject: obj->key];
		    }
                
                    // 更新總 cost
                    _totalCost -= cost;

	            // 釋放了足夠空間,則中止操做
		    if (cost > spaceNeeded) {
			break;
                    }
			    
	            // 更新須要釋放的空間
                    spaceNeeded -= cost;
		}
	    }
	}
      
      // 這裏進行清理操做
      if (_evictsObjectsWithDiscardedContent) {
            NSString *key;
            e = [evictedKeys objectEnumerator];
            while (nil != (key = [e nextObject])) {
		  [self removeObjectForKey: key];
            }
	}
       [evictedKeys release];
    }
}
複製代碼

在遍歷 _accesses 中的內容時,若是對象符合清理的條件,則使用了discardContentIfPossible 標識這個對象是可銷燬的,若是計數變量爲0時將會釋放這個對象。同時對該對象作了一些額外的工做:將 cost 重置爲 0,將 isEvictable 設置爲 NO,避免後續再次被清理。而後再將對象添加到清理的數組後,更新總 cost。此時,判斷若是釋放了足夠空間,則中止遍歷操做,直接進行下一步--遍歷清理數組,進行 remove 操做;不然更新須要釋放的空間,進入下一次迭代。數據結構

最後再對全部須要清理的緩存對象調用了 removeObjectForKey: 方法進行清理,該方法的具體實現爲:

- (void) removeObjectForKey: (id)key {
  _GSCachedObject *obj = [_objects objectForKey: key];
  
  if (nil != obj) {
        // 告知代理方,即將清理緩存對象
        [_delegate cache: self willEvictObject: obj->object];
        
        // 更新總的訪問次數
        _totalAccesses -= obj->accessCount;
        
        // 移除對象
        [_objects removeObjectForKey: key];
        
        // 移除LRU/LFU排序中的對象
        [_accesses removeObjectIdenticalTo: obj];
    }
}
複製代碼

在該方法中,先告知代理方,即將清理緩存對象,而後更新總的訪問次數,最後移除對象,同時移除 LRU/LFU 排序數組中的對象。

在 NSCache 中,淘汰的對象爲低於平均訪問次數的對象,對象的訪問頻次在-objectForKey:key 中進行更新,同時將標記了自動清理的對象添加到 LRU/LFU 排序數組的末端:

- (id) objectForKey: (id)key {
  _GSCachedObject *obj = [_objects objectForKey: key];

   if (nil == obj) {
      return nil;
   }
  
   // 若是標記了自動清理,則將對象添加到_accesses 的末端
   if (obj->isEvictable) {
      [_accesses removeObjectIdenticalTo: obj];
      [_accesses addObject: obj];
    }

    // 更新訪問自次數
    obj->accessCount++;
    _totalAccesses++;
    return obj->object;
}
複製代碼

上述方法中,將標記了能夠被自動清理的緩存對象添加到 LRU/LFU 排序數組的末端這一步是很是重要的,這樣可使訪問頻次高的都彙集在數組的尾部,當進行清理的時候,從頭部獲取的都是訪問頻次較低的對象排序數組中對象的訪問頻次

小結

GNUstep 的 NSCache 使用 NSMapTable 存儲緩存對象。NSMapTable 是 NSDictionary 的通用版本。

GNUstep 的 NSCache 自動清理邏輯爲:NSCache 使用 LRU/LFU 進行緩存的清理,使用數組存儲標記爲能夠被清理的對象,而且每次訪問對象時,將該對象移動到數組的末端,即實現了一個 LRU/LFU 的排序數組。 NSCache 記錄每一個緩存對象的訪問頻次和總的訪問頻次,在篩選清理對象時,將**(總訪問次數/緩存數量 * 0.2) + 1做爲平均訪問次數**,遍歷 LRU/LFU 的排序數組,將低於平均訪問次數的對象取出進行清理。若是已經釋放了足夠空間,則中止操做。

可是在 GNUstep 中並未發現線程安全的邏輯。

Swift中的NSCache

數據結構

在 Swift 版本中,採用一個 NSCacheEntry 類存儲 cache 對象的相關信息,NSCacheEntry 的數據結構以下:

private class NSCacheEntry<KeyType : AnyObject, ObjectType : AnyObject> {
    var key: KeyType
    var value: ObjectType
    var cost: Int
    var prevByCost: NSCacheEntry?
    var nextByCost: NSCacheEntry?
    init(key: KeyType, value: ObjectType, cost: Int) {
        self.key = key
        self.value = value
        self.cost = cost
    }
}
複製代碼

NSCacheEntry的數據結構

和 GNUstep 中的_GSCachedObject 緩存對象大體相同,都存儲了key、value、cost,不一樣的是 NSCacheEntry 提供了 prevByCostnextByCost ,用於實現雙向鏈表。

在 Apple Swift 版的 NSCache 中,採用 Dictionary 存儲緩存數據,實現了一個以緩存對象的 cost 升序的排序雙向鏈表,提供 head 頭節點,當須要淘汰緩存數據時,從頭節點開始刪除。同時,使用 NSLock 來保證線程安全。 cost升序的雙向鏈表

open class NSCache<KeyType : AnyObject, ObjectType : AnyObject> : NSObject {
    
    private var _entries = Dictionary<NSCacheKey, NSCacheEntry<KeyType, ObjectType>>()
    private let _lock = NSLock()  // 用於線程安全的鎖
    private var _totalCost = 0
    private var _head: NSCacheEntry<KeyType, ObjectType>?   // 排序鏈表的頭節點
    
    open var name: String = ""
    open var totalCostLimit: Int = 0 // 默認爲0,無限制
    open var countLimit: Int = 0 // 默認爲0,無限制
    open var evictsObjectsWithDiscardedContent: Bool = false
    open weak var delegate: NSCacheDelegate?
	
	public override init() {}
	open func object(forKey key: KeyType) -> ObjectType? {...}
	open func setObject(_ obj: ObjectType, forKey key: KeyType) {...}
	open func setObject(_ obj: ObjectType, forKey key: KeyType, cost g: Int) {...}
	open func removeObject(forKey key: KeyType) {...}
	open func removeAllObjects() {...}
}
複製代碼

NSCache的數據結構

具體實現

整個緩存的核心邏輯大部分在 setObject(_: forKey: cost: ) 方法中,該方法作了如下幾件事:

  1. 將對象存儲在 Dictionary 中
  2. 將對象加入排序鏈表中
  3. 執行淘汰策略
  4. 使用 NSlock 對整個插入和淘汰過程進行加鎖

先看一下執行淘汰以前的具體操做:

let g = max(g, 0)
let keyRef = NSCacheKey(key)

_lock.lock()    // 對整個insert和淘汰過程進行lock

let costDiff: Int

if let entry = _entries[keyRef] {   // 若是已存在相同key的對象,則更新字典中舊對象的value,若是新舊對象的cost不一樣,則刪除sort中的舊元素並插入新元素
    costDiff = g - entry.cost   // 計算舊對象和新對象cost的差值
    entry.cost = g  // 更新舊對象的cost
    
    entry.value = obj   // 更新舊對象的value
    
    if costDiff != 0 {  // 若是cost的差值 != 0,刪除舊的,插入新的
        remove(entry)
        insert(entry)
    }
} else {    // 不存在,則直接添加到字典中
    let entry = NSCacheEntry(key: key, value: obj, cost: g)
    _entries[keyRef] = entry
    insert(entry)
    
    costDiff = g
}

// 更新總的cost
_totalCost += costDiff
複製代碼

該流程分爲兩個分支:

  1. 若是字典中不存在相同 key 的對象,則直接將建立一個 NSCacheEntry 對象並添加到字典和排序鏈表中;
  2. 若是已存在相同 key 的對象,則更新字典中舊對象的 cost 和 value 。而後判斷新舊對象的 cost 是否有差別,若是有,則刪除排序鏈表中的舊元素,再插入新的元素。這裏經過複用舊對象,減小了對字典的寫入和刪除操做

執行完上述操做後,就執行淘汰邏輯:

// 根據Cost判斷是否須要淘汰
var purgeAmount = (totalCostLimit > 0) ? (_totalCost - totalCostLimit) : 0
while purgeAmount > 0 { // 使用while循環從頭開始remove元素,直到達到須要淘汰的數量,或者鏈表爲空
    if let entry = _head {  // 獲取head並remove
        delegate?.cache(unsafeDowncast(self, to:NSCache<AnyObject, AnyObject>.self), willEvictObject: entry.value)
        
        _totalCost -= entry.cost
        purgeAmount -= entry.cost
        
        // 在remove的時候head會移動到下一個對象上
        remove(entry) // _head will be changed to next entry in remove(_:)
        _entries[NSCacheKey(entry.key)] = nil
    } else {
        break
    }
}

// 根據count判斷是否須要淘汰
var purgeCount = (countLimit > 0) ? (_entries.count - countLimit) : 0
while purgeCount > 0 {
    if let entry = _head {
        delegate?.cache(unsafeDowncast(self, to:NSCache<AnyObject, AnyObject>.self), willEvictObject: entry.value)
        
        _totalCost -= entry.cost
        purgeCount -= 1
        
        remove(entry) // _head will be changed to next entry in remove(_:)
        _entries[NSCacheKey(entry.key)] = nil
    } else {
        break
    }
}

_lock.unlock()
複製代碼

Swift 中的淘汰流程分爲兩部分:先根據緩存的總 cost 進行淘汰,再根據總 count 進行淘汰。淘汰過程爲:使用 while 循環從頭開始 remove 排序雙向鏈表中的元素,直到鏈表爲空或者淘汰後的 cost/count 知足要求。由於是從鏈表的 head 開始刪除,因此在 remove 的時候 head 會移動到下一個對象上

接着去看排序鏈表的 removeinsert 。因爲鏈表是有序的,remove 比較簡單,若是刪除的是 head,則更新 head的 位置:

private func remove(_ entry: NSCacheEntry<KeyType, ObjectType>) {
    let oldPrev = entry.prevByCost
    let oldNext = entry.nextByCost
    
    oldPrev?.nextByCost = oldNext
    oldNext?.prevByCost = oldPrev
    
    if entry === _head { // 若是刪除的是head,則更新head的位置
        _head = oldNext
    }
}
複製代碼

排序鏈表的插入操做稍顯複雜,須要維持鏈表的排序,整個流程爲:

  • 當緩存爲空時,insert 的對象做爲 head,insert 結束。
  • 緩存不爲空,若是 insert 的對象 cost <= head 的 cost,將對象添加到鏈表頭部, insert 結束
  • 緩存不爲空,若是 insert 的對象 cost > head 的 cost,根據對象的 cost 找到合適的位置 insert,造成一個 cost 升序的雙向鏈表
private func insert(_ entry: NSCacheEntry<KeyType, ObjectType>) {
    guard var currentElement = _head else { // 當緩存爲空時,insert的內容做爲head
        // The cache is empty
        entry.prevByCost = nil
        entry.nextByCost = nil
        
        _head = entry
        return
    }
    
    guard entry.cost > currentElement.cost else { // 若是insert的對象cost <= head的cost,將對象添加到鏈表頭部
        // Insert entry at the head
        entry.prevByCost = nil
        entry.nextByCost = currentElement
        currentElement.prevByCost = entry
        
        _head = entry
        return
    }
    
    // 若是insert的對象cost > head的cost 的後續操做
    // 根據對象的cost找到合適的位置insert,造成一個cost升序的雙向鏈表
    while let nextByCost = currentElement.nextByCost, nextByCost.cost < entry.cost {
        currentElement = nextByCost
    }
    
    // Insert entry between currentElement and nextElement
    let nextElement = currentElement.nextByCost
    
    currentElement.nextByCost = entry
    entry.prevByCost = currentElement
    
    entry.nextByCost = nextElement
    nextElement?.prevByCost = entry
}
複製代碼

整個流程圖爲: 圖片

小結

  1. Swift 版本中的 NSCache 使用 Dictionary 存儲對象,在新增內容時,儘可能複用內部內容,減小字典的讀寫操做;
  2. 經過 NSCacheEntry 維護一個雙向鏈表,鏈表從 head 到 tail 造成一個 cost 升序的 sort ,在緩存淘汰時,從 head 開始刪除。淘汰的標準爲兩個:cost 和 count,先知足 cost,再知足 count。沒有根據訪問頻次來維護緩存,而是根據 cost 來維護緩存,淘汰的時 cost 較小的元素;
  3. 使用 NSLock 保證線程安全。

對比

  • 淘汰策略:GNUSetup 使用 LRU/LFU 機制進行淘汰,使用頻率較少的元素先淘汰;Swfit Foundation 依據對象的 cost 進行淘汰,cost 較少的先淘汰
  • 數據結構:GNUSetup 中使用 maptable 存儲緩存對象,使用 array 維護 LRU/LFU 排序後的對象,用於緩存淘汰;Swfit Foundation 中使用 dictionary 存儲緩存對象,維護一個排序的雙向鏈表,用於緩存淘汰
  • 線程安全:GNUSetup 中沒有保證 cache 線程安全的代碼;Swfit Foundation 中使用 NSLock 保證緩存讀寫的線程安全

可是須要注意 Apple 官方的這句話:

This is not a strict limit, and if the cache goes over the limit, an object in the cache could be evicted instantly, at a later point in time, or possibly never, all depending on the implementation details of the cache.

NSCache 並是不嚴格的依據 totalCostLimitcountLimit 來作緩存限制的,不必定會在一超出就立馬進行移除咱們的緩存對象,可能在未來的某一時刻移除,這取決於緩存算法的實現。

SDWebImage的應用

在 SDWebImage 中,經過將圖片放到 NSCache 中,利用 NSCache 自動釋放內存的特色在內存不足時自動淘汰不經常使用的圖片。在讀取圖片時,先檢查內存裏是否有,有則直接返回;沒有再從磁盤裏讀取。以此減小磁盤操做,保證空間合理釋放。

- (nullable UIImage *)imageFromCacheForKey:(nullable NSString *)key options:(SDImageCacheOptions)options context:(nullable SDWebImageContext *)context {
    // 先檢查內存裏是否有,有則直接返回
    UIImage *image = [self imageFromMemoryCacheForKey:key];
    if (image) {
        return image;
    }
    
    // 再從磁盤裏讀取
    image = [self imageFromDiskCacheForKey:key options:options context:context];
    return image;
}

- (nullable UIImage *)imageFromMemoryCacheForKey:(nullable NSString *)key {
    return [self.memoryCache objectForKey:key];
}
複製代碼

代碼中 self.memoryCache 爲 SDMemoryCache, SDMemoryCache 內部就是將 NSCache 擴展爲了 SDMemoryCache 協議:

@protocol SDMemoryCache <NSObject>
@required
- (nonnull instancetype)initWithConfig:(nonnull SDImageCacheConfig *)config;
- (nullable id)objectForKey:(nonnull id)key;
- (void)setObject:(nullable id)object forKey:(nonnull id)key;
- (void)setObject:(nullable id)object forKey:(nonnull id)key cost:(NSUInteger)cost;
- (void)removeObjectForKey:(nonnull id)key;
- (void)removeAllObjects;
@end

@interface SDMemoryCache <KeyType, ObjectType> : NSCache <KeyType, ObjectType> <SDMemoryCache>
@property (nonatomic, strong, nonnull, readonly) SDImageCacheConfig *config;
@end
複製代碼

Reference

相關文章
相關標籤/搜索