如何实现一个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算法适用于访问模式具有明显局部性原理,数据的访问热度变化频率较低的场景。
相关推荐
ThisIsClark21 分钟前
【后端面试总结】MySQL主从复制逻辑的技术介绍
mysql·面试·职场和发展
程序猿进阶1 小时前
深入解析 Spring WebFlux:原理与应用
java·开发语言·后端·spring·面试·架构·springboot
LCG元9 小时前
【面试问题】JIT 是什么?和 JVM 什么关系?
面试·职场和发展
GISer_Jing13 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试
m0_7482455213 小时前
吉利前端、AI面试
前端·面试·职场和发展
TodoCoder14 小时前
【编程思想】CopyOnWrite是如何解决高并发场景中的读写瓶颈?
java·后端·面试
Wyang_XXX15 小时前
CSS 选择器和优先级权重计算这么简单,你还没掌握?一篇文章让你轻松通关面试!(下)
面试
liyinuo201718 小时前
嵌入式(单片机方向)面试题总结
嵌入式硬件·设计模式·面试·设计规范
代码中の快捷键19 小时前
java开发面试有2年经验
java·开发语言·面试
bufanjun0011 天前
JUC并发工具---ThreadLocal
java·jvm·面试·并发·并发基础