如何实现一个LRU算法

前言

在我们的常规开发流程中,利用Redis来优化数据查询是一种常用且在效果上表现出色的策略。我们会精选一些读取频繁且更新频率不高的数据,将这些数据存储到Redis内。这样做的优点在于,每当有数据请求来临,我们可以直接从Redis中以极快的速度取得数据,避免了对数据库进行繁琐的查询。这一方式不仅让查询速度大大提升,同时也有效减轻了数据库的压力。然而,当Redis内存用量超过预设上限时,找到一个合适的方式来处理新进的数据请求就显得至关重要。正因如此,Redis提供了以下几种内存淘汰策略供我们选择:

  1. noeviction:这是默认的策略,当内存使用超过预设上限时,新进的写入操作将被拒绝,同时会返回一个错误消息。
  2. allkeys-lru:此策略在将新数据添加到内存时,会按照最近最少使用(Least Recently Used, LRU)的原则,选择最不常用的数据进行淘汰。
  3. volatile-lru:此策略在一定程度上与allkeys-lru类似,但它仅淘汰那些已设定过期时间的键值。
  4. allkeys-random:当内存使用超出预设上限时,此策略会随机选择数据进行淘汰。
  5. volatile-random:此策略类似于allkeys-random,唯一的区别在于,它仅对已设定过期时间的键值进行随机淘汰。
  6. volatile-ttl:此策略会优先淘汰那些快要过期的键值。在需要释放内存时,会参考键值的过期时间进行淘汰。 以上六种策略,可以根据实际业务需求和特性进行选择,以确保Redis能在内存资源有限的情况下,仍能高效运行。

这篇问题我们简单聊一聊LRU算法

LRU算法实现思路

我们使用1. 双向链表+哈希表:其中,双向链表按照元素被访问的时序顺序进行排序,越近期被访问的元素越靠近表头,越久未被访问的元素越靠近表尾。而哈希表则提供了查找任何元素的 O(1) 复杂度。当需要访问数据时,我们首先在哈希表中进行查找,如果存在则立刻返回,并更新双向链表(将此元素移动到表头),若不存在则需要从数据源获取数据,然后添加至表头,并在哈希表中增加相应的记录。如果此时缓存已满,则需要移除表尾元素,并在哈希表中移除相应记录。

代码实现

js 复制代码
public class LruCache {

    /**
     * 内部类,节点对象
     */
    class Node {

        /**
         * key
         */
        private int key;

        /**
         * value
         */
        private int value;

        /**
         * 前置节点
         */
        private Node prev;

        /**
         * 后置节点
         */
        private Node next;

        Node() {}

        Node(int key, int value) {
            this.key = key;
            this.value = value;
        }

        public int getKey() {
            return key;
        }

        public void setKey(int key) {
            this.key=key;
        }

        public int getValue() {
            return value;
        }

        public void setValue(int value) {
            this.value=value;
        }

        public Node getPrev() {
            return prev;
        }

        public void setPrev(Node prev) {
            this.prev=prev;
        }

        public Node getNext() {
            return next;
        }

        public void setNext(Node next) {
            this.next=next;
        }
    }

    /**
     * 数据队列
     */
    private Map<Integer, Node> cache = new HashMap<>();

    /**
     * 当前队列大小
     */
    private int size;

    /**
     * 队列最大容量
     */
    private int capacity;

    /**
     * 前置节点
     */
    private Node head;

    /**
     * 尾节点
     */
    private Node tail;

    /**
     * 初始化
     * @param capacity 容器最大容量
     */
    public LruCache(int capacity) {
        this.size = 0;
        this.capacity = capacity;
        // 使用伪头部和伪尾部节点
        head = new Node();
        tail = new Node();
        head.next = tail;
        tail.prev = head;
    }

    /**
     * 获取队列中的元素
     * @param key key
     * @return 节点
     */
    public int get(int key) {
        Node node = cache.get(key);
        if (node == null) {
            return -1;
        }
        // 如果 key 存在,先通过哈希表定位,再移到头部
        moveToHead(node);
        return node.value;
    }

    /**
     * 将元素存入队列中
     * @param key key
     * @param value value
     */
    public void put(int key, int value) {
        Node node = cache.get(key);
        if (node == null) {
            // 如果 key 不存在,创建一个新的节点
            Node newNode = new Node(key, value);
            // 添加进哈希表
            cache.put(key, newNode);
            // 添加至双向链表的头部
            addToHead(newNode);
            ++size;
            if (size > capacity) {
                // 如果超出容量,删除双向链表的尾部节点
                Node tail = removeTail();
                // 删除哈希表中对应的项
                cache.remove(tail.key);
                --size;
            }
        } else {
            // 如果 key 存在,先通过哈希表定位,再修改 value,并移到头部
            node.value = value;
            moveToHead(node);
        }
    }

    /**
     * 将当前节点添加至队列头节点
     * @param node 节点
     */
    private void addToHead(Node node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }

    /**
     * 从队列中删除节点
     * @param node 节点
     */
    private void removeNode(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    /**
     * 从队列获取到node节点后,将node节点移动至队列头节点
     * @param node node
     */
    private void moveToHead(Node node) {
        removeNode(node);
        addToHead(node);
    }

    /**
     * 删除尾节点
     * @return 尾节点node
     */
    private Node removeTail() {
        Node res = tail.prev;
        removeNode(res);
        return res;
    }
}

总结

复制代码
LRU算法是一种常见的缓存淘汰策略,其核心思想是:如果一个数据在最近一段时间没有被访问过,那么在将来它被访问的可能性也很小。因此,当缓存空间需要被新的数据占用时,我们可以淘汰那些最近最少使用的数据。
具体实现上,常用的方法是用一个队列来存储缓存数据,并且保证队列的最大长度等于缓存的最大容量。当新的数据进来时,我们从队尾插入,当数据再次被访问时,我们将它移动到队头。这样我们只需要在缓存满的时候,直接淘汰队尾的数据即可。
LRU算法的优点在于其实现简单,能够维持较好的缓存性能。而且,对于访问模式分布较为均匀,或者存在明显的局部性原理的数据访问模式,LRU表现通常很好。
然而,LRU并不适用于所有场景。在一些对访问时间敏感,但访问频率不高的场景中,LRU可能造成频繁的缓存淘汰和缓存不命中。例如,有些数据可能会在一个长时间周期内反复访问,但在这个周期内的访问频率不高,这种情况下LRU的效果就不如最少使用(LFU)算法。
总的来说,LRU算法适用于访问模式具有明显局部性原理,数据的访问热度变化频率较低的场景。
相关推荐
怕什么真理无穷5 小时前
C++面试4-线程同步
java·c++·面试
拉不动的猪8 小时前
# 关于初学者对于JS异步编程十大误区
前端·javascript·面试
熊猫钓鱼>_>10 小时前
Java面向对象核心面试技术考点深度解析
java·开发语言·面试·面向对象··class·oop
进击的野人12 小时前
CSS选择器与层叠机制
css·面试
T___T14 小时前
全方位解释 JavaScript 执行机制(从底层到实战)
前端·面试
9号达人14 小时前
普通公司对账系统的现实困境与解决方案
java·后端·面试
勤劳打代码15 小时前
条分缕析 —— 通过 Demo 深入浅出 Provider 原理
flutter·面试·dart
努力学算法的蒟蒻15 小时前
day10(11.7)——leetcode面试经典150
面试
进击的野人16 小时前
JavaScript 中的数组映射方法与面向对象特性深度解析
javascript·面试
南山安16 小时前
以腾讯面试题深度剖析JavaScript:从数组map方法到面向对象本质
javascript·面试