NSCache基础原理

942 阅读9分钟

- [背景]

最近突然想对内存部分做一个梳理, 就想要把一些常用的内存类做一个比较, 比如NSCache, YYCache, 
PINCache等几部分源码查看了解一下, 做个记录. 

NSCache

关于NSCache由于没有开源, 网上大致都是看之前GNUStep的源码, 所以今天参考代码为GNU公开NSCache的代码, 
这些知识主要都是学习框架和设计, 锻炼抽象能力, 理解当初大牛们实现的方法和思维方式.

源码地址

LRU科普

首先在说缓存之前, 说两个比较常见的缓存方式LRU和LFU, 没了解过的可以去力扣上面搜对应的题目, 一般题目要求都是在O(1)的复杂度内完成. 不管是YYCache还是NSCache都是使用了其中的原理.简单看一下lru,原理性代码如下:

#定义一个双向链表
class DLinkedNode:
    def __init__(self, key=0, value=0):
        self.key = key
        self.value = value
        self.prev = None
        self.next = None


class LRUCache:
    #初始化
    def __init__(self, capacity: int):
        #一个字典存储对应键值对
        self.cache = dict()
        # 使用伪头部和伪尾部节点    
        self.head = DLinkedNode()
        self.tail = DLinkedNode()
        self.head.next = self.tail
        self.tail.prev = self.head
        #缓存限制容量
        self.capacity = capacity
        #缓存真实容量
        self.size = 0
    #查
    def get(self, key: int) -> int:
        if key not in self.cache:
            return -1
        # 如果 key 存在,先通过哈希表定位,再移到头部
        node = self.cache[key]
        self.moveToHead(node)
        return node.value
    #增改
    def put(self, key: int, value: int) -> None:
        if key not in self.cache:
            # 如果 key 不存在,创建一个新的节点
            node = DLinkedNode(key, value)
            # 添加进哈希表
            self.cache[key] = node
            # 添加至双向链表的头部
            self.addToHead(node)
            self.size += 1
            #这里如果是正常的算法, 缓存的真实大小, 可以while循环做, 这里的size相当于缓存的数量, 并不是真实大小
            if self.size > self.capacity:
                # 如果超出容量,删除双向链表的尾部节点
                removed = self.removeTail()
                # 删除哈希表中对应的项
                self.cache.pop(removed.key)
                self.size -= 1
        else:
            # 如果 key 存在,先通过哈希表定位,再修改 value,并移到头部
            node = self.cache[key]
            node.value = value
            self.moveToHead(node)
    
    def addToHead(self, node):
        node.prev = self.head
        node.next = self.head.next
        self.head.next.prev = node
        self.head.next = node
    
    def removeNode(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev

    def moveToHead(self, node):
        self.removeNode(node)
        self.addToHead(node)
    def removeTail(self):
        node = self.tail.prev
        self.removeNode(node)
        return node

以上答案是力扣上的官方解答:

时间复杂度:对于 put 和 get 都是 O(1)。

空间复杂度:O(capacity),因为哈希表和双向链表最多存储capacity+1个元素。

首先:我们在考察一个缓存算法时, 需要考虑两点一

  • [是时间和空间, 大部分的设计初衷都会在同等代偿的情况下选择空间换时间]
  • [既然是缓存, 自然需要考虑 增, 删, 改, 查四个方式] 对上面的lru做一个最简单的解释:
  1. put: 如果存在: 就直接get修改, 然后会被移动到链表的头结点. 如果不存在:添加新节点到链表头部, 然后做容量处理判断是否超出内存,如果超出则从直接删除尾部节点腾出空间.O(1)
  2. get: 判断key是否在cache字典里, 如果有则移动节点到链表的头部, 返回节点的值.O(1)
  3. remove: 查询key是否在cache字典里, 如果有则删除节点,拼接节点的前后节点.O(1)
  • lru总结:上面的代码只是一个简单的思路规划, 为了更方便于理解, 目前的大部分缓存的算法基本都是一个字典或者MapTable+双向链表或者数组, 前者实现查找,后者用于真实内存和访问的改动.从而实现复杂度介于O(1)和O(n)的缓存算法.

NSCache中LRU的思想

NSCache初始化代码中:

有两个重要的属性,第一个是NSMapTable的_objects, 第二个是可变数组类型的_accesses.

NSMapTable不太熟悉的可以暂且当做字典认知, 所以大致根据上面lru的思路_objects就是查询的dic, _accesses就是真实的数组缓存删除的实际处理.

以下代码是一些配置的重要属性:

//初始化代码
- (id) init
{
  if (nil == (self = [super init]))
    {
      return nil;
    }
  ASSIGN(_objects,[NSMapTable strongToStrongObjectsMapTable]);
  _accesses = [NSMutableArray new];
  return self;
}

//其他参数
/*
_objects  "字典"缓存 结构为key:key, value为_GSCachedObject对象,
                    _GSCachedObject对象的object为实际存储的对象
_accesses 数组缓存 对象为_GSCachedObject对象
_countLimit 数量限制
_costLimit  总消耗限制
_totalCost  实际总消耗
_totalAccesses 总访问量

evictsObjectsWithDiscardedContent 缓存属性设置(如果为NO, 则处理缓存时_accesses中的数据不会被删除)

//缓存实际缓存的类(可理解为key-value中的value)
@interface _GSCachedObject : NSObject
{
  @public
  id object; //实际进来缓存的对象
  NSString *key; //缓存的key
  int accessCount; //访问数量
  NSUInteger cost; //obj所占内存大小
  BOOL isEvictable;//是否执行缓存策略, 默认yes
}
@end
*/

根据上面的属性, 源码的重要部分基本上也就用到这么多.接下来根据增删改查的方式, 进入源码一步一步查询:

setObject:forKey (增,改)

在不触发缓存溢出的情况下, 整个复杂度为O(1)

在出发了缓存溢出, 缓存策略执行的情况下, 由于遍历了_accesses数组, 所以复杂度为O(n), 实际上由于平均访问参数averageAccesses系数为*0.2+1的情况下, 执行的复杂度应该是远小于n, 去掉系数默认为O(n).

- (void) setObject: (id)obj forKey: (id)key cost: (NSUInteger)num
{
/*
逻辑:
1.先从_objects根据key取出_GSCachedObject对象oldObject.
2.如果oldObject存在,则移除掉.
3._evictObjectsToMakeSpaceForObjectWithCost 核心缓存策略
4.初始化newObject.
5.判断是否实现了协议NSDiscardableContent, 是否加入数组中.
6._totalCost加上传进来的大小.
*/

/*
此处为_evictObjectsToMakeSpaceForObjectWithCost是整个代码比较核心的地方!!!

*/

  _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;
}

- (void) setObject: (id)obj forKey: (id)key
{
  [self setObject: obj forKey: key cost: 0];
}

- (void)_evictObjectsToMakeSpaceForObjectWithCost: (NSUInteger)cost
{
  //spaceNeeded需要腾出的空间
  NSUInteger spaceNeeded = 0;
  NSUInteger count = [_objects count];
  //判断_costLimit(总消耗限制)大于0,并且_totalCost(已消耗)加上cost(当前消耗)是否大于_costLimit, spaceNeeded就是额外所需要的空间
  if (_costLimit > 0 && _totalCost + cost > _costLimit)
    {
      spaceNeeded = _totalCost + cost - _costLimit;
    }

  //如果spaceNeeded(额外空间大于0)或者count大于数量限制, 则进入缓存处理
  if (count > 0 && (spaceNeeded > 0 || count >= _countLimit))
    {
      //evictedKeys需要删除的keys数组
      NSMutableArray *evictedKeys = nil;
      // Round up slightly.
      
      //averageAccesses平均次数取了一个大致的相对小的数值
      //e _accesses转化为NSEnumerator对象方便遍历
      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.
          //此处标识为大概取值0.2, 但是本人并不是很明白, 这段代码读出来理解只能删除访问次数相对较少
          //的数据, 如果数据相对平均则不会进入真正的缓存处理阶段, 这个地方融合了一点LFU的思想, 
          //但是设计没怎么看懂, 各位道友求赐教
	  if (obj->accessCount < averageAccesses && obj->isEvictable)
	    {
	      [obj->object discardContentIfPossible];
              //obj->object指的是_GSCachedObject对象的isContentDiscarded代表是否可丢弃的意思
	      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 
                  //_evictsObjectsWithDiscardedContent是一个属性, 一般都是yes,不然缓存也无效
                  //这里的意思大致也是设置isEvictable的对象才能被移除
		  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];
    }
}

removeObjectForKey(删除), 整个逻辑很简单不太需要过多解释, 复杂度为O(1)

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

  if (nil != obj)
    {
      [_delegate cache: self willEvictObject: obj->object];
      _totalAccesses -= obj->accessCount;
      [_objects removeObjectForKey: key];
      [_accesses removeObjectIdenticalTo: obj];
    }
}

objectForKey(查找), 逻辑很简单, 复杂度为O(1)

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

  if (nil == obj)
    {
      return nil;
    }
  if (obj->isEvictable)
    {
      // Move the object to the end of the access list.
      [_accesses removeObjectIdenticalTo: obj];
      [_accesses addObject: obj];
    }
  obj->accessCount++;
  _totalAccesses++;
  return obj->object;
}

上述代码的整体的思路算是LRU和LFU的结合, 我还是不太理解设计系数为0.2的思路(主要是频次比较平均的使用数据的情况, 基本上不会触发缓存移除的策略), 目前只能理解为只删除低于一定频次的缓存, 希望大家赐教.

说说对NSCache这一份GNU源码源码的看法

  1. 源码在执行删除策略的同时, 用的是数组, 数组的低地址位存放的是最优先删除的部分, 数组的高地址存放的为最新使用的数据. 由于数组在移除策略的时候, 如果移除高地址则没有空间消耗直接移除就可以, 移除低地址高地址会统一向低地址偏移会有一定的内存消耗.
  2. 相比于双向链表的结构, 稍微差了那么一丢丢, 但是明显双向链表所需的空间更大.
  3. 这一份代码牛批的地方在哪里, 个人认为就是在于0.2系数删除策略的部分, 猜测是设计者想综合LRU和LFU的算法的优点做一个折中, 对此进行了数组前后相当于LRU, 执行删除的时候压低平均系数, 从而进行LFU的删除. (相当于每次删除的都是最久没使用, 频次又接近于最低的数据)
  4. LRU的问题是某一段特别频繁使用的数据,最近的时间段不用就会被清空, 但是在LFU里面这部分数据又是很重要的受保护数据, LFU反之推论. 所以设计者是为了综合的更好结果, 做了很多测试做了产生了这样一种折中的算法, 不得不说凭空想象的肆意捏造的思想着实是令人羡慕. (但是产生的问题至今没人回复我...)

总结

  • 在看NSCache设计到了几个点, 一个是NSMapTable区别于字典主要是可变和内存的管理,另外一个在GNU源码通知的实现中, NSMapTable还是那么的香, 以前的爸爸们喜欢用的原因个人认为多半是多种内存策略的原因
  • NSEnumerator的效率和普通的循环测试起来并不如for循环效率高, 但是似乎是很受用, 不过在开发中自带的循环block都要得益于抽象类NSEnumerator. 东西确实是好东西, 希望道友们解惑.
  • 总体来说:NSCache源码部分理解起来并不困难, 比较难的是设计者设计的思想, 我们可以多多借鉴学习.
  • 实际应用中的缓存一般都在内存和磁盘相互协作处理, 后续写完YY和PIN之后基本上就有大致的应用层理解了
  • 文章仅供参考,文中如果有错误的地方, 欢迎大家指出. --- 村村村村村村村村村长