NSCache OC及Swift底层源码详解

2,462 阅读12分钟

前言

本文的产生是因为看到了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, 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()
    }
  • 方法较长,依然逐段分析, 首先看到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, value: obj, 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, 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
            }
        }
  • 首先计算了需要淘汰的缓存大小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, 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
            }
        }
  • 可以看到这里计算出来了需要清理出来的缓存对象的数量大小purgeCount,依赖countLimit来对缓存的对象进行淘汰
  • 整个设置缓存对象的源码已经看完了,所以在这里的缓存淘汰策略是根据totalCostLimitcountLimit同时处理的

总结

  • 无论是GUNStep还是swift foundation, 用来做缓存限制的totalCostLimitcountLimit,这两个值都是不严格的,不一定会在一超出就立马进行移除我们的缓存对象,可能在将来的某一时刻移除,这取决于缓存算法的实现。以下是官方文档的解释。

  • 在这篇文章里主要探索的是OC和Swift里NSCache的源码实现,GNUStep是根据缓存对象的访问次数,也就是LRU算法来驱逐访问次数较小的对象,而swiftFoundation是根据缓存对象的cost,链表里是按cost大小进行排序的,头结点的cost最小,也就是先驱逐占用缓存较小的对象。