做开发时,合理的利用缓存是非常重要的,一方面可以为用户减少访问流量,另一方面也能加快应用的访问速度, 这部分的缓存学习内容是基于 PINCache的, PINCache项目是在Tumblr 宣布不在维护 TMCache 后,由 Pinterest 维护和改进的基于TMCache的一个内存缓存,修复了TMCache存在的性能和死锁问题,可以说是有了一个较大的提升。
PINCache 是多线程安全的, 使用键值队来保存数据。PINCache中包含两个类, 一个是PINMemoryCache负责内存缓存,一个是PINDiskCache负责磁盘缓存,PINCache属于它们的上层封装,将具体的缓存操作交给它的两个对象属性(PINMemoryCache属性,PINDiskCache属性)当App接收到内存警告时,PINCache会清理掉所有的内存缓存。关于缓存部分我想用三节来说,分别对应PINMemoryCache,PINDiskCache, 最后通过PINCache总结整个流程。我是将PINCache的源码敲了一遍,基本都了解了,在这一遍下来也颇有心得,于是决定写着系列关于缓存的文章,我想以后还会有关于多线程,网络部分的吧,学习框架,多学习,多进步
我觉得从.m文件开始讲起,因为这是整个框架的核心部分而.h是方法调用。
先铺一下需要了解的知识:
内存缓存:一般使用字典来作为数据的缓存池,配合一个保存每个内存缓存数据的缓存时间的字典,一个保存每个内存缓存数据的缓存容量的字典,一个保存内存缓存总容量的变量。对于增删改查操作,基本也都是围绕着字典来的,需要重点注意的就是在这些个操作过程的多线程安全问题,还有同步和异步访问方法,以及异步方法中的Block参数的循环引用问题。
-
线程安全:现在iPhone早已步入多核时代,多核就会产生并发操作,并发操作会遇到读写问题,比如去银行取款,取款时卡内余额显示1000,你决定取1000,当你进行取款操作的时候,你的家人往你卡上打了2000,假设取款操作先结束那么保存卡内余额的值会变成3000,如果存款操作先完成,那么取完款之后卡内余额变成了0, 所以会产生问题,这个时候我们就需要加锁操作,当执行读写,写写操作不能同时进行,必须要加同步锁,确保线程安全,同一时间只能有一条线程执行相应的操作。具体看框架中的代码:
// 代码加锁 - (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost { [self lock]; // 缓存数据 _dictionary[key] = object; [self unlock]; } // 代码不加锁 - (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost { // 缓存数据 _dictionary[key] = object; }
因为函数体在内存中是一片固定的内存区域,任何时间可以被任意线程访问,假设t1 线程 Thread1访问, 需要保存的值为 object1, key1,此时 Thread2访问值为 object2, key2,因为 Thread1未执行完函数,所以此时在函数内就有四个参数值 key1, object1, key2, object2,然后同时执行_dictionary[key] = object(_dictionary 为NSMutableDictionary不是线程安全)这条语句, 所以可能会出现_dictionary[key1] = object2 的问题; 如果进行加锁操作,当 Thread1未执行结束时, Thread2是无法执行_dictionary[key] = object 这条语句的。注意我们日常开发实在主线程中进行,很少涉及多线程问题。
-
锁:在PINCache中使用的是信号量来实现同步锁,具体代码如下:
@property (strong, nonatomic) dispatch_semaphore_t lockSemaphore; - (void)lock { dispatch_semaphore_wait(_lockSemaphore, DISPATCH_TIME_FOREVER); } - (void)unlock { dispatch_semaphore_signal(_lockSemaphore); }
我自己的代码中用的是pthread,效率能比信号量加锁稍微高一点点
缓存策略:有优先删除缓存最久,最少使用的策略,也有优先删除,容量最大,最少使用的策略。
临界区: 当访问一个公共资源时,而这些公共资源无法被多个线程同时访问,当一条线程进入临界区时, 其他线程必须等待,公用资源是互斥的
共享资源: 一个类中的属性, 成员变量全局变量就是这个对象的共享资源, 无论有多少个线程访问该对象, 访问的属性全局变量成员变量都是同一块内存区域, 不会因为线程不同创建不同的内存区域. 所以对于多线程操作的问题要将共享区域的取值, 设置值操作加锁
内存缓存我们要用个字典来存放数据,用个字典存放条数据的容量,用个字典来存放每条数据的最后的修改时间
/**
* 缓存数据, key可以为 URL, value 为网络数据
*/
@property (nonatomic, strong) NSMutableDictionary *dictionary;
/**
* 每个缓存数据的最后访问时间
*/
@property (nonatomic, strong) NSMutableDictionary *dates;
/**
* 记录每个缓存的花费
*/
@property (nonatomic, strong) NSMutableDictionary *costs;
同样我们还希望当通过GCD异步操作时为我们的缓存过程单独有个线程名
#if OS_OBJECT_USE_OBJC // iOS 6 之后 SDK 支持 GCD ARC, 不需要再 Dealloc 中 release
@property (nonatomic, strong) dispatch_queue_t concurrentQueue;
#else
@property (nonatomic, assign) dispatch_queue_t concurrentQueue;
#endif
锁,锁,锁重要的事说3遍
@implementation WNMemoryCache {
pthread_mutex_t _lock;
}
#define Lock(_lock) (pthread_mutex_lock(&_lock))
#define Unlock(_lock) (pthread_mutex_unlock(&_lock))
初始化方法
- (instancetype)init {
if (self = [super init]) {
1.
NSString *queueName = [NSString stringWithFormat:@"%@.%p",WannaMemoryCachePrefix,self];
// 以指定的名称, 创建并发队列, 用于异步缓存数据
_concurrentQueue = dispatch_queue_create([queueName UTF8String], DISPATCH_QUEUE_CONCURRENT);
2.
_removeAllObjectOnMemoryWoring = YES;
_removeAllObjectOnEnteringBackground = YES;
3.
_dictionary = [NSMutableDictionary dictionary];
_dates = [NSMutableDictionary dictionary];
_costs = [NSMutableDictionary dictionary];
4.
_willAddObjectBlock = nil;
_willRemoveObjectBlock = nil;
_willRemoveAllObjectsBlock = nil;
_didAddObjectBlock = nil;
_didRemoveObjectBlock = nil;
_didRemoveAllObjectsBlock = nil;
_didReceiveMemoryWarningBlock = nil;
_didEnterBackgroundBlock = nil;
5.
_ageLimit = 0.0;
_costLimit = 0;
_totalCost = 0;
6.
#if __IPHONE_OS_VERSION_MIN_REQUIRED >= __IPHONE_4_0 && !TARGET_OS_WATCH
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(didReceiveEnterBackgroundNotification:)
name:UIApplicationDidEnterBackgroundNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(didReceiveMemoryWarningNotification:)
name:UIApplicationDidReceiveMemoryWarningNotification
object:nil];
#endif
}
return self;
}
- 指定并发队列的名称
- 默认进入后台,收到内存警告时清理所有的内存缓存这个时候需要监听内存警告 name:UIApplicationDidReceiveMemoryWarningNotification和进入后台 name:UIApplicationDidEnterBackgroundNotification的通知 第6步
- 将缓存数据,时间,消耗的字典进行初始化,直接访问属性能够避免通过self调用get方法时消息发送时间的花费
- 定义的回调函数,并初始化为空
- _ageLimit 缓存存活时间, 如果设置为一个大于0的值, 就被开启为 TTL 缓存(指定存活期的缓存),即如果 ageLimit > 0 => ttlCache = YES;
_constLimit 内存花费限制,_totalConst 总的内存缓存消耗
这里要说一下TTLCache,当一个Ceche被设置为TTLCache,那么它的存活时间只有指定的时长ageLimit,当它存活的时间超过ageLimit时会被清理。在PINCache中设置ageLimit并未将TTLCache设置称为YES,但是通过阅读PINCache源码发现,只有设置好ageLimit,TTLCache才能在一定的时间限制内清空过期缓存,而设置ageLimit时就说明缓存有了存活周期,所以此时一定是TTLCache;(如理解有误欢迎指正)
@property (strong, readonly) __nonnull dispatch_queue_t concurrentQueue;
/** 内存缓存所占的总容量*/
@property (assign, readonly) NSUInteger totalCost;
@property (assign) NSUInteger costLimit;
/**
* 缓存存活时间, 如果设置为一个大于0的值, 就被开启为 TTL 缓存(指定存活期的缓存),即如果 ageLimit > 0 => ttlCache = YES;
*/
@property (assign) NSTimeInterval ageLimit;
/**
* 如果指定为 YES, 缓存行为就像 TTL 缓存, 缓存只在指定的存活期(ageLimit)内存活
* Accessing an object in the cache does not extend that object's lifetime in the cache
* When attempting to access an object in the cache that has lived longer than self.ageLimit,
* the cache will behave as if the object does not exist
*/
@property (assign, getter=isTTLCache) BOOL ttlCache;
/** 是否当内存警告时移除缓存, 默认 YES*/
@property (assign) BOOL removeAllObjectOnMemoryWoring;
/** 是否当进入到后台时移除缓存, 默认 YES*/
@property (assign) BOOL removeAllObjectOnEnteringBackground;
@property (copy) WNMemoryCacheObjectBlock __nullable willAddObjectBlock;
@property (copy) WNMemoryCacheObjectBlock __nullable willRemoveObjectBlock;
@property (copy) WNMemoryCacheObjectBlock __nullable didAddObjectBlock;
@property (copy) WNMemoryCacheObjectBlock __nullable didRemoveObjectBlock;
@property (copy) WNMemoryCacheBlcok __nullable willRemoveAllObjectsBlock;
@property (copy) WNMemoryCacheBlcok __nullable didRemoveAllObjectsBlock;
@property (copy) WNMemoryCacheBlcok __nullable didReceiveMemoryWarningBlock;
@property (copy) WNMemoryCacheBlcok __nullable didEnterBackgroundBlock;
这里并没有指定为nonatomic,所以就是默认的atomic,atomic是原子属性,线程安全。atomic和nonatomic用来决定编译器生成的getter和setter是否为原子操作。在多线程环境下,原子操作是必要的,否则有可能引起错误的结果。
这样的话setter/getter会变成下面的样式,添加线程安全
- (BOOL)isTTLCache {
BOOL isTTLCache;
[self lock];
isTTLCache = _ttlCache;
[self unlock];
return isTTLCache;
}
- (void)setTtlCache:(BOOL)ttlCache {
[self lock];
_ttlCache = ttlCache;
[self unlock];
}
getter实现创建一个局部变量用于在临界区内获得对象内部的属性值,setter在临界区内设置属性
/**
* 收到内存警告操作
*/
- (void)didReceiveMemoryWarningNotification:(NSNotification *)notify {
1.
if (self.removeAllObjectOnMemoryWoring) {
[self removeAllObject:nil];
}
__weak typeof(self)weakSelf = self;
AsyncOption(
__strong typeof(weakSelf)strongSelf = weakSelf;
if (!strongSelf) return ;
2.
Lock(_lock);
WNMemoryCacheBlcok didReceiveMemoryWarningBlock = strongSelf->_didReceiveMemoryWarningBlock;
Unlock(_lock);
3.
if (didReceiveMemoryWarningBlock) {
didReceiveMemoryWarningBlock(strongSelf);
}
);
}
- 如果指定了当收到内存警告时清理缓存,执行 removeAllObject 方法
- 加锁获得当前线程指定的_didReceiveMemoryWaringBlock 回调
- 执行回调
这里要说一下, 为什么只在第二步中加锁,第三步没有加锁;
先指定个假设前提回调是个超级耗时操作, 并且现在函数被两条线程访问,
(1). 如果没有锁当线程1获得didReceiveMemoryWarningBlock,这个时候 CPU 调度到线程2,由于didReceiveMemoryWarningBlock在线程1中已经获得,所以在线程2中执行的起始是线程1中的回调,导致回调不正确;
(2). 如果将第三步也加锁,线程1执行到第二步,加锁,获得回调并执行,依旧是当线程1执行到第二步时, CPU 调度到线程2,此时线程2执行发现线程1加锁操作,导致线程2等待,线程1安全执行线程1的回调,而回调是一个假设耗时10000s 的操作,导致线程2需要等待10000s, 效率低下;
(3).上述加锁方式执行的话,获得回调函数是线程安全, 线程1获得线程1中的回调, 线程2获得线程2中的回调, 所以即使在执行回调时进行 CPU 调度,那么线程1依旧执行的是线程1的回调,线程2执行线程2的回调,提高了效率,又避免安全性
所以,加锁可以避免线程问题,但盲目加锁会造成效率执行低下
/**
* 程序进入后台操作
*/
- (void)didReceiveEnterBackgroundNotification:(NSNotification *)notify {
if (self.removeAllObjectOnEnteringBackground) {
[self removeAllObject:nil];
}
__weak typeof(self)weakSelf = self;
AsyncOption(
__strong typeof(weakSelf)strongSelf = weakSelf;
if (!strongSelf) return ;
Lock(_lock);
WNMemoryCacheBlcok didEnterBackgroundBlock = strongSelf->_didEnterBackgroundBlock;
Unlock(_lock);
if (didEnterBackgroundBlock) {
didEnterBackgroundBlock(strongSelf);
}
);
}
函数体与执行内存警告的函数体相同, 就是回调方法不同。
继续往下看:
/**
* 线程安全, 移除指定 key 的缓存, 并执行回调
*
* @param key 指定的缓存 key
*/
- (void)removeObjectAndExectureBlockForKey:(NSString *)key {
1.
Lock(_lock);
id object = _dictionary[key];
NSNumber *cost = _costs[key];
WNMemoryCacheObjectBlock willRemoveObjectBlock = _willRemoveObjectBlock;
WNMemoryCacheObjectBlock didRemoveObjectBlcok = _didRemoveObjectBlock;
Unlock(_lock);
2.
if (willRemoveObjectBlock) {
willRemoveObjectBlock(self, key, object);
}
3.
Lock(_lock);
if (cost) {
_totalCost -= [cost unsignedIntegerValue];
}
[_dictionary removeObjectForKey:key];
[_costs removeObjectForKey:key];
[_dates removeObjectForKey:key];
Unlock(_lock);
4.
if (didRemoveObjectBlcok) {
didRemoveObjectBlcok(self, key, object);
}
}
1 . 加锁获得对应 key 存储的对象, 消耗, 及制定的回调
2 . 执行将要移除的回调, 与第四步形成呼应
3 . 如果存储该缓存存在花费, 从总花费中减去该该缓存的花费, 移除 key 对应的缓存对象, 花费以及最后修改的时间,而这些操作是要放在一片临界区内的
/**
* 使所有的缓存时间 <= date
*
* @param date 指定的缓存时间
*/
- (void)trimMemoryToDate:(NSDate *)date {
1.
Lock(_lock);
NSArray *sortKeyByDate = (NSArray *)[[_dates keysSortedByValueUsingSelector:@selector(compare:)] reverseObjectEnumerator];
Unlock(_lock);
2.
NSUInteger index = [self binarySearchEqualOrMoreDate:date fromKeys:sortKeyByDate];
3.
NSIndexSet *indexSets = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, index)];
4.
[sortKeyByDate enumerateObjectsAtIndexes:indexSets
options:NSEnumerationConcurrent
usingBlock:^(NSString *key, NSUInteger idx, BOOL * _Nonnull stop) {
5.
if (key) {
[self removeObjectAndExectureBlockForKey:key];
}
}];
}
这个方法我做了些修改, 在 PINCache 中,第一步按时间排序, 第二部从头开始遍历, 将时间 < 指定 date 的值移除, 我觉得当数据量很大时, 遍历的效率低下, 于是我写了个二分搜索, 搜索第一个大于等于 date 的位置, 所以我在第一步将排序结果进行倒转, 小的在前,大的在后
- 对_dates根据 key 排序, 排序结果是时间大的在前面, 比如20150101 在 20141230前面; 之后执行数组倒转, 小的在前, 大的在后
- 二分搜索算法, 搜索第一个大于等于指定 date 的位置
- 创建区间[0, index)
- 变量区间, 如果有 key, 就将其从缓存中移除, 并执行指定的"移除数据的回调"
/**
* 根据缓存大小移除缓存到临界值, 缓存大的先被移除
*
* @param limit 缓存临界值
*/
- (void)trimToCostLimit:(NSUInteger)limit {
// 1.
__block NSUInteger totalCost = 0;
// 2.
Lock(_lock);
totalCost = _totalCost;
NSArray *keysSortByCost = [_costs keysSortedByValueUsingSelector:@selector(compare:)];
Unlock(_lock);
// 3.
if (totalCost <= limit) {
return ;
}
// 4.
[keysSortByCost enumerateObjectsWithOptions:NSEnumerationReverse
usingBlock:^(NSString *key, NSUInteger idx, BOOL * _Nonnull stop) {
[self removeObjectAndExectureBlockForKey:key];
Lock(_lock);
totalCost = _totalCost;
Unlock(_lock);
if (totalCost <= limit) {
*stop = YES;
}
}];
}
- 设置局部变量, 负责记录在移除的过程中总花费的变化
- 加锁获取公共资源
- 如果当前的总花费小于限制值,直接返回
- 执行移除缓存操作, 从大到小逐个移除, 同时加锁修改总花费, 当总花费小于限制时, 停止移除操作
/**
* 递归检查并清除超过规定时间的缓存对象, TTL缓存操作
*/
- (void)trimToAgeLimitRecursively {
Lock(_lock);
NSTimeInterval ageLimit = _ageLimit;
BOOL ttlCache = _ttlCache;
Unlock(_lock);
if (ageLimit == 0.0 || !ttlCache) {
return ;
}
// 从当前时间开始, 往前推移 ageLimit(内存缓存对象允许存在的最大时间)
NSDate *trimDate = [NSDate dateWithTimeIntervalSinceNow:-ageLimit];
// 将计算得来的时间点之前的数据清除, 确保每个对象最大存在 ageLimit 时间
[self trimMemoryToDate:trimDate];
// ageLimit 之后在递归执行
__weak typeof(self)weakSelf = self;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(ageLimit * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
__strong typeof(weakSelf)strongSelf = weakSelf;
[strongSelf trimToAgeLimitRecursively];
});
}
这个方法我写了注释, 主要思路就是从当前时间为起点,往前推移一个设置的缓存存活时间, 这段时间段内的缓存应当被清理,然后 ageLimit 之后继续执行该方法, 同样清理这段时间里的缓存, 这是个递归调用, 每隔 ageLimit 时间请一次缓存
/**
* 移除所有的数据
*
* @param callBack 回调
*/
- (void)removeAllObject:(WNMemoryCacheBlcok)callBack {
__weak typeof(self)weakSelf = self;
// 异步移除所有数据
AsyncOption(
__strong typeof(weakSelf)strongSelf = weakSelf;
[strongSelf removeAllObjects];
if (callBack) {
callBack(strongSelf);
});
}
异步指定移除操作,将移除缓存方法放入到 GCD 的异步线程中
/**
* 线程安全的缓存对象的读取操作, 所有关于缓存读取的操作都是调用该方法
*
* @param key 要获得的缓存对应的 key
*
* @return 缓存对象
*/
- (__nullable id)objectForKey:(NSString *)key {
if (!key) {
return nil;
}
NSDate *now = [NSDate date];
Lock(_lock);
id object = nil;
/**
* 如果指定了 TTL, 那么判断是否指定存活期, 如果指定存活期, 要判断对象是否在存活期内
* 如果没有指定 TTL, 那么缓存对象一定存在, 直接获得
*/
if (!self->_ttlCache ||
self->_ageLimit <= 0 ||
fabs([_dates[key] timeIntervalSinceDate:now]) < self->_ageLimit) {
object = _dictionary[key];
}
Unlock(_lock);
if (object) {
Lock(_lock);
_dates[key] = now;
Unlock(_lock);
}
return object;
}
/**
* 线程安全的缓存存储操作, 所有的缓存写入都是调用该方法
*
* @param object 要缓存的对象
* @param key 缓存对象对应的 Key
* @param cost 缓存的代价
*/
- (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost {
if (!key || !object) {
return ;
}
// 加锁获得回调
Lock(_lock);
WNMemoryCacheObjectBlock willAddObjectBlock = _willAddObjectBlock;
WNMemoryCacheObjectBlock didAddObjectBlock = _didAddObjectBlock;
NSUInteger coseLimit = _costLimit;
Unlock(_lock);
// 执行回调
if (willAddObjectBlock) {
willAddObjectBlock(self, key, object);
}
// 加锁设置缓存信息
Lock(_lock);
_dictionary[key] = object, _costs[key] = @(cost), _dates[key] = [NSDate date];
_totalCost += cost;
Unlock(_lock);
// 执行回调
if (didAddObjectBlock) {
didAddObjectBlock(self, key, object);
}
// 如果设置花费限制, 判断此时总花费是否大于花费限制
if (coseLimit > 0) {
[self trimCostByDateToCostLimit:coseLimit];
}
}
/**
* 根据时间, 先移除时间最久的缓存, 直到缓存容量小于等于指定的 limit
* LRU(Last Recently Used): 最久未使用算法, 使用时间距离当前最就的将被移除
*/
- (void)trimCostByDateToCostLimit:(NSUInteger)limit {
__block NSUInteger totalCost = 0;
Lock(_lock);
totalCost = _totalCost;
NSArray *keysSortedByDate = [_dates keysSortedByValueUsingSelector:@selector(compare:)];
Unlock(_lock);
if (totalCost <= limit) {
return;
}
// 先移除时间最长的缓存, date 时间小的
[keysSortedByDate enumerateObjectsUsingBlock:^(NSString *key, NSUInteger idx, BOOL * _Nonnull stop) {
[self removeObjectAndExectureBlockForKey:key];
Lock(_lock);
totalCost = _totalCost;
Unlock(_lock);
if (totalCost <= limit) {
*stop = YES;
}
}];
}
执行缓存的设置和获取操作,最核心的是线程安全设置和获得, 异步也只是将线程安全的方法放入到异步线程, 在此不再赘述, 更多看源码, 有详细注释
还有两点要说:
PINCache 实现了下标脚本设置和获取方法, 即通过 id obj = cache[@"key"] 获得缓存值, cache[@"key"] = object设置缓存值.
具体步骤是两个协议方法
@required
/**
* 下标脚本的取值操作, 实现该方法, 可以通过下标脚本获得存储的缓存值
* 就像这样获得缓存值 id obj = cache[@"key"]
* @param key 缓存对象关联的 key
*
* @return 指定 key 的缓存对象
*/
- (id)objectForKeyedSubscript:(NSString *)key;
/**
* 下标脚本的设置值操作, 实现该方法可以通过下标脚本设置缓存
* 像这样 cache[@"key"] = object
* @param obj 要缓存的对象
* @param key 缓存对象关联的 key
*/
- (void)setObject:(id)obj forKeyedSubscript:(NSString *)key;
/**
* 以上两个方法应该确保线程安全
*/
MemoryCache 中具体实现
#pragma mark - Protocol Method
- (void)setObject:(id)obj forKeyedSubscript:(NSString *)key {
[self setObject:obj forKey:key withCost:0];
}
- (id)objectForKeyedSubscript:(NSString *)key {
return [self objectForKey:key];
}
设置和获得缓存都应该是线程安全的
还有一点就是由于我们设置属性为 atomic, 所以我们的 setter/getter 要确保线程安全, 具体上代码:
- (NSTimeInterval)ageLimit {
Lock(_lock);
NSTimeInterval age = _ageLimit;
Unlock(_lock);
return age;
}
- (void)setAgeLimit:(NSTimeInterval)ageLimit {
Lock(_lock);
_ageLimit = ageLimit;
if (ageLimit > 0) {
_ttlCache = YES;
}
Unlock(_lock);
[self trimToAgeLimitRecursively];
}
- (NSUInteger)costLimit {
Lock(_lock);
NSUInteger limit = _costLimit;
Unlock(_lock);
return limit;
}
- (void)setCostLimit:(NSUInteger)costLimit {
Lock(_lock);
_costLimit = costLimit;
Unlock(_lock);
if (costLimit > 0) {
[self trimCostByDateToCostLimit:costLimit];
}
}
以上就是内存缓存一些必要知识, 以上只是一部分代码, 具体看项目源码 WannaCache
感谢:
Amin706 PINCache
阳光飞鸟 atomic与nonatomic的区别