如何优雅地实现LRU缓存淘汰算法?

本文深入探讨了缓存在服务端开发中的重要性,特别是在性能优化中的作用。详细介绍了三种常见的缓存淘汰策略:FIFO、LFU和LRU。重点讲解了LRU策略,它结合时间和使用频率,更有效地保留热点数据。文章还通过Java代码展示了如何使用LinkedHashMap实现LRU缓存,强调了链表和散列表在优化查找效率中的应用。
摘要由CSDN通过智能技术生成

良心公众号

关注不迷路

作为服务端研发而言,缓存是在日常工作中很难绕过的一个话题,也是在面试过程中几乎必考的重点。这一点尤其凸显在对性能有较高要求的项目上,出于各种各样的考虑,比如为了提升接口响应速度,抑或是为了减轻对数据库的压力……我们通常都会对相应的数据作缓存操作,从而提升服务的性能。

其实,缓存不仅广泛应用于服务端研发中,其设计思想在硬件设计、软件开发中都有着非常广泛的应用,比如常见的CPU缓存、数据库缓存、浏览器缓存等等。

如果对缓存的概念给出一个相对书面的阐述,则缓存是一种依赖空间换取时间从而提高数据读取性能的技术。其适用于空间充足,但对时间要求较高的场合。随着硬件相关技术的发展,因为空间不足对系统的带来的限制已经趋弱,而对时间的追求则是永恒的,因此,缓存技术就显得更加重要。

了解了缓存的概念,重要性及其应用场景之后,很容易能够理解,缓存的大小是有限的,它不可能一直不停地对数据加以存储,而不做相应地清理动作。因此,采用不同的清理思路,就催生了不同的缓存淘汰策略。

常用的缓存淘汰策略有:先进先出FIFO最少使用LFU最近最少使用LRU。下面对这三种缓存淘汰策略的特点进行大致的描述:

  • 先进先出FIFO策略,与队列的特性如出一辙,拿队列来理解,就是当缓存来时添加到队尾,当缓存满时队头出队,谓之淘汰;

  • 最少使用LFU策略,则是对缓存的使用情况进行计数,每次当缓存满时,淘汰掉缓存中使用次数最少的数据。

  • 对比上述两种策略,可以看出,FIFO策略考虑的是时间上的优先级,而LFU策略考虑的是使用次数上的优先级,而本文将要阐述的重点——最近最少使用LRU策略,则是对时间和使用次数的考虑兼而有之。在更多的情况下,LRU策略更加合理,因为其更能体现数据的近期使用频率,从而使缓存中的数据更加贴近热点数据,以达到更好地提升性能的目的。

接下来,让我们一起来看一下本文的主角:LRU缓存淘汰算法。只要对LRU缓存淘汰策略稍加分析就不难发现,根据其最近最少使用的特点,采用链表这种数据结构去维护缓存数据是非常合适的。至于原因,在接下来的分析和实现过程中,我想你将深有体会。在具体实现LRU缓存算法之前,我们先来梳理一下该算法处理数据的思路,具体可以分为三种情况:

  • 待缓存的数据未存在于缓存链表中,且缓存空间未满

  • 待缓存的数据未存在于缓存链表中,且缓存空间已满

  • 待缓存的数据已存在于缓存链表中

到这里,为什么采用链表这种数据结构实现LRU缓存策略,已经比较明显了。因为对于上述三种情况的缓存更新,与链表的特点实在是太过匹配。我们一一来看,上述三种情况具体对应于链表的哪些操作:

  • 待缓存的数据未存在于缓存链表中,且缓存空间未满,此时只需将待缓存数据添加至链表头部(或尾部)即可

  • 待缓存的数据未存在于缓存链表中,且缓存空间已满,此时只需将链表尾部(或头部)的数据删除,并将待缓存数据添加至链表头部(或尾部)即可

  • 待缓存的数据已存在于缓存链表中,此时只需将待缓存数据从原先的位置删除,并将其插入至链表头部(或尾部)即可

对上述步骤的时间复杂度加以分析,无论缓存是否已满,我们都需要对链表进行遍历,以确定待缓存数据是否已经存在于缓存中,这个过程最好的时间复杂度为O(1),最坏的时间复杂度为O(n),平均时间复杂度为O(n)。那么,有没有更好的实现方式,使其时间复杂度逼近常量呢?答案是有的,只需要引入散列表来记录每个缓存数据的位置,即可避免遍历整个链表,这也是典型的空间换时间思想的应用。在该优化中,链表 + 散列表在Java中对应的数据结构便是LinkedHashMap。

分析至此,一切都已经如拨云见日般明晰了,话不多说,Talk is cheap, show me the code!

import java.util.LinkedHashMap;
import java.util.Map;


/**
 * 本程序及其注释在JDK11验证通过
 * @param <K>
 * @param <V>
 */
public class LRUCache<K, V> {


    // 默认负载因子
    private static final float DEFAULT_LOAD_FACTOR = 0.75f;


    // 缓存最大容量
    private final int MAX_CACHE_SIZE;


    // 自定义负载因子
    private final float LOAD_FACTOR;


    // 缓存主体LinkedHashMap
    private final LinkedHashMap<K, V> cacheMap;


    public LRUCache(int maxCacheSize, float loadFactor) {
        MAX_CACHE_SIZE = maxCacheSize;
        // 可根据具体情况自定义负载因子
        LOAD_FACTOR = loadFactor;
        // 根据缓存最大容量计算Map的初始化容量,避免扩容影响性能
        int capacity = (int)Math.ceil(MAX_CACHE_SIZE / LOAD_FACTOR) + 1;
        // accessOrder设置为true,表示在插入或者访问的时候,都会更新缓存,将该数据插入链表尾部或者移动至链表尾部
        cacheMap = new LinkedHashMap<>(capacity, LOAD_FACTOR, true) {
            private static final long serialVersionUID = 1001L;
            // 重写removeEldestEntry方法,当cacheMap的size超过缓存最大容量时,将链表头部数据移除
            @Override
            protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
                return size() > MAX_CACHE_SIZE;
            }
        };
    }


    public LRUCache(int maxCacheSize) {
        // 使用默认负载因子
        this(maxCacheSize, DEFAULT_LOAD_FACTOR);
    }


    public void put(K key, V value) {
        cacheMap.put(key, value);
    }


    public V get(K key) {
        return cacheMap.get(key);
    }
}

本文关于LRU缓存淘汰算法的总结及其实现就到这里了。欢迎大家一起讨论技术,共同成长!

学习 | 工作 | 分享

????长按关注“有理想的菜鸡

只有你想不到,没有你学不到

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值