YYCache 学一下...

437 阅读15分钟

YYCache源码

YYCache设计思路

YYCache设计思路这篇文章非常推荐, 很早以前就读过, 每次看的理解都不一样, 这一篇当时作者比较了世面上主流的缓存框架, 然后在此基础上写出了该框架.

YYCache

api官方注释接口和NSChche基本一致:

/** 磁盘和内存的文件名和缓存名根据此路径存取*/
@property (copy, readonly) NSString *name;

/** 内存缓存类*/
@property (strong, readonly) YYMemoryCache *memoryCache;

/** 磁盘缓存类*/
@property (strong, readonly) YYDiskCache *diskCache;

/** 增,改*/
- (void)setObject:(nullable id<NSCoding>)object forKey:(NSString *)key;
/** 删*/
- (void)removeObjectForKey:(NSString *)key;
/** 查*/
- (BOOL)containsObjectForKey:(NSString *)key;
/** 取*/
- (nullable id<NSCoding>)objectForKey:(NSString *)key;

以上每种操作都有对应的block回调方法, 这里也写了必须存入实现NSCoding协议的对象才可以, 主要还是存入磁盘归档解档的时候必须如此.

1.基本上大部分的block都是为了对应文件存, 取, 删的时候做的回调处理.

2.所有的操作中都是以内存优先, 先操作内存在操作磁盘

YYMemoryCache

#pragma mark - Attribute
///=============================================================================
/// @name Attribute 属性
///=============================================================================

/** 缓存名字, 默认为nil */
@property (nullable, copy) NSString *name;

/** 当前对象缓存的总数量(只读) */
@property (readonly) NSUInteger totalCount;

/** 当前缓存的总大小(只读) */
@property (readonly) NSUInteger totalCost;

///=============================================================================
/// @name Limit 限制
///=============================================================================

/** 最大的缓存数量, 默认为NSUIntegerMax, 等同于不限制.  
    这只是一个大概的限制, 如果缓存超过限制, 一些缓存对象会在后台线程中快速清除 */
@property NSUInteger countLimit;

/** 
    执行移除策略前的最大缓存量.默认为DBL_MAX, 等同于不限制
    这不是一个明确的限制, 如果超过缓存限制, 一些缓存对象会在后台线程中快速清除 */
@property NSUInteger costLimit;

/**
    缓存对象的最大缓存时间, 默认为DBL_MAX, 等同于不限制
    这不是一个明确的限制, 如果超过缓存时间限制, 该对象会在后台线程中快速清除
 */
@property NSTimeInterval ageLimit;

/**
    自动检查时间间隔, 默认为5秒
    缓存内置了一个计时器, 用于不断检查缓存各种限制是否到达临界条件, 如果到达就会执行移除的策略
 */
@property NSTimeInterval autoTrimInterval;

/**
    当系统内存警告是否执行移除策略的设置, 默认为YES.
    内存警告执行的回调
*/
@property BOOL shouldRemoveAllObjectsOnMemoryWarning;
@property (nullable, copy) void(^didReceiveMemoryWarningBlock)(YYMemoryCache *cache);

/**
    当app进入后台时候, 是否执行移除策略的设置, 默认为YES.
    内存警告执行的回调
*/
@property BOOL shouldRemoveAllObjectsWhenEnteringBackground;
@property (nullable, copy) void(^didEnterBackgroundBlock)(YYMemoryCache *cache);

/**
    如果为YES, 则在主线程移除, 否则在后台线程. 默认为NO
    如果键值对中包含需要在主线程中移除的对象, 比如UIView/CALayer, 则可以设置为YES
*/
@property BOOL releaseOnMainThread;

/**
    如果为YES, 键值对将被移除删除, 避免了阻塞访问方法. 否则它将在访问方法中实现, 例如removeObjectForKey, 默认为YES
*/
@property BOOL releaseAsynchronously;

///=============================================================================
/// @name Trim
///=============================================================================

/**
    修剪后允许保留的最大数量. 
    利用LRU移除数据, 直到totalCount数量小于等于该值
*/
- (void)trimToCount:(NSUInteger)count;

/**
    修剪后允许保留的最大数据大小. 
    利用LRU移除数据, 直到totalCost小于等于该值
*/
- (void)trimToCost:(NSUInteger)cost;

/**
    数据最大保存时间限制.(秒)
    利用LRU移除数据, 直到所有到期的数据全部清除
*/
- (void)trimToAge:(NSTimeInterval)age;

内存缓存

作者原话:

YYMemoryCache 是我开发的一个内存缓存,相对于 PINMemoryCache 来说,我去掉了异步访问的接口,尽量优化了同步访问的性能,用 OSSpinLock 来保证线程安全。另外,缓存内部用双向链表和 NSDictionary 实现了 LRU 淘汰算法,相对于上面几个算是一点进步吧。

后续OSSpinLock由于不同线程访问同样资源导致的优先级反转问题, 作者也说了如果不能保证操作在同样优先级的线程中, 自旋锁基本上已经不可以用了. 在后续作者用pthread_mutex_t替换了OSSpinLock.

YYMemoryCache的访问方法

YYMemoryCache内部作者定义个一个双向链表_YYLinkedMap, 结构如下

/**
    YYMemoryCache内部定义_lru为_YYLinkedMap
*/
@implementation YYMemoryCache {
    pthread_mutex_t _lock;
    _YYLinkedMap *_lru;
    dispatch_queue_t _queue;
}
@end

/**
    _YYLinkedMap双向链表结构
*/
@interface _YYLinkedMap : NSObject {
    @package
    CFMutableDictionaryRef _dic; // 不要直接设值
    NSUInteger _totalCost;  //总数量
    NSUInteger _totalCount; //总大小
    _YYLinkedMapNode *_head; // MRU(头结点), 不要直接修改
    _YYLinkedMapNode *_tail; // LRU(尾部节点) 不要直接修改
    BOOL _releaseOnMainThread; //是否在主线程移除, 默认NO
    BOOL _releaseAsynchronously; //是否异步移除, 默认YES
}

@interface _YYLinkedMapNode : NSObject {
    @package /*
              @package变量,包内可以访问, 包外无法访问
              */
    __unsafe_unretained _YYLinkedMapNode *_prev; // retained by dic
    __unsafe_unretained _YYLinkedMapNode *_next; // retained by dic
    id _key;
    id _value;
    NSUInteger _cost;
    NSTimeInterval _time;
}
@end
  • 关于_YYLinkedMap的访问方法, 节点修改, 和之前lru部分的大差不差, 思路都是一致的. yy作者用的是dic和双向链表, dic控制查询, 双向链表的头结点永远都是最新访问的数据, 尾节点是最久未访问的数据. 移除策略就是从尾结点向前递归移除, 递归终止条件就是_totalCount或者_totalCost小于限制.

  • 内存缓存的代码量很少, 整体的结构是CFMutableDictionaryRef的对象_dic, key为存入的key, value为_YYLinkedMapNode的节点, _dic持有keys和values. 双向链表的每个节点的_prev和_next全部都不持有对应的node修饰权为__unsafe_unretained, 作者也在后面备注了retained by dic.

未命名文件(1).png 图中所有的虚线都是用__unsafe_unretained修饰, 并不持有对象并且每一个节点的指向要么为nil要么为一个节点, 所以不会存在访问野指针问题.

上文中NSCache是用, NSMapTable做的_dic的工作, NSMutableArray做的_YYLinkedMap的工作, 并且都持有了实际存储的对象.

YYMemoryCache的增删改查:

增: 如果不存在, YYMemoryCache的增加直接放入_dic, key和节点node. O(1)

改: 直接查找, 如果存在查找之后, 先移除然后放到头节点.整个过程都是O(1)

删: 删除就是直接查到key, 根据key移除掉_dic的键值对, 然后处理掉双向链表的前后节点.O(1)

查: 根据key可以直接用_dic查到或者查不到. O(1)

YYMemoryCache从上看, 只有_dic持有了一份keys和values, 双向链表没有持有. 所有的操作均为O(1), 确实优秀. 假想一下, 方案如果用NSMapTable做查询, values使用weak方案, 双向链表的持有每个节点也是可以, 可能比作者这种稍麻烦一点, 应该也可以实现.

这样的话, 或许直接使用_dic就可以实现上述的所有方案, 并不需要用双向链表. 双向链表的作用主要是用头结点代表最新操作的数据, 尾结点表示最久未操作的数据, 在内存满载或者发生内存警告时候,要从尾节点从后往前进行清理缓存的策略.

YYMemoryCache一共有三个成员变量

_lru: _YYLinkedMap类型的lru缓存方案对象

_queue: dispatch_queue_t类型的串行队列

_lock: pthread_mutex_t的互斥锁

_queue是用于所有的裁剪策略都保证在串行队列中实现, 每次的操作加上互斥锁保证线程安全.

_trimRecursively的实现方案是使用dispatch_after调用, 然后在_queue队列中延时5秒执行, 进行递归操作, 有兴趣的可以查查dispatch_after和performSelector以及计时器的区别.

内存缓存中的锁

作者用的是pthread_mutex替代了最早写的OSSpinLock的, 为了找到替代OSSpinLock的锁作者也是做了一系列的性能测试,结果就是单线程内除了 OSSpinLock 外,dispatch_semaphore 和 pthread_mutex 性能是最高的。原话为有消息称,苹果在新系统中已经优化了 pthread_mutex 的性能,所以它看上去和 OSSpinLock 差距并没有那么大了。所以作者使用了. 有兴趣可以去读, 看一看锁的测试代码也是不错的.

内存缓存中增删改查操作全部都上互斥锁了, 所以在访问或者多线程访问的时候不会出现数据错乱的问题,是线程安全的

内存缓存中的多线程

作者用GCD的串行队列, 关于队列的所有操作都是异步操作, 用于进行淘汰算法根据各种条件裁剪内存. 可查看_trimInBackground此方法.

lru中有一个属性是_releaseAsynchronousl默认为YES, _releaseOnMainThread默认为NO, 获取了一个dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);优先级低的全局队列, 然后在释放内存的时候可以控制是在主线程释放还是异步线程释放资源.非常值得学习的设计.

第一次看还以为是多余代码, 真的很尴尬啊...

- (void)_trimToCost:(NSUInteger)costLimit {
    BOOL finish = NO;
    pthread_mutex_lock(&_lock);
    if (costLimit == 0) {
        [_lru removeAll];
        finish = YES;
    } else if (_lru->_totalCost <= costLimit) {
        finish = YES;
    }
    pthread_mutex_unlock(&_lock);
    if (finish) return;
    
    NSMutableArray *holder = [NSMutableArray new];
    while (!finish) {
        if (pthread_mutex_trylock(&_lock) == 0) {
            if (_lru->_totalCost > costLimit) {
                _YYLinkedMapNode *node = [_lru removeTailNode];
                if (node) [holder addObject:node];
            } else {
                finish = YES;
            }
            pthread_mutex_unlock(&_lock);
        } else {
            usleep(10 * 1000); //10 ms
        }
    }
    //这里是处理release的queue, holder局部变量持有移除掉的node节点数据, 局部变量在栈区当内存被系统回收掉的时候, 对应的node也在指定的被回收
    if (holder.count) {
        dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
        dispatch_async(queue, ^{
            [holder count]; // release in queue
        });
    }
}

磁盘缓存

摘要

磁盘缓存大致分为基于文件读写、基于 mmap 文件内存映射、基于数据库三种。

  • TMDiskCache, PINDiskCache, SDWebImage 等缓存,都是基于文件系统的,即一个 Value 对应一个文件,通过文件读写来缓存数据。他们的实现都比较简单,性能也都相近,缺点也是同样的:不方便扩展、没有元数据、难以实现较好的淘汰算法、数据统计缓慢。

  • FastImageCache 采用的是 mmap 将文件映射到内存。用过 MongoDB 的人应该很熟悉 mmap 的缺陷:热数据的文件不要超过物理内存大小,不然 mmap 会导致内存交换严重降低性能;另外内存中的数据是定时 flush 到文件的,如果数据还未同步时程序挂掉,就会导致数据错误。抛开这些缺陷来说,mmap 性能非常高。

  • YYDiskCache 也是采用的 SQLite 配合文件的存储方式,在 iPhone 6 64G 上的性能基准测试结果见下图。在存取小数据 (NSNumber) 时,YYDiskCache 的性能远远高出基于文件存储的库;而较大数据的存取性能则比较接近了。但得益于 SQLite 存储的元数据,YYDiskCache 实现了 LRU 淘汰算法、更快的数据统计,更多的容量控制选项。

磁盘缓存的淘汰策略也是根据数据库的数据的last_access_time字段取出数据, 递归清除, 递归终止条件内存小于限制内存, 和内存缓存基本一致.

个人整体把框架看下来, 最直观的感受就是强大的计算机基本功对程序的影响, 里面很多细节的使用, 不同的场景对于锁和多线程的调研和使用, 还有在磁盘缓存对于数据库了解的深度都是一般开发人员不具备的.所以尽量理解整体的设计, 框架可以直接学习写, 细节也可以模仿了解其中的原理, 慢慢就会变得更好.

YYDiskCache

YYDiskCache整个流程是YYDiskCache存储对象的value大于_inlineThreshold(20kb)的时候会创建文件写入文件数据库写入文件名, 否则数据库直接写入二进制数据.

写入本地文件的数据会进行相应的归档和解档.

YYKVStorage

YYKVStorage是数据库存储key和内部封装的YYKVStorageItem对象基本上是对应数据库存储键值, 其中有最后访问时间, 数据大小等变量, 在执行移除策略的时候通过数据库直接筛选出来数据获取到对应的数据(一般是排序好后的数组)根据递归条件, 进行递归移除.

  • 如果数据大于20kb, YYKVStorageItem对象filename存储文件名, 使用YYDiskCache默认文件名都是_YYNSStringMD5加密过的128位的二进制, 16个字节的形式表现.
  • 如果数据小于等于20kb, 数据库直接写入二进制数据读写性能更高存在inline_data字段中. 关于数据库性能测试的代码和网站作者的文章中都有.

YYDiskCache所有的访问方法, 都是对应的YYKVStorage进行数据的操作的.YYDiskCache就像是一个入口, YYKVStorage是直接操作的类. YYKVStorage也可以单独使用.

好的实用设计

磁盘缓存中的锁

作者使用的是dispatch_semaphore初始化信号量为1的操作, 以下为原话:

OSSpinLock 自旋锁,性能最高的锁。原理很简单,就是一直 do while 忙等。它的缺点是当等待时会消耗大量 CPU 资源,所以它不适用于较长时间的任务。对于内存缓存的存取来说,它非常合适。(后续由于安全问题被替换为了pthread_mutex)

dispatch_semaphore 是信号量,但当信号总量设为 1 时也可以当作锁来。在没有等待情况出现时,它的性能比 pthread_mutex 还要高,但一旦有等待情况出现时,性能就会下降许多。相对于 OSSpinLock 来说,它的优势在于等待时不会消耗 CPU 资源。对磁盘缓存来说,它比较合适。

磁盘缓存中的多线程

磁盘缓存中有一个异步队列, 主要是用于访问方法block回调的操作, 均为异步并发, 操作属性都有上锁所以多线程访问也是线程安全的.没有block的方法调用操作, 均为在当前线程.

NSMapTable做YYDiskCache对象的管理者
  • 获取YYDiskCache对象, 作者使用了一个静态变量类型为NSMapTable的对象. _globalInstances的key为path, value为YYDiskCache实例, 可以保证同样路径的对象内存中只存在一份, _globalInstances的key用strong, values用weak.

  • 在_globalInstances做set和get的时候用的dispatch_semaphore作为锁保护数据访问安全. 是很棒的设计, 比如有一些单例就可以这样来做, 从内存各方面来讲处理的更好.

static NSMapTable *_globalInstances;
static dispatch_semaphore_t _globalInstancesLock;

static void _YYDiskCacheInitGlobal() {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _globalInstancesLock = dispatch_semaphore_create(1);
        _globalInstances = [[NSMapTable alloc] initWithKeyOptions:NSPointerFunctionsStrongMemory valueOptions:NSPointerFunctionsWeakMemory capacity:0];
    });
}
//获取YYDiskCache对象, _globalInstances的key为path, value为YYDiskCache实例
static YYDiskCache *_YYDiskCacheGetGlobal(NSString *path) {
    if (path.length == 0) return nil;
    _YYDiskCacheInitGlobal();
    dispatch_semaphore_wait(_globalInstancesLock, DISPATCH_TIME_FOREVER);
    id cache = [_globalInstances objectForKey:path];
    dispatch_semaphore_signal(_globalInstancesLock);
    return cache;
}
//储存
static void _YYDiskCacheSetGlobal(YYDiskCache *cache) {
    if (cache.path.length == 0) return;
    _YYDiskCacheInitGlobal();
    dispatch_semaphore_wait(_globalInstancesLock, DISPATCH_TIME_FOREVER);
    [_globalInstances setObject:cache forKey:cache.path];
    dispatch_semaphore_signal(_globalInstancesLock);
}


初始化的时候
- (instancetype)initWithPath:(NSString *)path
             inlineThreshold:(NSUInteger)threshold {
    self = [super init];
    if (!self) return nil;
    
    YYDiskCache *globalCache = _YYDiskCacheGetGlobal(path);
    if (globalCache) return globalCache;
    //此处省去中间代码, 属性设置//
    _YYDiskCacheSetGlobal(self);
    return self;
}

存数据时比较核心的逻辑 截屏2021-04-14 上午10.45.29.png 在数据库中的表现为filename下图:

大数据.png 大数据所在文件夹如下图:

大数据文件夹.png

小数据所在数据库存储为inline_data的二进制对象 如下图:

小数据.png

删除的主要还是根据YYKVStorage数据库设计, 通过sql查找到对应的item进行处理.

截屏2021-04-14 上午11.18.10.png

removeAllItemsWithProgressBlock:endBlock方法, 查找出来的数据是按时间升序排列item的数组, 所以在移除过程中, 就算移除失败了, 留下的依旧的和lru算法一样的最新使用的数据, 是一种很好的细节响应.

removeAllItems方法就稍微暴力一点, 直接重置数据库. 对于删除部分, 有兴趣的同学可以自己去看看源码sql的具体实现

getItemForKey和getItemInfoForKey都是查询的方法, 具体的区别就是

  • getItemForKey没有扩展数据, 数据库中extended_data字段值为NULL
  • getItemInfoForKey有扩展数据, 数据库中inline_data字段值为NULL

取值的细节和逻辑和内存缓存中基本一致, 好的一点是数据库直接根据sql对应的字段可以获取到对应的数据, 基本上和内存中的双向链表的功能一致.从而实现了磁盘缓存的lRU

总结

YYCache中其实有很多很好的细节设计, 每次看源码都会发现不同的细节处理, 例如单单一个简单的双向链表inserttohead方法, 如果不是做过十几遍这种头插尾插, 一次性把逻辑细节写全基本上不可能.

个人认为的难点:

个人认为YYCache的难点在于YYKVStorage, 对于数据库不熟悉和c基础不牢固的人来说写出YYKVStorage基本上不可能.所以最好便捷的方法就是学习和借鉴, 就算写不了, 最起码拿过来可以知道怎么改动使用.

第二点就是框架设计能力了, 这个需要强大的基础和大量的练习磨练出来的, 没有任何捷径可言, 骨骼惊奇者请忽略.

个人认为的重点:

  1. YYCache的设计思路, 设计之前和市面上主流框架应用的调研和对比, 了解了所有的优劣性之后,从而决定自己的框架设计的方向.
  2. 看完YYCache的实现之后, 很多不同地方的细节, 展现出的强大的结构设计能力和思想.其次是, 多个文件对外暴露的api和接口属性基本一致, 而且每一个类你都可以单独拿出来使用, 并不影响整体.
  3. 对于不同的缓存方式, 使用多线程和锁也有不同的选择和性能对比, 从而在测试之后选择结果最优的方案进行使用
  4. 对于磁盘缓存的调研和对比, 从而选择数据库磁盘结合使用的方案, 即便现在也很少有人能做到这种地步.
  5. 多看看作者性能测试的代码以及不同的网站调阅, 性能测试代码中也有很多不起眼的细节, 从而可以更深入的了解语言内部机制.

最后就是细节很多吧,尽量多看几遍, 每次一个不起眼的小细节都可能让你眼前一亮. YYCache应该算是YYKit框架里相比较下来最简单的了,慢慢学习, 不慌不忙.

有任何不错的地方, 欢迎指正.