NSCache OC及Swift底層原始碼詳解
前言
本文的產生是因為看到了SDWebImage
原始碼是使用NSCache
來處理快取,之前對NSCache
幾乎沒了解,所以本文將從OC
和Swift
兩個角度來探索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
有兩個指定初始化方法和一個便捷初始化方法 - 初始化方法的引數
keyOptions
和valueOptions
,都是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
這個列舉型別 - 這個時候找官方文件,可以看到說明是返回一個
key
和value
是強引用的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)
}
}
}
複製程式碼
- 在這裡重寫了
hash
和isEqual
這兩個方法,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
}
}
複製程式碼
- 重點看到這裡的
prevByCost
和nextByCost
,這是一個雙向連結串列,指向的是當前的物件,關於雙向連結串列我這裡不詳細解釋了,不理解的可以補下相關知識 - 繼續回到剛才的外界程式碼,看到這個判斷,先看如果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
,再重新計算_totalCost
和purgeAmount
的大小,最後再移除這個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
來對快取的物件進行淘汰 - 整個設定快取物件的原始碼已經看完了,所以在這裡的快取淘汰策略是根據
totalCostLimit
和countLimit
同時處理的
總結
-
無論是GUNStep還是swift foundation,用來做快取限制的
totalCostLimit
和countLimit
,這兩個值都是不嚴格的,不一定會在一超出就立馬進行移除我們的快取物件,可能在將來的某一時刻移除,這取決於快取演算法的實現。以下是官方文件的解釋。 -
在這篇文章裡主要探索的是OC和Swift裡
NSCache
的原始碼實現,GNUStep
是根據快取物件的訪問次數,也就是LRU演算法來驅逐訪問次數較小的物件,而swiftFoundation
是根據快取物件的cost,連結串列裡是按cost大小進行排序的,頭結點的cost最小,也就是先驅逐佔用快取較小的物件。