yycache中的细节

最近花了点时间看了下YYCache的源码,觉得其中的一些代码细节非常好,遂记录之,虽然这些知识可能被别人写烂了,但自己总结下总归是好的。

锁的使用

作者在YYMemoryCache中使用了C语言中的pthread_mutex_t来加锁解锁保证线程安全,而在YYDiskCache中使用dispatch_semaphore信号量dispatch_semaphore不算锁,但可以实现锁的功能。

YYMemoryCache中:

1
2
3
4
5
6
7
8
9
10
11
- (id)objectForKey:(id)key {
if (!key) return nil;
pthread_mutex_lock(&_lock);
_YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key));
if (node) {
node->_time = CACurrentMediaTime();
[_lru bringNodeToHead:node];
}
pthread_mutex_unlock(&_lock);
return node ? node->_value : nil;
}

YYDiskCache中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- (id<NSCoding>)objectForKey:(NSString *)key {
if (!key) return nil;
dispatch_semaphore_wait(self->_lock, DISPATCH_TIME_FOREVER);
YYKVStorageItem *item = [_kv getItemForKey:key];
dispatch_semaphore_signal(self->_lock);
if (!item.value) return nil;
id object = nil;
if (_customUnarchiveBlock) {
object = _customUnarchiveBlock(item.value);
} else {
@try {
object = [NSKeyedUnarchiver unarchiveObjectWithData:item.value];
}
@catch (NSException *exception) {
// nothing to do...
}
}
if (object && item.extendedData) {
[YYDiskCache setExtendedData:item.extendedData toObject:object];
}
return object;
}

在YYMemoryCache中,作者原先是使用自旋锁OSSpinLock来实现线程安全的,原理就是do while 忙等,缺点是等待时间会消耗大量 CPU 资源,所以它不适用于较长时间的任务,固在YYDiskCache中用dispatch_semaphoredispatch_semaphore优势在于等待时不会消耗 CPU 资源。对磁盘缓存来说,它比较合适。

但是由于OSSpinLock有安全问题,如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,它会处于 spin lock 的忙等状态从而占用大量 CPU。此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成、无法释放 lock。具体见不再安全的 OSSpinLock。所以就换成了pthread_mutex_lock互斥锁。

GCD中使用__weak

1
2
3
4
5
6
7
8
9
- (void)_trimRecursively {
__weak typeof(self) _self = self;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_autoTrimInterval * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
__strong typeof(_self) self = _self;
if (!self) return;
[self _trimInBackground];
[self _trimRecursively];
});
}

这是YYDiskCache中的一段代码,目的是循环调用_trimInBackground方法来检查磁盘中的存储是否大于限制,大于限制的话就删除数据直到符合限制。这里作者使用了__weak来防止Block持有self。刚开始看到这句代码,我也不理解为什么要使用__weak,按理来说GCD中Block执行完后会释放self,不会存在内存泄露,在issues也有关于这个问题的讨论。看了issues中的讨论,后来想了想,因为这是个递归调用,自己调用自己。

1
if (!self) return;

假如block中的self一直有值,那么这个循环会一直调用下去。那什么情况下block中self为nil呢。请看:

1
2
YYCache *cache = [[YYCache alloc] initWithPath:path];
[cache setObject:@"ZhangSan" forKey:@"name"];

假如我们在ViewController中使用YYCache进行存储数据,但ViewController并没有持有YYCache实例,那么这两行代码执行完,cache这个对象就释放了,这是完美的结果。

但是假如在上述的源码里,没有使用__weak,而是直接使用了self,即使ViewController没有持有YYCache实例,cache这个对象也不会释放,YYDiskCache中的dealloc方法一直不会调用。因为Block一直在持有cache对象,并且这个方法是递归方法,所以就发生了“假的内存泄露”。

既然我们在ViewController中没有强引用cache,就希望使用它后自己释放掉,所以使用了__weak。假如在ViewController中强引用cache对象,那么使不使用__weak都是无关紧要的了。

1
2
3
4
5
@interface ViewController ()
@property (nonatomic, strong) YYCache *cache;
@end

另外提一点,因为YYCache中可以自己控制内存占用大小,磁盘占用大小,即超过限制自动删除尾部数据。假如我们不需要这个功能的时候,即对缓存大小无限制,可以注释掉相关检查操作的代码,因为它每隔一段时间(YYDiskCache默认为60s,YYMemoryCache默认为5s)会调用方法自动检查并操作数据,算是一个小优化吧。

UIAppliaction对象

1
2
3
4
5
6
7
8
9
10
11
12
13
static UIApplication *_YYSharedApplication() {
static BOOL isAppExtension = NO;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class cls = NSClassFromString(@"UIApplication");
if(!cls || ![cls respondsToSelector:@selector(sharedApplication)]) isAppExtension = YES;
if ([[[NSBundle mainBundle] bundlePath] hasSuffix:@".appex"]) isAppExtension = YES;
});
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
return isAppExtension ? nil : [UIApplication performSelector:@selector(sharedApplication)];
#pragma clang diagnostic pop
}

上述代码是获得当前UIAppliaction对象,里面判断了当在App Extension里返回nil。假如是我们自己设计一个SDK给别人使用,会不会注意到这些细节呢?

指定queue中子线程中释放对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
NSMutableArray *holder = [NSMutableArray new];
while (!finish) {
if (pthread_mutex_trylock(&_lock) == 0) {
if (_lru->_tail && (now - _lru->_tail->_time) > ageLimit) {
_YYLinkedMapNode *node = [_lru removeTailNode];
if (node) [holder addObject:node];
} else {
finish = YES;
}
pthread_mutex_unlock(&_lock);
} else {
usleep(10 * 1000); //10 ms
}
}
if (holder.count) {
dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
dispatch_async(queue, ^{
[holder count]; // release in queue
});
}

在当前线程中创建一个NSMutableArray,然后将要删除释放的对象添加到数组中,再在子线程中调用count方法,这样就达到了在子线程中释放对象的目的了。

原因:当把将要删除的对象添加到array中,此时array不会释放,array中的对象也不会释放,因为在另外的线程中又调用了count方法,持有了array。当block执行完,就会释放持有的array,也达到了释放删除对象的目的,即在指定的queue中子线程释放对象。(说实话,看源码之前从来没有想到过这个需求,在指定的queue中释放对象,汗颜!)

双向链表的选择

在YYMemoryCache中使用了双向链表来实现LRU的策略缓存,不止iOS平台,在其他平台,安卓,Linux等实现LRU算法都是使用双向链表+Map的方法。使用Map是为了更快找到要删除或移动的节点,链表是将经常使用的数据放到头部节点,删除的时候删除尾部节点的数据。一开始我想,这个明明用单向链表+Map就能实现,为什么要使用更加复杂的双线链表呢?

后来我又对比了双向链表与单向链表的的不同,单向链表每个节点只知道下个节点的内存地址,而不知道它上个节点的地址,双向链表既知道下个节点的地址,也知道上个节点的地址。正是因为这个原因,当我们要移动某个节点,比如移动中间的节点使它作为头部节点,假如用的是单向链表,因为它不知道它的上一个节点,所以还要遍历整个链表,找到上个节点,然后才能衔接起来。而双向链表不用遍历整个链表,它自己就知道它的上个节点地址。虽然双向链表更加复杂了一点,但是带来的好处,对性能的提升也是不言而喻的。

接口的设计

YYCache里的接口及架构设计可以说是简洁明了,YYCache持有YYMemoryCache和YYDiskCache,两者分别负责内存缓存及磁盘缓存,两者之间互不影响。再来看下相关的api

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@interface YYCache : NSObject
@property (copy, readonly) NSString *name
@property (strong, readonly) YYMemoryCache *memoryCache
@property (strong, readonly) YYDiskCache *diskCache
- (nullable instancetype)initWithName:(NSString *)name
- (nullable instancetype)initWithPath:(NSString *)path
+ (nullable instancetype)cacheWithName:(NSString *)name
- (BOOL)containsObjectForKey:(NSString *)key
- (void)containsObjectForKey:(NSString *)key withBlock:(nullable void(^)(NSString *key, BOOL contains))block
- (nullable id<NSCoding>)objectForKey:(NSString *)key
- (void)objectForKey:(NSString *)key withBlock:(nullable void(^)(NSString *key, id<NSCoding> object))block
- (void)setObject:(nullable id<NSCoding>)object forKey:(NSString *)key
- (void)setObject:(nullable id<NSCoding>)object forKey:(NSString *)key withBlock:(nullable void(^)(void))block
- (void)removeObjectForKey:(NSString *)key
- (void)removeObjectForKey:(NSString *)key withBlock:(nullable void(^)(NSString *key))block
- (void)removeAllObjects
- (void)removeAllObjectsWithBlock:(void(^)(void))block
- (void)removeAllObjectsWithProgressBlock:(nullable void(^)(int removedCount, int totalCount))progress
endBlock:(nullable void(^)(BOOL error))end

可以说不用看注释,看方法名就可以直接知道该方法的作用,并且没有一个多余的方法。哪些方法该放在.h文件里声明给外部调用,哪些方法该放在.m文件里私有操作,都控制的完美无瑕。

YYCache里的接口及架构设计可以说是作为一个SDK的典范,并且代码也对每一个细节追求到极致。正因为设计的这么漂亮,源码读起来才会通俗易懂,让人赞叹👍!