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

相关文章
相关标签/搜索