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

常常據說 HTTP 緩存 , 磁盤緩存 , 內存緩存 , 等等 . 但卻搞不太清楚具體內容 ? 不要緊 , 這兩篇文章咱們一塊兒來探索一下 .git

1. NSCache

1.1 NSCache 定義與主要特色

  • NSCache 是蘋果官方提供的緩存類,具體使用和 NSMutableDictionary 相似,在 AFNSDWebImage 框架中被使用來管理緩存
  • 官方解釋 NSCache 在系統內存很低時,會自動釋放對象 ( 可是注意 , 這裏還有點文章 , 本文會講 )
  • NSCache 是線程安全的,在多線程操做中,不須要對 NSCache 加鎖
  • NSCacheKey 只是對對象進行 Strong 引用,不是拷貝,在清理的時候計算的是實際大小而不是引用的大小 , 其 key 不須要實現 NSCoping 協議. ( 這一點不太瞭解的同窗能夠類比 NSMapTable 去學習)

1.2 NSCache 中比較重要的屬性 & 方法

NSCache 中有幾個比較重要的屬性和方法 , 是你必需要了解的 :github

1.2.1 屬性

  • totalCostLimitswift

    • 總消耗大小 . 當超過這個大小時 NSCache 會作一個內存修剪操做 . 默認值爲0,表示沒有限制緩存

  • countLimit安全

    • 可以緩存的對象的最大數量。默認值爲0,表示沒有限制bash

  • 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 , 以便研究它的釋放機制和邏輯 .app

  • 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 中的處理機制講解 .

若有錯誤 , 歡迎指正 .

如需轉載請標明出處以及跳轉連接 .

相關文章
相關標籤/搜索