Redis源码解析:缓存淘汰策略

在这里插入图片描述

Redis内存满了该怎么办?

在这里插入图片描述
Redis是一个内存数据库,当Redis使用的内存超过物理内存的限制后,内存数据会和磁盘产生频繁的交换,交换会导致Redis性能急剧下降。所以在生产环境中我们通过配置参数maxmemoey来限制使用的内存大小。

在redis.conf中和缓存淘汰策略相关的有如下2个配置

maxmemory: 设置Redis server可以使用的最大内存容量,一旦超过最大容量,会按照maxmemory-policy配置的策略进行内存淘汰操作

maxmemory-policy: 设置内存淘汰策略,主要有近似lru,近似lfu,ttl和随机淘汰这几种策略

当实际使用的内存超过maxmemoey后,Redis提供了如下几种可选策略。


noeviction:写请求返回错误


volatile-lru:使用lru算法删除设置了过期时间的键值对

volatile-lfu:使用lfu算法删除设置了过期时间的键值对

volatile-random:在设置了过期时间的键值对中随机进行删除

volatile-ttl:根据过期时间的先后进行删除,越早过期的越先被删除


allkeys-lru:在所有键值对中,使用lru算法进行删除

allkeys-lfu:在所有键值对中,使用lfu算法进行删除

allkeys-random:所有键值对中随机删除


我们来详细了解一下lru和lfu算法,这是2个常见的缓存淘汰算法。因为计算机缓存的容量是有限的,所以我们要删除那些没用的数据,而这两种算法的区别就是判定没用的纬度不一样。

LRU算法

lru(Least recently used,最近最少使用)算法,即最近访问的数据,后续很大概率还会被访问到,即是有用的。而长时间未被访问的数据,应该被淘汰

lru算法中数据会被放到一个链表中,链表的头节点为最近被访问的数据,链表的尾节点为长时间没有被访问的数据

在这里插入图片描述

lru算法的核心实现就是哈希表加双向链表。链表可以用来维护访问元素的顺序,而hash表可以帮我们在O(1)时间复杂度下访问到元素。

至于为什么是双向链表呢?主要是要删除元素,所以要获取前继节点。数据结构图示如下

在这里插入图片描述
举个例子演示一下LRU算法是如何工作的

LRUCache cache = new LRUCache(2);
// 左边是队头,右边是队尾

cache.put(1, 1);
// cache = [(1, 1)]

cache.put(2, 2);
// cache = [(2, 2), (1, 1)]
// 一直往队头放

cache.get(1);
// cache = [(1, 1), (2, 2)]
// 元素被访问了,放到队头

cache.put(3, 3);
// cache = [(3, 3), (1, 1)]
// 缓存容量满了,删除最久未使用的元素
// 然后把新元素放入队头

继承LinkedHashMap实现LRU算法

public class LruCache<K, V> extends LinkedHashMap<K, V> {

    private int cacheSize;


    public LruCache(int cacheSize) {
        super(cacheSize, 0.75f, true);
        this.cacheSize = cacheSize;
    }

    /**
     * 当调用put或者putAll方法时会调用如下方法,是否删除最老的数据,默认为false
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > cacheSize;
    }
}

LruCache的构造函数会调用LinkedHashMap的构造函数,每个参数的作用如下

  1. initialCapacity: 初始容量大小
  2. loadFactor: 负载因子
  3. accessOrder: false基于插入排序(默认)。true基于访问排序,即最近访问的元素会被移动到链表头部,

LinkedHashMap和HashMap一样提供了get和put方法,实现细节稍有不同(以下为accessOrder为true的场景)

  • put方法:key存在则更新对应的值,并将元素移动到链表末尾。key不存在,则将元素插入到哈希表中
  • get方法:key存在则返回,并将元素移动到链表末尾。key不存在,则返回null

注意这个缓存并不是线程安全的,可以调用Collections.synchronizedMap方法返回线程安全的map

LruCache<String, String> lruCache = new LruCache(3);
Map<String, String> safeMap = Collections.synchronizedMap(lruCache);

Collections.synchronizedMap实现线程安全的方式很简单,只是返回一个代理类。代理类对Map接口的所有方法加锁

public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
    return new SynchronizedMap<>(m);
}

使用LinkedList+HashMap实现LRU算法

public class LRUCache {

    private int capacity;
    private Map<Integer, Integer> cache;
    private LinkedList<Integer> keyList;

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.cache = new HashMap<>(capacity);
        this.keyList = new LinkedList<>();
    }

    public int get(int key) {
        if (cache.containsKey(key)) {
            keyList.remove((Object) key);
            keyList.addLast(key);
            return cache.get(key);
        }
        return -1;
    }

    public void put(int key, int value) {
        cache.put(key, value);
        keyList.remove((Object) key);
        keyList.addLast(key);
        if (cache.size() > capacity) {
            Integer firstKey = keyList.removeFirst();
            cache.remove(firstKey);
        }
    }
}

使用这种方式也能实现类似的效果,但是时间复杂度比较高,因为我们这个例子从LinkedList删除元素时间复杂度比较高,为O(n),我们可以自己来定义双向链表,来实现删除时时间复杂度为O(1)

使用双向链表+HashMap

我们首先先定义链表的节点

public class ListNode<K, V> {
    K key;
    V value;
    ListNode pre;
    ListNode next;

    public ListNode() {}

    public ListNode(K key, V value) {
        this.key = key;
        this.value = value;
    }
}

接着定义双向链表,可以看到head和tail节点并不是链表中实际的头节点和尾节点,而是假头和假尾,之所以定义假头和假尾,主要是为了简化删除节点操作,不然当删除头节点和尾节点的时候需要特判

public class DoubleList {

    ListNode head;
    ListNode tail;

    public DoubleList() {
        head = new ListNode();
        tail = new ListNode();
        head.next = tail;
        tail.pre = head;
    }
    
    public void remove(ListNode node) {
        node.pre.next = node.next;
        node.next.pre = node.pre;
    }

    // 将节点加到链表尾部
    public void addLast(ListNode node) {
        tail.pre.next = node;
        node.next = tail;
        node.pre = tail.pre;
        tail.pre = node;
    }

    // 删除链表的头节点
    public ListNode removeFirst() {
        ListNode removeNode = head.next;
        remove(removeNode);
        return removeNode;
    }
}

双向链表最基本的操作有3个,访问元素或修改这个元素时,需要删除元素,并把元素放到链表尾部,当放置的元素超过缓存容量时,需要删除链表头部(即删除最长没有被访问的元素)

封装一个缓存类,实现为双向链表和HashMap

public class LRUCache {

    int capacity;
    DoubleList doubleList;
    Map<Integer, ListNode> map;

    public LRUCache(int capacity) {
        this.capacity = capacity;
        map = new HashMap<>();
        doubleList = new DoubleList();
    }

    public int get(int key) {
    }

    public void put(int key, int value) {
    }
}

我们来实现get和put方法。

public int get(int key) {
    ListNode listNode = map.get(key);
    if (listNode == null) {
        return -1;
    }
    // 将节点变成最新访问的节点
    // 涉及到2个操作,删除原来的节点,并添加新节点到链表尾部
    doubleList.remove(listNode);
    doubleList.addLast(listNode);
    return listNode.value;
}
public void put(int key, int value) {
    ListNode listNode = map.get(key);

    if (listNode != null) {
    	doubleList.remove(listNode);
    	doubleList.addLast(listNode);
        listNode.value = value;
        return;
    }

    if (map.size() == capacity) {
        // 删除最长事件没有访问的节点
        // 即删除链表的头节点和对应HashMap中的值
   		ListNode listNode = doubleList.removeFirst();
    	map.remove(listNode.key);
    }

	// 增加最新访问的节点
	// 即将节点增加到链表尾部,并放到HashMap中
    ListNode listNode = new ListNode(key, value);
    doubleList.addLast(listNode);
    map.put(key, listNode);

}

都看到这了,你就顺便把LeetCode的这道算法题给AC了把

https://leetcode-cn.com/problems/lru-cache/

LFU算法

LRU算法有一个问题,当一个长时间不被访问的key,偶尔被访问一下后,可能会造成比这个key访问更频繁的key被淘汰。

即LRU算法对key的冷热程度的判断可能不准确。而LFU算法(Least Frequently Used,最不经常使用)则是按照访问频率来判断key的冷热程度的,每次删除的是访问频率最低的数据,当有多个key的访问频率一样时,则删除最旧的数据

首先还是来演示一下LFU算法是如何工作的

LFUCache cache = new LFUCache(2);

cache.put(1, 1);

cache.put(2, 2);

cache.get(1);
// 键 1 的访问频率增加1

cache.put(3, 3);
// 缓存容量满了,删除访问频率最低的键 2

cache.get(2);
// 键 2 已被删除,返回-1

我们应该如何组织数据呢?

当我们实现LRU算法的时候使用一个map加一个双向链表来实现的,那么实现LFU算法应该如何组织数据?

首先为了实现键值的对快速访问,用一个map来保存键值对

private HashMap<Integer, Integer> keyToVal;

另外还需要用一个map来保存键的访问频率

private HashMap<Integer, Integer> keyToFreq;

接下来就是最核心的部分,删除访问频率最低的数据。

  1. 为了能在O(1)时间复杂度内找到访问频率最低的数据,我们需要一个变量minFreq记录访问最低的频率
  2. 每个访问频率有可能对应多个键。当空间不够用时,我们要删除最早被访问的数据,所以需要如下数据结构,Map<频率, 有序集合>。每次内存不够用时,删除有序集合的第一个元素即可。并且这个有序集合要能快速删除某个key,因为某个key被访问后,需要从这个集合中删除,加入freq+1对应的集合中
  3. 有序集合很多,但是能满足快速删除某个key的只有set,但是set插入数据是无序的。幸亏Java有LinkedHashSet这个类,链表和集合的结合体,链表不能快速删除元素,但是能保证插入顺序。集合内部元素无序,但是能快速删除元素,完美

我们将LFUCache定义如下

public class LFUCache {

    Map<Integer, Integer> keyToVal;
    Map<Integer, Integer> keyToFreq;
    Map<Integer, LinkedHashSet<Integer>> freqTokeys;

    // 最小的频次
    int minFreq;
    int capacity;

    public LFUCache(int capacity) {
        keyToVal = new HashMap<>();
        keyToFreq = new HashMap<>();
        freqTokeys = new HashMap<>();
        this.capacity = capacity;
        this.minFreq = 0;
    }

    public int get(int key) {
    }

    public void put(int key, int value) {
    }
}

接着就是实现get和put方法,老规矩还是将复杂的操作抽象出来

public int get(int key) {
    Integer v = keyToVal.get(key);
    if (v == null) {
        return -1;
    }
    // 增加key对应的频率
    increaseFrey(key);
    return v;
}
public void put(int key, int value) {
    if (keyToVal.containsKey(key)) {
        // 重新设置值
        keyToVal.put(key, value);
        increaseFrey(key);
        return;
    }

    // 超出容量,删除频率最低的key
    if (keyToVal.size() == capacity) {
        // 删除访问频率最低的key
        removeMinFreqKey();
    }

    keyToVal.put(key, value);
    keyToFreq.put(key, 1);
    freqTokeys.putIfAbsent(1, new LinkedHashSet<>());
    freqTokeys.get(1).add(key);
    this.minFreq = 1;
}

最后实现抽象的方法

// 增加频率
private void increaseFrey(int key) {
    int freq = keyToFreq.get(key);
    keyToFreq.put(key, freq + 1);
    freqTokeys.get(freq).remove(key);
    freqTokeys.putIfAbsent(freq + 1, new LinkedHashSet<>());
    freqTokeys.get(freq + 1).add(key);
    if (freqTokeys.get(freq).isEmpty()) {
        freqTokeys.remove(freq);
        // 最小频率的set为空,key被移动到minFreq+1对应的set了
        // 所以minFreq也要加1
        if (freq == this.minFreq) {
            this.minFreq++;
        }
    }
}
// 删除出现频率最低的key
private void removeMinFreqKey() {
    LinkedHashSet<Integer> keyList = freqTokeys.get(minFreq);
    Integer deleteKey = keyList.iterator().next();
    keyList.remove(deleteKey);
    if (keyList.isEmpty()) {
        // 这里删除元素后不需要重新设置minFreq
        // 因为put方法执行完会将minFreq设置为1
        freqTokeys.remove(keyList);
    }
    keyToVal.remove(deleteKey);
    keyToFreq.remove(deleteKey);
}

都看到这了,你就顺便把LeetCode的这道算法题给AC了把

https://leetcode-cn.com/problems/lfu-cache/

在Redis中的LRU和LFU算法并不是严格按照上面的思路来的,而是采用近似LRU和LFU算法,效果相差不大,有对算法的实现细节感兴趣的小伙伴可以看看其他文章,我就不多做介绍了

参考博客

lru算法和lfu算法
[1]https://my.oschina.net/lscherish/blog/4467394
海子大佬的页面置换算法
[2]https://www.cnblogs.com/dolphin0520/p/3749259.html
[3]leetcode lru算法
https://leetcode-cn.com/problems/lru-cache-lcci/
[4]leetcode lfu算法
https://leetcode-cn.com/problems/lfu-cache
[5]https://labuladong.gitee.io/algo/2/21/45/
lfu算法
[6]https://mp.weixin.qq.com/s/oXv03m1J8TwtHwMJEZ1ApQ
redis经典面试题
[7]https://stor.51cto.com/art/202103/651799.htm
redis中的lru
[1]https://segmentfault.com/a/1190000017555834

  • 29
    点赞
  • 86
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 17
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 17
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Java识堂

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值