在通常标准的操做系统教材里,会用下面的方式来演示 LRU 原理,假设内存只能容纳3个页大小,按照 7 0 1 2 0 3 0 4 的次序访问页。假设内存按照栈的方式来描述访问时间,在上面的,是最近访问的,在下面的是,最远时间访问的,LRU就是这样工做的。node
可是若是让咱们本身设计一个基于 LRU 的缓存,这样设计可能问题不少,这段内存按照访问时间进行了排序,会有大量的内存拷贝操做,因此性能确定是不能接受的。redis
那么如何设计一个LRU缓存,使得放入和移除都是 O(1) 的,咱们须要把访问次序维护起来,可是不能经过内存中的真实排序来反应,有一种方案就是使用双向链表。算法
1.用一个数组来存储数据,给每个数据项标记一个访问时间戳,每次插入新数据项的时候,先把数组中存在的数据项的时间戳自增,并将新数据项的时间戳置为0并插入到数组中。每次访问数组中的数据项的时候,将被访问的数据项的时间戳置为0。当数组空间已满时,将时间戳最大的数据项淘汰。数组
2.利用一个链表来实现,每次新插入数据的时候将新数据插到链表的头部;每次缓存命中(即数据被访问),则将数据移到链表头部;那么当链表满的时候,就将链表尾部的数据丢弃。缓存
3.利用链表和hashmap。当须要插入新的数据项的时候,若是新数据项在链表中存在(通常称为命中),则把该节点移到链表头部,若是不存在,则新建一个节点,放到链表头部,若缓存满了,则把链表最后一个节点删除便可。在访问数据的时候,若是数据项在链表中存在,则把该节点移到链表头部,不然返回-1。这样一来在链表尾部的节点就是最近最久未访问的数据项。app
对于第一种方法,须要不停地维护数据项的访问时间戳,另外,在插入数据、删除数据以及访问数据时,时间复杂度都是O(n)。对于第二种方法,链表在定位数据的时候时间复杂度为O(n)。因此在通常使用第三种方式来是实现LRU算法。dom
总体的设计思路是,可使用 HashMap 存储 key,这样能够作到 save 和 get key的时间都是 O(1),而 HashMap 的 Value 指向双向链表实现的 LRU 的 Node 节点,如图所示。ide
LRU 存储是基于双向链表实现的,下面的图演示了它的原理。其中 head 表明双向链表的表头,tail 表明尾部。首先预先设置 LRU 的容量,若是存储满了,能够经过 O(1) 的时间淘汰掉双向链表的尾部,每次新增和访问数据,均可以经过 O(1)的效率把新的节点增长到对头,或者把已经存在的节点移动到队头。post
下面展现了,预设大小是 3 的,LRU存储的在存储和访问过程当中的变化。为了简化图复杂度,图中没有展现 HashMap部分的变化,仅仅演示了上图 LRU 双向链表的变化。咱们对这个LRU缓存的操做序列以下:性能
save("key1", 7)
save("key2", 0)
save("key3", 1)
save("key4", 2)
get("key2")
save("key5", 3)
get("key2")
save("key6", 4)
相应的 LRU 双向链表部分变化以下:
总结一下核心操做的步骤:
完整基于 Java 的代码参考以下
class DLinkedNode { String key; int value; DLinkedNode pre; DLinkedNode post; }
LRU Cache
public class LRUCache { private Hashtable<Integer, DLinkedNode> cache = new Hashtable<Integer, DLinkedNode>(); private int count; private int capacity; private DLinkedNode head, tail; public LRUCache(int capacity) { this.count = 0; this.capacity = capacity; head = new DLinkedNode(); head.pre = null; tail = new DLinkedNode(); tail.post = null; head.post = tail; tail.pre = head; } public int get(String key) { DLinkedNode node = cache.get(key); if(node == null){ return -1; // should raise exception here. } // move the accessed node to the head; this.moveToHead(node); return node.value; } public void set(String key, int value) { DLinkedNode node = cache.get(key); if(node == null){ DLinkedNode newNode = new DLinkedNode(); newNode.key = key; newNode.value = value; this.cache.put(key, newNode); this.addNode(newNode); ++count; if(count > capacity){ // pop the tail DLinkedNode tail = this.popTail(); this.cache.remove(tail.key); --count; } }else{ // update the value. node.value = value; this.moveToHead(node); } } /** * Always add the new node right after head; */ private void addNode(DLinkedNode node){ node.pre = head; node.post = head.post; head.post.pre = node; head.post = node; } /** * Remove an existing node from the linked list. */ private void removeNode(DLinkedNode node){ DLinkedNode pre = node.pre; DLinkedNode post = node.post; pre.post = post; post.pre = pre; } /** * Move certain node in between to the head. */ private void moveToHead(DLinkedNode node){ this.removeNode(node); this.addNode(node); } // pop the current tail. private DLinkedNode popTail(){ DLinkedNode res = tail.pre; this.removeNode(res); return res; } }
继承LinkedHashMap的简单实现:
LinkedHashMap底层就是用的HashMap加双链表实现的,并且自己已经实现了按照访问顺序的存储。此外,LinkedHashMap中自己就实现了一个方法removeEldestEntry用于判断是否须要移除最不常读取的数,方法默认是直接返回false,不会移除元素,因此须要重写该方法。即当缓存满后就移除最不经常使用的数。
public class LRUCache<K, V> extends LinkedHashMap<K, V> { private final int CACHE_SIZE; // 这里就是传递进来最多能缓存多少数据 public LRUCache(int cacheSize) { // 设置一个hashmap的初始大小,最后一个true指的是让linkedhashmap按照访问顺序来进行排序,最近访问的放在头,最老访问的就在尾 super((int) Math.ceil(cacheSize / 0.75) + 1, 0.75f, true); CACHE_SIZE = cacheSize; } @Override protected boolean removeEldestEntry(Map.Entry eldest) { // 当map中的数据量大于指定的缓存个数的时候,就自动删除最老的数据 return size() > CACHE_SIZE; } }
那么问题的后半部分,是 Redis 如何实现,这个问题这么问确定是有坑的,那就是redis确定不是这样实现的。
若是按照HashMap和双向链表实现,须要额外的存储存放 next 和 prev 指针,牺牲比较大的存储空间,显然是不划算的。因此Redis采用了一个近似的作法,就是随机取出若干个key,而后按照访问时间排序后,淘汰掉最不常用的,具体分析以下:
为了支持LRU,Redis 2.8.19中使用了一个全局的LRU时钟,server.lruclock
,定义以下,
#define REDIS_LRU_BITS 24 unsigned lruclock:REDIS_LRU_BITS; /* Clock for LRU eviction */
默认的LRU时钟的分辨率是1秒,能够经过改变REDIS_LRU_CLOCK_RESOLUTION
宏的值来改变,Redis会在serverCron()
中调用updateLRUClock
按期的更新LRU时钟,更新的频率和hz参数有关,默认为100ms
一次,以下,
#define REDIS_LRU_CLOCK_MAX ((1<<REDIS_LRU_BITS)-1) /* Max value of obj->lru */ #define REDIS_LRU_CLOCK_RESOLUTION 1 /* LRU clock resolution in seconds */ void updateLRUClock(void) { server.lruclock = (server.unixtime / REDIS_LRU_CLOCK_RESOLUTION) & REDIS_LRU_CLOCK_MAX; }
server.unixtime
是系统当前的unix时间戳,当 lruclock 的值超出REDIS_LRU_CLOCK_MAX时,会从头开始计算,因此在计算一个key的最长没有访问时间时,可能key自己保存的lru访问时间会比当前的lrulock还要大,这个时候须要计算额外时间,以下,
/* Given an object returns the min number of seconds the object was never * requested, using an approximated LRU algorithm. */ unsigned long estimateObjectIdleTime(robj *o) { if (server.lruclock >= o->lru) { return (server.lruclock - o->lru) * REDIS_LRU_CLOCK_RESOLUTION; } else { return ((REDIS_LRU_CLOCK_MAX - o->lru) + server.lruclock) * REDIS_LRU_CLOCK_RESOLUTION; } }
Redis支持和LRU相关淘汰策略包括,
volatile-lru
设置了过时时间的key参与近似的lru淘汰策略allkeys-lru
全部的key均参与近似的lru淘汰策略当进行LRU淘汰时,Redis按以下方式进行的,
...... /* volatile-lru and allkeys-lru policy */ else if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU || server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU) { for (k = 0; k < server.maxmemory_samples; k++) { sds thiskey; long thisval; robj *o; de = dictGetRandomKey(dict); thiskey = dictGetKey(de); /* When policy is volatile-lru we need an additional lookup * to locate the real key, as dict is set to db->expires. */ if (server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU) de = dictFind(db->dict, thiskey); o = dictGetVal(de); thisval = estimateObjectIdleTime(o); /* Higher idle time is better candidate for deletion */ if (bestkey == NULL || thisval > bestval) { bestkey = thiskey; bestval = thisval; } } } ......
Redis会基于server.maxmemory_samples
配置选取固定数目的key,而后比较它们的lru访问时间,而后淘汰最近最久没有访问的key,maxmemory_samples的值越大,Redis的近似LRU算法就越接近于严格LRU算法,可是相应消耗也变高,对性能有必定影响,样本值默认为5。