- [背景]
最近突然想对内存部分做一个梳理, 就想要把一些常用的内存类做一个比较, 比如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做一个最简单的解释:
- put: 如果存在: 就直接get修改, 然后会被移动到链表的头结点. 如果不存在:添加新节点到链表头部, 然后做容量处理判断是否超出内存,如果超出则从直接删除尾部节点腾出空间.O(1)
- get: 判断key是否在cache字典里, 如果有则移动节点到链表的头部, 返回节点的值.O(1)
- 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源码源码的看法
- 源码在执行删除策略的同时, 用的是数组, 数组的低地址位存放的是最优先删除的部分, 数组的高地址存放的为最新使用的数据. 由于数组在移除策略的时候, 如果移除高地址则没有空间消耗直接移除就可以, 移除低地址高地址会统一向低地址偏移会有一定的内存消耗.
- 相比于双向链表的结构, 稍微差了那么一丢丢, 但是明显双向链表所需的空间更大.
- 这一份代码牛批的地方在哪里, 个人认为就是在于0.2系数删除策略的部分, 猜测是设计者想综合LRU和LFU的算法的优点做一个折中, 对此进行了数组前后相当于LRU, 执行删除的时候压低平均系数, 从而进行LFU的删除. (相当于每次删除的都是最久没使用, 频次又接近于最低的数据)
- LRU的问题是某一段特别频繁使用的数据,最近的时间段不用就会被清空, 但是在LFU里面这部分数据又是很重要的受保护数据, LFU反之推论. 所以设计者是为了综合的更好结果, 做了很多测试做了产生了这样一种折中的算法, 不得不说凭空想象的肆意捏造的思想着实是令人羡慕. (但是产生的问题至今没人回复我...)
总结
- 在看NSCache设计到了几个点, 一个是NSMapTable区别于字典主要是可变和内存的管理,另外一个在GNU源码通知的实现中, NSMapTable还是那么的香, 以前的爸爸们喜欢用的原因个人认为多半是多种内存策略的原因
- NSEnumerator的效率和普通的循环测试起来并不如for循环效率高, 但是似乎是很受用, 不过在开发中自带的循环block都要得益于抽象类NSEnumerator. 东西确实是好东西, 希望道友们解惑.
- 总体来说:NSCache源码部分理解起来并不困难, 比较难的是设计者设计的思想, 我们可以多多借鉴学习.
- 实际应用中的缓存一般都在内存和磁盘相互协作处理, 后续写完YY和PIN之后基本上就有大致的应用层理解了
- 文章仅供参考,文中如果有错误的地方, 欢迎大家指出. --- 村村村村村村村村村长