阅读 818

iOS NSCache & NSURLCache 机制原理探究 (一)

经常听说 HTTP 缓存 , 磁盘缓存 , 内存缓存 , 等等 . 但却搞不太清楚具体内容 ? 没关系 , 这两篇文章我们一起来探索一下 .

1. NSCache

1.1 NSCache 定义与主要特点

  • NSCache 是苹果官方提供的缓存类,具体使用和 NSMutableDictionary 类似,在 AFNSDWebImage 框架中被使用来管理缓存
  • 官方解释 NSCache 在系统内存很低时,会自动释放对象 ( 但是注意 , 这里还有点文章 , 本文会讲 )
  • NSCache 是线程安全的,在多线程操作中,不需要对 NSCache 加锁
  • NSCacheKey 只是对对象进行 Strong 引用,不是拷贝,在清理的时候计算的是实际大小而不是引用的大小 , 其 key 不需要实现 NSCoping 协议. ( 这一点不太了解的同学可以类比 NSMapTable 去学习)

1.2 NSCache 中比较重要的属性 & 方法

NSCache 中有几个比较重要的属性和方法 , 是你必须要了解的 :

1.2.1 属性

  • totalCostLimit

    • 总消耗大小 . 当超过这个大小时 NSCache 会做一个内存修剪操作 . 默认值为0,表示没有限制

  • countLimit

    • 能够缓存的对象的最大数量。默认值为0,表示没有限制

  • evictsObjectsWithDiscardedContent

    • 标识缓存是否回收废弃的内容

1.2.2 方法

//在缓存中设置指定键名对应的值,0成本
- (void)setObject:(ObjectType)obj forKey:(KeyType)key;

/*  
· 在缓存中设置指定键名对应的值,并且指定该键值对的成本,
用于计算记录在缓存中的所有对象的总成本
· 当出现内存警告或者超出缓存总成本上限的时候,缓存会开启一个回收过程,释放部分内容
*/
- (void)setObject:(ObjectType)obj forKey:(KeyType)keycost:(NSUInteger)g;

//删除缓存中指定键名的对象
- (void)removeObjectForKey:(KeyType)key;

//删除缓存中所有的对象
- (void)removeAllObjects;
复制代码

1.3 NSCache Demo

简单的了解了 NSCache 这个类 , 我们来写个 demo , 以便研究它的释放机制和逻辑 .

  • LBNSCacheIOP 类 , 遵循了 NSCacheDelegate , 主要是监听 NSCache 对象的释放代理回调通知.
// LBNSCacheIOP.h
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface LBNSCacheIOP : NSObject 

@end
NS_ASSUME_NONNULL_END

//LBNSCacheIOP.m
#import "LBNSCacheIOP.h"
@interface LBNSCacheIOP () <NSCacheDelegate>

@end

@implementation LBNSCacheIOP

- (void)cache:(NSCache *)cache willEvictObject:(id)obj{
    NSLog(@"obj:%@ 即将被:%@销毁",obj,cache);
}

@end
复制代码
  • ViewController
// ViewController.h
#import <UIKit/UIKit.h>

@interface ViewController : UIViewController

@end

// ViewController.m
#import "ViewController.h"
#import "LBNSCacheIOP.h"

@interface ViewController ()
@property(nonatomic , strong) NSCache * cache;
@property(nonatomic , strong) LBNSCacheIOP * cacheIOP;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    _cacheIOP = [LBNSCacheIOP new];
    
    _cache = [[NSCache alloc] init];
    _cache.countLimit = 5;
    _cache.delegate = _cacheIOP;
    
    //往缓存中添加数据
    [self lb_addCacheObject];
    
    //内存警告通知
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(lb_didReceiveMemoryWaring:) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
}

#pragma Mark - funcs
- (void)lb_addCacheObject{
    for (int i = 0; i < 10; i++) {
        [_cache setObject:[NSString stringWithFormat:@"lb_%d",i] forKey:[NSString stringWithFormat:@"lb__%d",i]];
    }
}
- (void)lb_getCacheObject{
    for (int i = 0; i < 10; i++) {
        NSLog(@"Cache object:%@, at index :%d",[_cache objectForKey:[NSString stringWithFormat:@"lb__%d",i]],i);
    }
}

#pragma Mark - MemoryWaringNotif
- (void)lb_didReceiveMemoryWaring:(NSNotification *)notification{
    NSLog(@"notification----%@",notification);
}
//点击屏幕查看当前缓存对象存储内容
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    [self lb_getCacheObject];
}
@end
复制代码

简单说一下代码逻辑就是:创建了一个 NSCache 类 , 注册了代理去监听内容释放 , 页面创建就执行添加十个字符串进去 , 点击屏幕就查看当前 cache 存储的内容.

OK , 执行 , 打印如下 :

obj:lb_0 即将被:<NSCache: 0x600002f41cc0>销毁
obj:lb_1 即将被:<NSCache: 0x600002f41cc0>销毁
obj:lb_2 即将被:<NSCache: 0x600002f41cc0>销毁
obj:lb_3 即将被:<NSCache: 0x600002f41cc0>销毁
obj:lb_4 即将被:<NSCache: 0x600002f41cc0>销毁
复制代码
  • 点击屏幕 . 打印如下:
Cache object:(null), at index :0
Cache object:(null), at index :1
Cache object:(null), at index :2
Cache object:(null), at index :3
Cache object:(null), at index :4
Cache object:lb_5, at index :5
Cache object:lb_6, at index :6
Cache object:lb_7, at index :7
Cache object:lb_8, at index :8
Cache object:lb_9, at index :9
复制代码

可以看到 , 我们 countLimit 缓存数量设置为 5 时 , 后续继续添加缓存时 , NSCache 对象会释放之前存储的内容 , 然后设置新的内容 .

( 注意 , 我并没有说会依次从前往后按存的顺序释放 , 虽然目前来看打印结果是这样 , 释放的到底是谁会根据其他一些处理来决定 . 下面会讲述. )

  • 选择模拟器 ,shift + cmd + h 将程序放入后台 ,然后我们就看到控制台上打印了:
obj:lb_5 即将被:<NSCache: 0x600002f41cc0>销毁
obj:lb_6 即将被:<NSCache: 0x600002f41cc0>销毁
obj:lb_7 即将被:<NSCache: 0x600002f41cc0>销毁
obj:lb_8 即将被:<NSCache: 0x600002f41cc0>销毁
obj:lb_9 即将被:<NSCache: 0x600002f41cc0>销毁
复制代码
  • 点击屏幕 . 打印如下:
Cache object:(null), at index :0
Cache object:(null), at index :1
Cache object:(null), at index :2
Cache object:(null), at index :3
Cache object:(null), at index :4
Cache object:(null), at index :5
Cache object:(null), at index :6
Cache object:(null), at index :7
Cache object:(null), at index :8
Cache object:(null), at index :9
复制代码

也就是说 ,APP 进入后台之后 NSCache 会自动释放存储内容 ,并触发回调

  • 那么当我们收到内存警告的时候 ,会自动释放其中内容吗 ?我们来测试一下:

选择模拟器 ,发送通知。查看控制台 , 然后点击屏幕

打印如下 :

notification----NSConcreteNotification 0x6000010816b0 {name = UIApplicationDidReceiveMemoryWarningNotification; object = <UIApplication: 0x7fb0d1600a50>}
Cache object:(null), at index :0
Cache object:(null), at index :1
Cache object:(null), at index :2
Cache object:(null), at index :3
Cache object:(null), at index :4
Cache object:lb_5, at index :5
Cache object:lb_6, at index :6
Cache object:lb_7, at index :7
Cache object:lb_8, at index :8
Cache object:lb_9, at index :9
复制代码

以上发现 , 当收到内存警告时 , NSCache 并不会自动释放存储的内容 .

还有一点需要提到的就是 鉴于 NSCache 官方文档中描述的所说. 苹果源生提供了一个 NSDiscardableContent 协议机制 , 以此来提高缓存的驱逐/释放行为.

什么意思呢 ? 这里就不讲述的很细了 因为我也只是了解个大概

  • 也就是说 , 当我们同意了这个这个协议 , 其实就是给存储的内容打上了一个 purgeable (可被清除) 的标识 , 具体逻辑机制我们等下来探究 , 为什么要做这个呢 ? 结合苹果硬件来说的话 , 默认情况时 , 当我们申请一块内存 , 当没有空闲内存时 , 系统会将一块可释放的内存中的数据置换到磁盘上而并非是直接删除 . 那么这块内存就可以被用来存储新的内容.

  • 那么内存置换内容和创建新内容产生的开销对比 , 前者会更大 , 因此这个协议标识之后 , 这块内存会被直接释放 , 不再进行置换 . 以此达到优化的策略 .

还是不太清楚 ? 没关系 . 我们写代码来验证它的具体机制.

同样是刚刚我们的这一份代码 . 不过增加一下几个步骤的处理.

  • 1️⃣: 添加一个 NSPurgeableData 类型的属性 testPurgeableData.
@property (nonatomic, strong) NSPurgeableData *testPurgeableData;
复制代码
  • 2️⃣: 同样 , 还是在 viewdidload 中设置初始化东西 , 读取一张图片 CGImageGetDataProvider , 然后赋值到 _testPurgeableData 中.
- (void)viewDidLoad {
    [super viewDidLoad];
    
    _cacheIOP = [LBNSCacheIOP new];
    
    _cache = [[NSCache alloc] init];
    _cache.countLimit = 5;
    _cache.delegate = _cacheIOP;
    
    //加载一张图片数据
    UIImage *image = [UIImage imageNamed:@"timg.jpeg"];;
    CFDataRef rawData = CGDataProviderCopyData(CGImageGetDataProvider(image.CGImage));
    
    //读取数据赋值给 NSPurgeableData 属性对象
    _testPurgeableData = [[NSPurgeableData alloc] initWithData:(__bridge NSData * _Nonnull)(rawData)];
    
    //往缓存中添加数据
    [self lb_addCacheObject];
    
    //内存警告通知
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(lb_didReceiveMemoryWaring:) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
}
复制代码
  • 3️⃣: 添加数据的方法作如下处理 :
- (void)lb_addCacheObject{
    for (int i = 0; i < 4; i++) {
        [_cache setObject:[NSString stringWithFormat:@"lb_%d",i] forKey:[NSString stringWithFormat:@"lb__%d",i]];
    }
    [_cache setObject:_testPurgeableData forKey:@"lb__4"];
}
复制代码
  • 4️⃣: 接收到内存警告处理
#pragma Mark - MemoryWaringNotif
- (void)lb_didReceiveMemoryWaring:(NSNotification *)notification{
    NSLog(@"notification----%@",notification);
    [_testPurgeableData endContentAccess];
}
复制代码

简单说一下代码 , 其实就是我们使用了一个 NSPurgeableData 的对象 , 因为它是遵循了 NSDiscardableContent 协议的.

  • 在初始化 vc 时添加了 4 个字符串和一个 NSPurgeableData 对象.
  • 在收到内存警告时 将这个对象计数器减一 endContentAccess .

这里的计数器还是提一下吧 , 它和我们的引用计数不同 , 但是又很类似.

@protocol NSDiscardableContent
@required
- (BOOL)beginContentAccess; //计数器加一,
- (void)endContentAccess;  // 计数器减一
@end
复制代码

当计数器 >= 1 时 , 代表对象是可以使用的 , 否则代表对象是可被清除的.

好 . 那么我们 run 一下 code . 运行成功后 大家可以先点击一下屏幕打印一下当前 NSCache 存储的情况 . 我就不列了 . 因为图片 data 很长 . 然后选择模拟器 shift + cmd + m 发出内存警告. 点击屏幕 . 打印结果 :

Cache object:lb_0, at index :0
Cache object:lb_1, at index :1
Cache object:lb_2, at index :2
Cache object:lb_3, at index :3
obj:<NSPurgeableData: 0x6000010fc580> 即将被:<NSCache: 0x6000010c2680>销毁
Cache object:(null), at index :4
Cache object:(null), at index :5
Cache object:(null), at index :6
Cache object:(null), at index :7
Cache object:(null), at index :8
Cache object:(null), at index :9
复制代码

我们看到一个小细节 , 收到内存警告并没有释放, 但当我们再次访问时 , 第 5 个数据被释放了. 第五个数据实现了 NSDiscardableContent 协议 , 那么也就是 当访问 NSCache 对象时 , 会自动释放掉所有计数为 0 的对象 .

看到这里我们大体上对 NSCache 的机制大体上有了了解. 那么接下来 我们结合 GNUstep 以及 swift foundation 来查看下 NSCache 源码.

1.4 GNUstep - NSCache 源码

1.4.1 GNUstep - NSCache 类源码

直接搜索 NSCache 来到这个类中.

@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;
#endif
#if     GS_NONFRAGILE
#else
  @private id _internal GS_UNUSED_IVAR;
#endif
}
复制代码

这里基本跟我们的认知差不多 , 值得一提的是 _objects 的内容是用 NSMapTable 管理的 .

1.4.2 setObject : forKey : cost

同样这个类中找到 setObject : forKey : cost 方法实现

- (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;
}
复制代码

简单概述一下 :

1.4.3 GNUstep - NSCache 机制总结

  • 1 : 先根据 key 查找有无旧值 , 有则先移除 , 后设置新值

  • 2 : 根据传过来的 cost 进行缓存淘汰 _evictObjectsToMakeSpaceForObjectWithCost ( 这个方法源码过长 , 我就不放了, 简单概述一下他的淘汰策略 , 大家结合源码方法来看 )

    • 2.1 : 先计算出需要驱逐的空间大小 : 总开销 + 本次 set 开销 - 限制的大小
    • 2.2 : 计算出了一个平均访问次数 averageAccesses = ((_totalAccesses / (double)count) * 0.2) + 1; 取平均数的百分之二十 , 用了一个二八定律 . 其实它的淘汰策略的根本原理也就是我们经常说的 LRU.
    • 2.3 : 循环处理 , 发送通知 ( discardContentIfPossible ) , 驱逐访问次数小于计算结果并且对象是可移除的 value. 直到达到上面计算出来的所需空间. 最后更新占用数等属性.
  • 3 : 创建一个新的 _GSCachedObject , 将属性赋值存储进去.

  • 4 : 将这个新创建的对象 set_objects ( NSMapTable ) 当中.

  • 5 : 总占用数更新.

1.5 Swift Foundation - NSCache 源码

swift foundation 这个是 Apple 开源的 Swift Foundation 库的源码 . 我们来看看它里面 NSCache 的淘汰策略.

同样 , 我们直接来到 NSCache.swift 中. 类中基本和我们熟知的大致相同 , 有一点需要提的就是:

SwiftNSCache_entries 是使用 Dictionary 来实现的 , 只不过它的 key value 分别是 NSCacheKeyNSCacheEntry<KeyType, ObjectType> . 类比 GNUstep , 数据结构上是一模一样, 只不过 GNUstep 使用了 NSMapTable 来存储 values.

1.5.1 key -- NSCacheKey

而这个作为 key 值的 NSCacheKey , 重写了 hashisEqual 两个方法 , 以此来定义 当前 key 的哈希值相等的条件 ( NSMapTable ).

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)
    }
}
复制代码

1.5.2 value -- NSCacheEntry

这个 NSCacheEntry 是一个双向链表的数据结构 , 另外存储了用户传进来的 keyvalue 以及所花费的空间大小.

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
    }
}
复制代码

1.5.3 设置新值

那么接下来我们同样来到赋值的方法.

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()
}
复制代码

方法很长 , 我没有做省略 , 方便没有下载的同学分析查看.

这里面有几个点需要提的 :

  • 1 . 首先和 GNUstep 中一样 , 先通过这个 key_entries 中取值 , 取到就代表有旧值 , 先更新这个对象中存储的 value 和内存消耗大小 , 然后先移除 . 再添加插入 ( 更新链表结构 , 另外插入的时候根据占用内存排了序 entry.cost > currentElement.cost ).
  • 2 . 接下来与 GNUstep 同样 , 根据 totalCostLimit 占用大小限制 计算出需要放逐的空间大小. ( var purgeAmount = (totalCostLimit > 0) ? (_totalCost - totalCostLimit) : 0
  • 3 . 通知代理回调 , 即将放逐对象
  • 4 . 更新总花费大小 _totalCost , 释放对象 , 更新链表结构.
  • 5 . 通过个数限制 countLimit 计算需要释放个数. ( var purgeCount = (countLimit > 0) ? (_entries.count - countLimit) : 0
  • 6 . 通知代理回调 , 即将放逐对象
  • 7 . 更新总花费大小 _totalCost , 释放对象 , 更新链表结构.

1.6 NSCache 总结

  • 通过 GNUstep 提供的源码 , 我们得知其对于 NSCache 的处理是计算出一个平均访问次数 , 然后释放的是访问次数较少的对象 , 直到满足需要释放大小 . LRU 的机制.
  • 通过 swift-corelibs-foundation 源码 , 我们得知其首先 , 存储链表结构中是按对象花费内存大小排序的 .
    • 然后首先通过用户有无指定 totalCostLimit 大小限制来依次释放 , ( 先释放占用较小的对象 ) , 直到满足需要释放大小 .
    • 然后再通过个数限制来释放 , 直到满足需要释放大小 ( 依旧是先释放较小的对象 ) .

至此 , NSCache 的淘汰策略和结构原理我们已经讲完 , 下篇博客会继续就 NSURLCache 以及 SDWebImage 中的处理机制讲解 .

如有错误 , 欢迎指正 .

如需转载请标明出处以及跳转链接 .