LFU缓存策略算法

在之前的文章中,我们介绍了如何设计一个LRU算法–如何设计LRU Cache算法,今天我们再聊一聊另一种缓存策略LFU。目前博主个人博客已经搭建发布,后期相关文章也会发布在上面,大家有兴趣可以去上面学习,点击即可前往文青乐园

1 LFU基本介绍

LFU,全称是Least Frequently Used,最不经常使用策略,在一段时间内,数据被使用频次最少的,优先被淘汰。维基百科中这样介绍:最少使用(LFU)是一种用于管理计算机内存的缓存算法,主要是记录和追踪内存块的使用次数,当缓存已满并且需要更多空间时,系统将以最低内存块使用频率清除内存。采用LFU算法的最简单方法是为每个加载到缓存的块分配一个计数器,每次引用该块时,计数器将增加一,当缓存达到容量并有一个新的内存块等待插入时,系统将搜索计数器最低的块并将其从缓存中删除。
在这里插入图片描述
上面这个图就是一个LFU的简单实现思路:

  1. 在链表的开始插入元素,每插入一次计数一次
  2. 按照次数重新排序链表,
  3. 如果次数相同的话,按照插入时间排序,然后从链表尾部选择淘汰的数据

基于以上的思路我们接下来设计LFU算法。

2 LFU算法实现

2.1 Node节点定义

  • 首先Node需要包含了key和value,主要用来存放相应的数据,必不可少;
  • 基于以上的思路,LFU的主要实现思想是比较访问的次数,如果在次数相同的情况下需要比较节点的时间,越早放入的越快被淘汰,因此我们需要在Node节点上加入time和count的属性,分别用来记录节点的访问的时间和访问次数;
  • 因为要比较时间和次数,所以我们需要一个比较的方法,所以Node节点需要实现comparable接口,并重写了compareTo方法,方法里的具体逻辑为首先比较节点的访问次数,在访问次数相同的情况下比较节点的访问时间,这样在排序方法里面通过比较key来选择淘汰的key。

有了上面的分析,我们给出定义好的Node节点的代码,如下:

/**
 * @author likangmin
 * @version 1.0
 * @create 2021/3/24 14:29
 * @desc
 */
public class Node implements Comparable<Node> {
    /**
     * 键
     */
    Object key;
    /**
     * 值
     */
    Object value;
    /**
     * 访问时间
     */
    long time;
    /**
     * 访问次数
     */
    int count;

    public Node(Object key, Object value, long time, int count) {
        this.key = key;
        this.value = value;
        this.time = time;
        this.count = count;
    }

    public Object getKey() {
        return key;
    }

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

    public Object getValue() {
        return value;
    }

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

    public long getTime() {
        return time;
    }

    public void setTime(long time) {
        this.time = time;
    }

    public int getCount() {
        return count;
    }

    public void setCount(int count) {
        this.count = count;
    }

    @Override
    public int compareTo(Node o) {
        int compare = Integer.compare(this.count, o.count);
        //在数目相同的情况下 比较时间
        if (compare == 0) {
            return Long.compare(this.time,o.time);
        }
        return compare;
    }

}

2.2 LFU类的定义

定义LFU类,这里采用泛型,声明为K和V,还有总容量和一个Map(caches)用来维护所有的节点。在构造方法里将size传递进去,并且创建了一个LinkedHashMap,采用linkedHashMap的主要原因是维护key的顺序。对应的代码如下:

/**
 * @author likangmin
 * @version 1.0
 * @create 2021/3/24 14:51
 * @desc
 */
public class LFU<K, V> {
    /**
     * 总容量
     */
    private int capacity;

    /**
     * 所有的node节点
     */
    private Map<K, Node> caches;

    public Map<K, Node> getCaches() {
        return caches;
    }

    public void setCaches(Map<K, Node> caches) {
        this.caches = caches;
    }

    /**
     * 构造方法
     * @param size
     */
    public LFU(int size) {
        this.capacity = size;
        caches = new LinkedHashMap<K, Node>(size);
    }

}

有了类的定义之后,我们接下来需要设计一些方法,包括添加元素,删除元素,获取元素等。

2.3 添加元素

添加元素的逻辑主要如下:

  1. 先从缓存中根据key获取节点,如果获取不到,说明是新添加的元素,然后和容量比较,大于预定容量的话,需要找出count计数最小(计数相同的情况下,选择时间最久)的节点,然后移除掉那个;
  2. 如果在预定的大小之内,就新创建节点,注意这里不能使用 System.currentTimeMillis()方法,因为毫秒级别的粒度无法对插入的时间进行区分,在运行比较快的情况下,只有System.nanoTime()才可以将key的插入时间区分,默认设置count计数为1;
  3. 如果能获取到,表示是旧的元素,那么就用新值覆盖旧值,计数+1,设置key的time为当前纳秒时间;
  4. 最后还需要进行排序

从以上步骤可以看出插入元素的逻辑主要是添加进入缓存,更新元素的时间和计数,重新排序,流程图如下:
在这里插入图片描述
每次put或者get元素都需要进行排序,排序的主要意义在于按照key的cout和time进行一个key顺序的重组,这里的逻辑是首先将缓存map创建成一个list,然后按照Node的value进行重组整个map。然后将原来的缓存清空,遍历这个map, 把key和value的值放进去原来的缓存中的顺序就进行了重组。淘汰最小的元素这里调用了Collections.min方法,然后通过比较key的compareTo方法,找到计数最小和时间最长的元素,直接从缓存中移除。有了上面的分析,我们直接写出对应的方法:

/**
     * 添加元素
     * @param key
     * @param value
     */
    public void put(K key, V value) {
        Node node = caches.get(key);
        //如果新元素
        if (node == null) {
            //如果超过元素容纳量
            if (caches.size() >= capacity) {
                //移除count计数最小的那个key
                K leastKey = removeLeastCount();
                caches.remove(leastKey);
            }
            //创建新节点
            node = new Node(key, value, System.nanoTime(), 1);
            caches.put(key, node);
        } else {
            //已经存在的元素覆盖旧值
            node.value = value;
            node.setCount(node.getCount() + 1);
            node.setTime(System.nanoTime());
        }
        sort();
    }

/**
     * 移除统计数或者时间比较最小的那个
     * @return
     */
    private K removeLeastCount() {
        Collection<Node> values = caches.values();
        Node min = Collections.min(values);
        return (K) min.getKey();

    }

    /**
     * 排序
     */
    private void sort() {
        List<Map.Entry<K, Node>> list = new ArrayList<>(caches.entrySet());
        Collections.sort(list, (o1, o2) -> o2.getValue().compareTo(o1.getValue()));
        caches.clear();
        for (Map.Entry<K, Node> kNodeEntry : list) {
            caches.put(kNodeEntry.getKey(), kNodeEntry.getValue());
        }
    }

2.4 获取元素

获取元素的逻辑主要如下:

  1. 首先是从缓存map中获取,如果不存在则返回null,
  2. 如果存在,在获取到元素之后需要进行节点的更新,计数+1和刷新节点的时间
  3. 根据LFU的原则,在当前时间获取到这个节点以后,这个节点就暂时变成了热点节点,但是它的count计数也有可能是小于某个节点的count的,所以此时不能将它直接移动到链表顶,还需要进行一次排序,重组它的位置

我们依然可以画出对应的流程图:
在这里插入图片描述
有了上面的分析,我们直接写出对应的方法:

/**
     * 获取元素
     * @param key
     * @return
     */
    public V get(K key) {
        Node node = caches.get(key);
        if (node != null) {
            node.setCount(node.getCount() + 1);
            node.setTime(System.nanoTime());
            sort();
            return (V) node.value;
        }
        return null;
    }

3 LFU算法测试

  • 我们首先声明一个LRU,然后默认最大的大小为5,依次put进入A、B、C、D、E、F6个元素,此时将会找到计数最小和时间最短的元素,那么将会淘汰A(因为count值都是1);
  • 记着get两次B元素,那么B元素的count=3,时间更新为最新。此时B将会移动到顶;
  • 接着在getC元素,C元素的count=2,时间会最新,那么此时因为它的count值依然小于B,所以它依然在B后面;
  • 再getF元素,F元素的count=2,又因为它的时间会最新,所以在与C相同的计数下,F元素更新(时间距离现在最近),所以链表将会移动,F会在C的前面;
  • 再次put一次C,此时C的count=3,同时时间为最新,那么此刻C的count和B保持一致,则他们比较时间,C明显更新,所以C将会排在B的前面;
  • 最终的顺序应该是:C->B->F->E->D。

代码如下:

/**
 * @author likangmin
 * @version 1.0
 * @create 2021/3/24 14:57
 * @desc
 */
public class MyLFUTest {
    public static void main(String[] args) {
        LFU<Integer,String> lruCache = new LFU<>(5);
        lruCache.put(1, "A");
        lruCache.put(2, "B");
        lruCache.put(3, "C");
        lruCache.put(4, "D");
        lruCache.put(5, "E");
        lruCache.put(6, "F");
        lruCache.get(2);
        lruCache.get(2);
        lruCache.get(3);
        lruCache.get(6);
        //重新put节点3
        lruCache.put(3,"C");

        final Map<Integer, Node> caches = (Map<Integer, Node>) lruCache.getCaches();
        for (Map.Entry<Integer, Node> nodeEntry : caches.entrySet()) {
            System.out.println(nodeEntry.getValue().value);
        }
    }
}

我们启动程序看一下输出,可以看到正如我们分析的一样。
在这里插入图片描述

4 LRU和LFU的区别

LRU和LFU侧重点不同,LRU主要体现在对元素的使用时间上,而LFU主要体现在对元素的使用频次上。LFU的缺陷是:在短期的时间内,对某些缓存的访问频次很高,这些缓存会立刻晋升为热点数据,而保证不会淘汰,这样会驻留在系统内存里面。而实际上,这部分数据只是短暂的高频率访问,之后将会长期不访问,瞬时的高频访问将会造成这部分数据的引用频率加快,而一些新加入的缓存很容易被快速删除,因为它们的引用频率很低。

最后,大家可以跟我之前的文章如何设计LRU Cache算法对照着来看,相信看完你会对这两种缓存策略有更深刻的理解,下次不管是跟朋友谈论还是面试,你都可以在他们面前show一波了。

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
引用中提到,LFU(最少使用频率)缓存替换算法是一种常见的缓存替换算法。引用中提到,要理解LFU算法,首先要了解LRU(最近最少使用)算法,因为它们的原理类似。LFU算法和LRU算法都是用来管理缓存中的数据的替换策略LFU算法主要基于缓存中数据的使用频率来进行替换。每当缓存中的数据被访问时,其使用频率就会增加。当需要替换缓存中的数据时,LFU算法会选择使用频率最低的数据进行替换,即使用频率最低的数据很有可能是最不常用的数据。 与LFU算法类似,LRU算法是基于最近访问时间来进行替换。每当数据被访问,就会更新其最近访问时间。当需要替换数据时,LRU算法会选择最久未被访问的数据进行替换。 虽然LFU和LRU算法在实现上有一些差异,但它们都是为了优化缓存的性能。LFU算法主要关注数据的使用频率,而LRU算法则关注数据的最近访问时间。这些算法可以帮助我们更好地管理缓存中的数据,提高缓存的命中率和性能。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [LFU缓存替换算法:least frequency unused 缓存替换算法](https://blog.csdn.net/weixin_46838716/article/details/124369765)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值