iOS源码解析—YYCache(YYMemoryCache)

概述

上一篇主要讲解了YYCache的文件结构,分析了YYCache类的相关方法,本章主要分析内存缓存类YYMemoryCache。该对象内部维护一个字典对象来存取缓存数据,同时支持缓存容量的限制,当缓存数据超过指定的内存容量大小的时候,会删除部分缓存数据。这主要是通过LRU算法实现的。

LRU

LRU全称是Least recently used,基于最近最少使用的原则,属于一种缓存淘汰算法。实现思路是维护一个双向链表数据结构,每次有新数据要缓存时,将缓存数据包装成一个节点,插入双向链表的头部,如果访问链表中的缓存数据,同样将该数据对应的节点移动至链表的头部。这样的做法保证了被使用的数据(存储/访问)始终位于链表的前部。当缓存的数据总量超出容量时,先删除末尾的缓存数据节点,因为末尾的数据最少被使用过。如下图:

2-1.png

YYMemoryCache内部维护了一个_YYLinkMap对象,_YYLinkMap对象负责实现缓存和LRU的功能。下面是代码注释:

@interface _YYLinkedMap : NSObject {
    @package
    CFMutableDictionaryRef _dic; //哈希字典,存放缓存数据
    NSUInteger _totalCost; //缓存总大小
    NSUInteger _totalCount; //缓存节点总个数
    _YYLinkedMapNode *_head; //头结点
    _YYLinkedMapNode *_tail; //尾结点
    BOOL _releaseOnMainThread; //在主线程释放
    BOOL _releaseAsynchronously;//在异步线程释放
}
@end

_dic是哈希字典,负责存放缓存数据,_head和_tail分别是双链表中指向头节点和尾节点的指针,链表中的节点单元是_YYLinkedMapNode对象,该对象封装了缓存数据的信息。

@interface _YYLinkedMapNode : NSObject {
    @package
    __unsafe_unretained _YYLinkedMapNode *_prev; //前向前一个节点的指针
    __unsafe_unretained _YYLinkedMapNode *_next; //指向下一个节点的指针
    id _key; //缓存数据key
    id _value; //缓存数据value
    NSUInteger _cost; //节点占用大小
    NSTimeInterval _time; //节点操作时间戳
}
@end

下面分析一下_YYLinkedMap对象的主要方法:

  1. insertNodeAtHead:方法

    该方法首先将需要新插入的数据节点存入字典中,以节点中的key作为字典的key。然后更新总大小_totalCost和节点总个数_totalCount,将节点置于链表头部。

    - (void)insertNodeAtHead:(_YYLinkedMapNode *)node {
        CFDictionarySetValue(_dic, (__bridge const void *)(node->_key), (__bridge const void *)(node)); //存入字典中
        _totalCost += node->_cost; //更新总大小
        _totalCount++; //更新总数
        if (_head) { //节点置于链表头部
            node->_next = _head;
            _head->_prev = node;
            _head = node;
        } else {
            _head = _tail = node;
        }
    }
    
  2. bringNodeToHead:方法

    该方法将节点移动至链表的头部,因为调用该方法的场景是节点已经存在于字典中,所以不需要新加入字典中。

    - (void)bringNodeToHead:(_YYLinkedMapNode *)node {
        if (_head == node) return;
        if (_tail == node) {
            _tail = node->_prev;
            _tail->_next = nil;
        } else {
            node->_next->_prev = node->_prev;
            node->_prev->_next = node->_next;
        }
        node->_next = _head;
        node->_prev = nil;
        _head->_prev = node;
        _head = node;
    }
    
  3. removeNode方法和removeTailNode方法

    removeNode方法将数据节点从字典和链表中删除,同时更新总大小_totalCost和节点总个数_totalCount。removeTailNode方法将链表中的尾部节点删除,同时从字典中删除节点。

  4. removeAll方法

    该方法删除链表中所有节点,同时从字典中删除所有节点。

YYMemoryCache

YYMemoryCache实现了内存缓存的功能,下面是其维护的成员变量:

pthread_mutex_t _lock;
_YYLinkedMap *_lru;
dispatch_queue_t _queue;

_lock是互斥所,当涉及多线程执行代码的情况下,通过pthread_mutex_lock(&_lock)方法给下面的代码块加互斥锁,这样其它线程会被阻塞,直到pthread_mutex_unlock(&_lock)被调用。如下:

pthread_mutex_lock(&_lock);
//代码块1
pthread_mutex_unlock(&_lock);

pthread_mutex_lock(&_lock);
//代码块2
pthread_mutex_unlock(&_lock);

线程A执行代码块1,线程B执行代码块2,如果线程A先执行代码块1,_lock被锁住,这样线程B被阻塞,直到线程A执行完代码块1后,调用pthread_mutex_unlock(_lock),线程B开始执行代码块2。由于执行缓存的操作很容易涉及到多线程调用,所以需要通过pthread_mutex_lock来控制,关于各种锁性能的测试,YYCache的作者ibireme大神在他的博客中进行了阐述。

_lru用来做数据缓存,实现了lru算法。下面分析一下YYMemoryCache主要的方法:

  1. 初始化

    调用init方法进行初始化,创建了lru对象,进行了一些参数设置,包括缓存节点个数限制,总cost限制,时间戳界限,进行边界检测的间隔时长等等。如下:

    - (instancetype)init {
        self = super.init;
        pthread_mutex_init(&_lock, NULL);
        _lru = [_YYLinkedMap new];
        _queue = dispatch_queue_create("com.ibireme.cache.memory", DISPATCH_QUEUE_SERIAL);
        _countLimit = NSUIntegerMax; 
        _costLimit = NSUIntegerMax;
        _ageLimit = DBL_MAX;
        _autoTrimInterval = 5.0;
        ...
        [self _trimRecursively];
        return self;
    }
    

    这些limit参数如果不设置,默认值都是最大,且这些参数以及_trimRecursively方法是用来做缓存空间的边界检测,在下文中提到。

  2. 存储数据

    调用setObject: forKey:方法存储缓存数据,代码如下:

    - (void)setObject:(id)object forKey:(id)key withCost:(NSUInteger)cost {
        if (!key) return;
        if (!object) {
            [self removeObjectForKey:key];
            return;
        }
        pthread_mutex_lock(&_lock); //上锁
        _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key)); //从字典中取节点
        NSTimeInterval now = CACurrentMediaTime();
        if (node) { //如果能取到,说明链表中之前存在key对应的缓存数据
             //更新totalCost 
             _lru->_totalCost -= node->_cost;
            _lru->_totalCost += cost;
            node->_cost = cost;
            node->_time = now; //更新节点的访问时间
            node->_value = object; //更新节点中存放的缓存数据
            [_lru bringNodeToHead:node]; //将节点移至链表头部
        } else { //如果不能取到,说明链表中之前不存在key对应的缓存数据
            node = [_YYLinkedMapNode new]; //创建新的节点
            node->_cost = cost;
            node->_time = now; //设置节点的时间
            node->_key = key; //设置节点的key
            node->_value = object; //设置节点中存放的缓存数据
            [_lru insertNodeAtHead:node]; //将新的节点加入链表头部
        }
        if (_lru->_totalCost > _costLimit) {
            dispatch_async(_queue, ^{
                [self trimToCost:_costLimit];
            });
        }
        if (_lru->_totalCount > _countLimit) {
            _YYLinkedMapNode *node = [_lru removeTailNode];
            ...
        }
        pthread_mutex_unlock(&_lock); //解锁
    }
    

    首先判断key和object是否为空,object如果为空,删除缓存中key对应的数据。然后从字典中查找key对应的缓存数据,分为两种情况,如果访问到节点,说明缓存数据存在,则根据最近最少使用原则,将本次操作的节点移动至链表的头部,同时更新节点的访问时间。如果访问不到节点,说明是第一次添加key和数据,需要创建一个新的节点,把节点存入字典中,并且加入链表头部。cost是指定的,默认是0。

  3. 访问数据

    调用objectForKey:方法访问缓存数据,代码注释如下:

    - (id)objectForKey:(id)key {
        if (!key) return nil;
        pthread_mutex_lock(&_lock);
        _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key)); //从字典中读取key相应的节点
        if (node) {
            node->_time = CACurrentMediaTime(); //更新节点访问时间
            [_lru bringNodeToHead:node]; //将节点移动至链表头部
        }
        pthread_mutex_unlock(&_lock);
        return node ? node->_value : nil;
    }
    

    该方法从字典中获取缓存数据,如果key对应的数据存在,则更新访问时间,根据最近最少使用原则,将本次操作的节点移动至链表的头部。如果不存在,则直接返回nil。

  4. 边界检测

    YYCache通过LRU算法处理缓存数据是否超出容量的情况。首先在初始化时,调用_trimRecursively方法,通过dispatch_after方法默认每隔5秒重新调用。下面是代码注释:

    - (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]; //递归调用本方法
        });
    }
    

    _trimInBackground分别调用_trimToCost、_trimToCount和_trimToAge方法检测。

    _trimToCost方法判断链表中所有节点占用大小之和totalCost是否大于costLimit,如果超过,则从链表末尾开始删除节点,直到totalCost小于等于costLimit为止。代码注释如下:

    - (void)_trimToCost:(NSUInteger)costLimit {
        BOOL finish = NO;
        ...
        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; //totalCost<=costLimit,检测完成
                }
                pthread_mutex_unlock(&_lock);
            } else {
                usleep(10 * 1000); //10 ms
            }
        }
        ...
    }
    

    其中每个节点的cost是人为指定的,默认是0,且costLimit默认是NSUIntegerMax,所以在默认情况下,_trimToCost方法不会删除末尾的节点。

    _trimToCount方法判断链表中的所有节点个数之和是否大于countLimit,如果超过,则从链表末尾开始删除节点,直到个数之和小于等于countLimit为止。代码注释如下:

    - (void)_trimToCount:(NSUInteger)countLimit {
        BOOL finish = NO;
        ...
        NSMutableArray *holder = [NSMutableArray new];
        while (!finish) {
            if (pthread_mutex_trylock(&_lock) == 0) {
                if (_lru->_totalCount > countLimit) {
                    _YYLinkedMapNode *node = [_lru removeTailNode]; //删除末尾节点
                    if (node) [holder addObject:node];
                } else {
                    finish = YES; //totalCount<=countLimit,检测完成
                }
                pthread_mutex_unlock(&_lock);
            } else {
                usleep(10 * 1000); //10 ms
            }
        }
        ...
    }
    

    初始化时countLimit默认是NSUIntegerMax,如果不指定countLimit,节点的总个数永远不会超过这个限制,所以_trimToCount方法不会删除末尾节点。

    _trimToAge方法遍历链表中的节点,删除那些和now时刻的时间间隔大于ageLimit的节点,代码如下:

    - (void)_trimToAge:(NSTimeInterval)ageLimit {
        BOOL finish = NO;
        ...
        NSMutableArray *holder = [NSMutableArray new];
        while (!finish) {
            if (pthread_mutex_trylock(&_lock) == 0) {
                if (_lru->_tail && (now - _lru->_tail->_time) > ageLimit) { //间隔大于ageLimit
                    _YYLinkedMapNode *node = [_lru removeTailNode]; //删除末尾节点
                    if (node) [holder addObject:node];
                } else {
                    finish = YES;
                }
                pthread_mutex_unlock(&_lock);
            } else {
                usleep(10 * 1000); //10 ms
            }
        }
        ...
    }
    

    由于链表中从头部至尾部的节点,访问时间由晚至早,所以尾部节点和now时刻的时间间隔较大,从尾节点开始删除。ageLimit的默认值是DBL_MAX,如果不人为指定ageLimit,则链表中节点不会被删除。

  5. 线程同步

    YYCache通过在方法中添加互斥所的逻辑,来保证多线程操作缓存时数据的同步。例如在setObject:forKey:withCost:方法和objectForKey:中添加代码:

    - (void)setObject:(id)object forKey:(id)key withCost:(NSUInteger)cost {
         pthread_mutex_lock(&_lock);
         //操作链表,写缓存数据
         pthread_mutex_unlock(&_lock);
    }
    - (id)objectForKey:(id)key {
     pthread_mutex_lock(&_lock);
         //访问缓存数据
         pthread_mutex_unlock(&_lock);
    }
    

    如果存在线程A和B,线程A在写缓存的时候,上锁,线程B读取缓存数据时,被阻塞,需要等到线程A执行完写缓存的操作,调用pthread_mutex_unlock后,线程B才能读缓存数据,这个时候新的缓存数据已经写完,保证了操作的数据的同步。

    YYCache在每一个操作的缓存的方法中都是用了互斥所来保证多线程访问数据的同步性,保证代码执行过程中的安全性。

总结

YYMemoryCache操作了内存缓存,相较于硬盘缓存需要进行I/O操作,在性能上快很多,因此YYCache访问缓存时,优先用的是YYMemoryCache。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容