1. 程式人生 > IOS開發 >NSCache OC及Swift底層原始碼詳解

NSCache OC及Swift底層原始碼詳解

前言

本文的產生是因為看到了SDWebImage原始碼是使用NSCache來處理快取,之前對NSCache幾乎沒了解,所以本文將從OCSwift兩個角度來探索NSCache的原始碼。

本文同樣篇幅較長,但內容完整,希望大家能親自探索一遍,互相學習,互相交流。

NSCache一個可變集合,用於臨時儲存在資源不足時可能被收回的臨時鍵值對。 NSCache的特點:

  • 使用方便,類似字典,但與字典不同
  • 執行緒安全
  • 記憶體不足,NSCache會自動釋放儲存物件
  • NSCache是Key-Value資料結構,其中key是強引用,不實現NSCoping協議,作為key的物件不會被拷貝
  • NSDiscardableContent 可以改進快取回收行為

基於GNUstep原始碼探索NSCache

蘋果不提供OC的Foundation原始碼,有過相關了解的開發者們都知道喬布斯在離開蘋果後,成立了Next公司,並且推出了NeXTStep,最後演變成GNUstep。所以我們可以通過GNUStep來研究蘋果的一些原始碼,這是 GNUstep下載地址github.com/gnustep/lib…

開啟原始碼,在headers/Foundation下找到NSCache.h檔案

@interface GS_GENERIC_CLASS(NSCache,KeyT,ValT) : NSObject
{
#if GS_EXPOSE(NSCache)
  @private
/** The maximum total cost of all cache objects. */ NSUInteger _costLimit; /** Total cost of currently-stored objects. */ NSUInteger _totalCost; /** The maximum number of objects in the cache. */ NSUInteger _countLimit; /** The delegate object,notified when objects are about to be evicted. */
id _delegate; /** Flag indicating whether discarded objects should be evicted */ BOOL _evictsObjectsWithDiscardedContent; /** Name of this cache. */ NSString *_name; /** The mapping from names to objects in this cache. */ NSMapTable *_objects; /** LRU ordering of all potentially-evictable objects in this cache. */ GS_GENERIC_CLASS(NSMutableArray,ValT) *_accesses; /** Total number of accesses to objects */ int64_t _totalAccesses; - (NSUInteger) countLimit; - (void) setCountLimit: (NSUInteger)lim; - (NSUInteger) totalCostLimit; - (void) setTotalCostLimit: (NSUInteger)lim; 複製程式碼
  • 在這個檔案裡,相關的一些屬性如下:
    • _totalCost:總消耗數,所有快取物件的總消耗.
    • _totalAccesses:當前儲存物件的總訪問次數。
    • _countLimit:能夠快取物件的最大數量,預設值是0,沒有限制(限制是不精/不嚴格的)
    • totalCostLimit: 快取佔用的記憶體大小(限制是不精/不嚴格的)
    • _evictsObjectsWithDiscardedContent:是一個表示是否應該回收廢棄內容的標誌,預設YES
    • 外界能給開發者使用的只有_countLimit,totalCostLimit和_evictsObjectsWithDiscardedContent,這個在蘋果的Foundation裡的NSCache.h裡可以看到

NSMapTable解析

  • 再來看用來儲存快取的物件_objects,它是一個NSMapTable型別,那麼先點選進去看看NSMapTable
//指定初始化方法
- (id) initWithKeyOptions: (NSPointerFunctionsOptions)keyOptions
	     valueOptions: (NSPointerFunctionsOptions)valueOptions
	         capacity: (NSUInteger)initialCapacity;

- (id) initWithKeyPointerFunctions: (NSPointerFunctions*)keyFunctions
	     valuePointerFunctions: (NSPointerFunctions*)valueFunctions
			  capacity: (NSUInteger)initialCapacity;

// 便捷初始化方法
+ (id) mapTableWithKeyOptions: (NSPointerFunctionsOptions)keyOptions
		 valueOptions: (NSPointerFunctionsOptions)valueOptions;
複製程式碼
  • NSMapTable 有兩個指定初始化方法和一個便捷初始化方法
  • 初始化方法的引數keyOptionsvalueOptions,都是NSPointerFunctionsOptions型別,點進去可以看到它是一個列舉型別
enum {
  NSPointerFunctionsStrongMemory = (0<<0),NSPointerFunctionsZeroingWeakMemory = (1<<0),NSPointerFunctionsOpaqueMemory = (2<<0),NSPointerFunctionsMallocMemory = (3<<0),NSPointerFunctionsMachVirtualMemory = (4<<0),NSPointerFunctionsWeakMemory = (5<<0),NSPointerFunctionsObjectPersonality = (0<<8),NSPointerFunctionsOpaquePersonality = (1<<8),NSPointerFunctionsObjectPointerPersonality = (2<<8),NSPointerFunctionsCStringPersonality = (3<<8),NSPointerFunctionsStructPersonality = (4<<8),NSPointerFunctionsIntegerPersonality = (5<<8),NSPointerFunctionsCopyIn = (1<<16)
};
typedef	NSUInteger NSPointerFunctionsOptions;    
複製程式碼
  • 常用的列舉值是
    • NSPointerFunctionsStrongMemory: 強引用儲存物件
    • NSPointerFunctionsWeakMemory: 弱引用儲存物件
    • NSPointerFunctionsCopyIn:copy儲存物件
  • 換句話說,如果用下面的方法初始化NSMapTable
NSMapTable *aMapTable = [[NSMapTable alloc] initWithKeyOptions:NSPointerFunctionsCopyIn valueOptions:NSPointerFunctionsStrongMemory capacity:0];

NSMapTable *aMapTable = [NSMapTable mapTableWithKeyOptions:NSPointerFunctionsCopy
複製程式碼

其實等於NSMutableDictionay的如下初始化化方法

NSMutableDictionary *aDictionary = [[NSMutableDictionary alloc] initWithCapacity:0];

NSMutableDictionary *aDictionary = [NSMutableDictionary dictionary];
複製程式碼
  • NSDcitionary或者NSMutableDictionary中對於key和value的記憶體管理是,對key進行copy,對value進行強引用,只有滿足NSCopying協議的物件才能成為key值。

  • NSMaptable可以通過弱引用來持有keys和values,所以當key或者value被deallocated的時候,所儲存的實體也會被移除

  • 來到NSCache.m,找到快取物件_objects的初始化

 ASSIGN(_objects,[NSMapTable strongToStrongObjectsMapTable]); 
 
+ (id) strongToStrongObjectsMapTable
{
  return [self mapTableWithKeyOptions: NSPointerFunctionsObjectPersonality
                         valueOptions: NSPointerFunctionsObjectPersonality];
}

/** Use the -hash and -isEqual: methods for storing objects,and the
   * -description method to describe them. */
  NSPointerFunctionsObjectPersonality = (0<<8),複製程式碼
  • 看到上面用的是strongToStrongObjectsMapTable建立的物件,還是有點疑惑,key和value都是NSPointerFunctionsObjectPersonality這個列舉型別
  • 這個時候找官方文件,可以看到說明是返回一個keyvalue是強引用的MapTable物件,那麼正如我們開頭說的,NSCache的key會強引用快取物件,作為key的物件不會被拷貝,不會被拷貝意味著新增快取物件的時候是0消耗的

快取方法解析

直接來到SDCache.m,看看設定快取物件的方法

- (void) setObject: (id)obj forKey: (id)key cost: (NSUInteger)num
{
  _GSCachedObject *oldObject = [_objects objectForKey: key];
  _GSCachedObject *newObject;

  if (nil != oldObject)
    {
      [self removeObjectForKey: oldObject->key];
    }
  [self _evictObjectsToMakeSpaceForObjectWithCost: num];
  newObject = [_GSCachedObject new];
  // Retained here,released when obj is dealloc'd
  newObject->object = RETAIN(obj);
  newObject->key = RETAIN(key);
  newObject->cost = num;
  if ([obj conformsToProtocol: @protocol(NSDiscardableContent)])
    {
      newObject->isEvictable = YES;
      [_accesses addObject: newObject];
    }
  [_objects setObject: newObject forKey: key];
  RELEASE(newObject);
  _totalCost += num;
}
複製程式碼
  • 先看看這裡用到了一個物件_GSCachedObject,看看是如何定義的,已在下面加了註釋說明
@interface _GSCachedObject : NSObject
{
  @public
  id object;
  NSString *key;
  int accessCount; //當前物件的訪問次數
  NSUInteger cost; //當前物件的消耗
  BOOL isEvictable; //當前物件是否能被回收
}
@end
複製程式碼
  • 首先判斷是否有舊物件,如果有則呼叫removeObjectForKey移除
  • 接下來做了快取淘汰[self _evictObjectsToMakeSpaceForObjectWithCost: num]
  • 點進去看看是如何實現快取淘汰演算法的
- (void)_evictObjectsToMakeSpaceForObjectWithCost: (NSUInteger)cost
{
  NSUInteger spaceNeeded = 0;
  NSUInteger count = [_objects count];
  if (_costLimit > 0 && _totalCost + cost > _costLimit)
    {
      spaceNeeded = _totalCost + cost - _costLimit;
    }
  // Only evict if we need the space.
  if (count > 0 && (spaceNeeded > 0 || count >= _countLimit))
    {
      NSMutableArray *evictedKeys = nil;
      // Round up slightly.
      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]))
	{
	  // Don't evict frequently accessed objects.
	  if (obj->accessCount < averageAccesses && obj->isEvictable)
	    {
	      [obj->object discardContentIfPossible];
	      if ([obj->object isContentDiscarded])
		{
		  NSUInteger cost = obj->cost;

		  // Evicted objects have no cost.
		  obj->cost = 0;
		  // Don't try evicting this again in future; it's gone already.
		  obj->isEvictable = NO;
		  // Remove this object as well as its contents if required
		  if (_evictsObjectsWithDiscardedContent)
		    {
		      [evictedKeys addObject: obj->key];
		    }
		  _totalCost -= cost;
		  // If we've freed enough space,give up
		  if (cost > spaceNeeded)
		    {
		      break;
		    }
		  spaceNeeded -= cost;
		}
	    }
	}
      // Evict all of the objects whose content we have discarded if required
      if (_evictsObjectsWithDiscardedContent)
	{
	  NSString *key;

	  e = [evictedKeys objectEnumerator];
	  while (nil != (key = [e nextObject]))
	    {
	      [self removeObjectForKey: key];
	    }
	}
    [evictedKeys release];
    }
}
複製程式碼
  • 程式碼較多,逐段分析,前幾行程式碼主要是用來計算需要清除的空間spaceNeeded,_totalCost + cost - _costLimit總消耗 + 傳進來指定的銷燬(一般傳0) - 消耗限制
  • 接下來就看到最重要的一個平均訪問次數的計算公式
  • 接下來的程式碼通過[_accesses objectEnumerator]獲取了e物件,然後遍歷,先看看_accesses是如何定義的
/** LRU ordering of all potentially-evictable objects in this cache. */
  GS_GENERIC_CLASS(NSMutableArray,ValT) *_accesses;
複製程式碼
  • _accesses是一個用LRU演算法(如果一個數據在最近一段時間沒有被訪問到,那麼在將來它被訪問的可能性也很小)排序的NSMutableArray物件
  • 接下來看到這個遍歷e裡的判斷
if(obj->accessCount < averageAccesses && obj->isEvictable){
    [obj->object discardContentIfPossible];
}
複製程式碼
  • 如果這個物件小於上面計算出來的平均訪問次數並且它設定了可回收的屬性,呼叫[obj->object discardContentIfPossible];傳送一個訊息,標識這個物件是可銷燬的,如果計數變數為0時將會釋放這個物件
if ([obj->object isContentDiscarded])
		{
		  NSUInteger cost = obj->cost;

		  // Evicted objects have no cost.
		  obj->cost = 0;
		  // Don't try evicting this again in future; it's gone already.
		  obj->isEvictable = NO;
		  // Remove this object as well as its contents if required
		  if (_evictsObjectsWithDiscardedContent)
		    {
		      [evictedKeys addObject: obj->key];
		    }
		  _totalCost -= cost;
		  // If we've freed enough space,give up
		  if (cost > spaceNeeded)
		    {
		      break;
		    }
		  spaceNeeded -= cost;
		}
複製程式碼
  • 接下來通過_evictsObjectsWithDiscardedContent這個標識( 指示這個物件是否應該驅逐丟棄的標誌)來把當前物件的key加到evictedKeys
  • 然後重新計算需要清除的空間spaceNeeded_totalCost
 if (_evictsObjectsWithDiscardedContent)
	{
	  NSString *key;

	  e = [evictedKeys objectEnumerator];
	  while (nil != (key = [e nextObject]))
	    {
	      [self removeObjectForKey: key];
	    }
	}
    [evictedKeys release];
    }
複製程式碼
  • 最後在遍歷完e後,根據相同的標識把上面新增進來的key通過removeObjectForKey移除掉所有物件
  • 這個快取淘汰策略的方法已經解析完了,再回到外界
  // Retained here,released when obj is dealloc'd
  newObject->object = RETAIN(obj);
  newObject->key = RETAIN(key);
  newObject->cost = num;
  if ([obj conformsToProtocol: @protocol(NSDiscardableContent)])
    {
      newObject->isEvictable = YES;
      [_accesses addObject: newObject];
    }
  [_objects setObject: newObject forKey: key];
  RELEASE(newObject);
  _totalCost += num;
複製程式碼
  • 這裡把要新增的快取物件的key和value都進行RETAIN操作
  • 然後再判斷是否實現了NSDiscardableContent協議,實現了就把它加到lru排序的這個可變陣列_accesses
  • 最後再設定了當前快取物件並重新計算了當前的總消耗

小結

  • 在GNUstep裡的NSCache,最核心的快取淘汰策略還是通過LRU演算法來實現的,一個LRU演算法排序的可變陣列儲存所有的快取物件,然後根據物件的平均訪問次數 * 0.2 + 1 這個限制來淘汰所有低於這個訪問次數的物件,一直釋放直到有足夠的所需空間。
  • 通過NSDiscardableContent協議能夠改進快取回收行為,當一個類實現了該協議,並且這個類的物件不再被使用時意味著可以被釋放
  • 前言裡的特點基本都在原始碼裡探究到了,不過NSCache是執行緒安全的這裡沒有探究到。網上總結是說(本人沒看到原始碼,不敢確定):我們可以從不同的執行緒中新增、刪除和查詢快取中的物件,而不需要鎖定快取區域,其中執行緒安全是pthread_mutex完成的。

基於Swift Foundation原始碼探索NSCache

蘋果官方提供了swift版本的Foundation原始碼,這裡是下載連結github.com/apple/swift…

老規矩,開啟原始碼,找到NSCache.swift的新增快取物件的方法

open func setObject(_ obj: ObjectType,forKey key: KeyType,cost g: Int) {
        let g = max(g,0)
        let keyRef = NSCacheKey(key)
        
        _lock.lock()
        
        let costDiff: Int
        
        if let entry = _entries[keyRef] {
            costDiff = g - entry.cost
            entry.cost = g
            
            entry.value = obj
            
            if costDiff != 0 {
                remove(entry)
                insert(entry)
            }
        } else {
            let entry = NSCacheEntry(key: key,value: obj,cost: g)
            _entries[keyRef] = entry
            insert(entry)
            
            costDiff = g
        }
        
        _totalCost += costDiff
        
        var purgeAmount = (totalCostLimit > 0) ? (_totalCost - totalCostLimit) : 0
        while purgeAmount > 0 {
            if let entry = _head {
                delegate?.cache(unsafeDowncast(self,to:NSCache<AnyObject,AnyObject>.self),willEvictObject: entry.value)
                
                _totalCost -= entry.cost
                purgeAmount -= entry.cost
                
                remove(entry) // _head will be changed to next entry in remove(_:)
                _entries[NSCacheKey(entry.key)] = nil
            } else {
                break
            }
        }
        
        var purgeCount = (countLimit > 0) ? (_entries.count - countLimit) : 0
        while purgeCount > 0 {
            if let entry = _head {
                delegate?.cache(unsafeDowncast(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()
    }
複製程式碼
  • 方法較長,依然逐段分析,首先看到let keyRef = NSCacheKey(key)這行程式碼把當前的key包裝了一下,進去看看NSCacheKey
fileprivate class NSCacheKey: NSObject {
    
    var value: AnyObject
    
    init(_ value: AnyObject) {
        self.value = value
        super.init()
    }
    
    override var hash: Int {
        switch self.value {
        case let nsObject as NSObject:
            return nsObject.hashValue
        case let hashable as AnyHashable:
            return hashable.hashValue
        default: return 0
        }
    }
    
    override func isEqual(_ object: Any?) -> Bool {
        guard let other = (object as? NSCacheKey) else { return false }
        
        if self.value === other.value {
            return true
        } else {
            guard let left = self.value as? NSObject,let right = other.value as? NSObject else { return false }
            
            return left.isEqual(right)
        }
    }
}
複製程式碼
  • 在這裡重寫了hashisEqual這兩個方法,isEqual目的是為了重新定義key相等的條件,hash是為了能根據不同的情況生成唯一的key,總之,我們瞭解到這裡只是對於傳進來的key進行的一次包裝就行
  • 再回到剛剛的方法,_lock.lock()使用了物件鎖NSLock,來確保存取快取物件的執行緒安全,這裡驗證了前面說的執行緒安全結論
  • 接下來看到if let entry = _entries[keyRef]這裡去除了entry物件,_entries是一個Dictionary<NSCacheKey,NSCacheEntry<KeyType,ObjectType>>(),所以點進去看看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
    }
}
複製程式碼
  • 重點看到這裡的prevByCostnextByCost,這是一個雙向連結串列,指向的是當前的物件,關於雙向連結串列我這裡不詳細解釋了,不理解的可以補下相關知識
  • 繼續回到剛才的外界程式碼,看到這個判斷,先看如果key存在的判斷程式碼
 if let entry = _entries[keyRef] {
            costDiff = g - entry.cost
            entry.cost = g
            
            entry.value = obj
            
            if costDiff != 0 {
                remove(entry)
                insert(entry)
            }
        } else {
            let entry = NSCacheEntry(key: key,cost: g)
            _entries[keyRef] = entry
            insert(entry)
            
            costDiff = g
        }
複製程式碼
  • 更新了entry的消耗大小cost和儲存物件value,還計算了消耗大小差值costDiff
  • 如果消耗大小差值不等於0,則先移除entry,再插入entry,先看看remove(entry)
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 = oldNext
        }
    }
複製程式碼
  • 這裡是正常的雙向連結串列的刪除操作,如下圖我們要刪除 P
  • 再回到外面的程式碼,點進去看看insert(entry)的程式碼
 private func insert(_ entry: NSCacheEntry<KeyType,ObjectType>) {
        guard var currentElement = _head else {
            // The cache is empty
            entry.prevByCost = nil
            entry.nextByCost = nil
            
            _head = entry
            return
        }
        
        guard entry.cost > currentElement.cost else {
            // Insert entry at the head
            entry.prevByCost = nil
            entry.nextByCost = currentElement
            currentElement.prevByCost = entry
            
            _head = entry
            return
        }
        
        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
    }
複製程式碼
  • 這裡主要做了兩個處理,首先通過對cost的比較來找到當前的entry的合適插入位置,這也說明了這個連結串列是通過cost排序的,淘汰快取物件的時候方便根據cost淘汰,但其實一般我們傳入的cost都是0
  • 所以其實在這裡主要還是做了連結串列的插入處理,如下圖所示
  • 剛剛分析完快取物件的儲存,再回到最外層的程式碼繼續分析快取的淘汰策略
//重新計算當前的cost總數
_totalCost += costDiff
        
        var purgeAmount = (totalCostLimit > 0) ? (_totalCost - totalCostLimit) : 0
        while purgeAmount > 0 {
            if let entry = _head {
                delegate?.cache(unsafeDowncast(self,willEvictObject: entry.value)
                
                _totalCost -= entry.cost
                purgeAmount -= entry.cost
                
                remove(entry) // _head will be changed to next entry in remove(_:)
                _entries[NSCacheKey(entry.key)] = nil
            } else {
                break
            }
        }
複製程式碼
  • 首先計算了需要淘汰的快取大小purgeAmount
  • 然後在頭結點不為空if let entry = _head的情況下開始淘汰,第一步先進行代理的回撥delegate?.cache,再重新計算_totalCostpurgeAmount的大小,最後再移除這個entry,並且把這個entry.key指向的物件的值為nil
  • 所以上面的程式碼裡是依賴於totalCostLimit,根據cost來進行淘汰的,但是我們傳入的cost一般為0,這個策略一般也就沒有意義
  • 再繼續看接下來的快取淘汰策略
var purgeCount = (totalCostLimit > 0) ? (_entries.count - countLimit) : 0
        while purgeCount > 0 {
            if let entry = _head {
                delegate?.cache(unsafeDowncast(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
            }
        }
複製程式碼
  • 可以看到這裡計算出來了需要清理出來的快取物件的數量大小purgeCount,依賴countLimit來對快取的物件進行淘汰
  • 整個設定快取物件的原始碼已經看完了,所以在這裡的快取淘汰策略是根據totalCostLimitcountLimit同時處理的

總結

  • 無論是GUNStep還是swift foundation,用來做快取限制的totalCostLimitcountLimit,這兩個值都是不嚴格的,不一定會在一超出就立馬進行移除我們的快取物件,可能在將來的某一時刻移除,這取決於快取演算法的實現。以下是官方文件的解釋。

  • 在這篇文章裡主要探索的是OC和Swift裡NSCache的原始碼實現,GNUStep是根據快取物件的訪問次數,也就是LRU演算法來驅逐訪問次數較小的物件,而swiftFoundation是根據快取物件的cost,連結串列裡是按cost大小進行排序的,頭結點的cost最小,也就是先驅逐佔用快取較小的物件。