使用LinkedList实现LRU缓存

1,541 阅读5分钟

本文已参与掘金创作者训练营第三期「话题写作」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

详细介绍了LRU缓存的概念,以及Java中如何通过LinkedList快速实现LRU缓存。

1 LRU缓存的概念

LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。

很多的软件框架都在使用LRU缓存策略,比如Redis的LRU缓存淘汰策略。并且LRU实现比较的简单,最常见的实现是使用一个链表保存缓存数据,详细算法实现如下:

  1. 新数据插入到链表头/尾部;
  2. 每当缓存命中(即缓存数据被访问),则将数据移到链表头/尾部;
  3. 指定LRU缓存的容量,当链表长度大于容量时,将链表头/尾部的数据丢弃。

2 LinkedHashMap与LRU缓存

我们的LinkedHashMap已经提供了基于访问顺序的迭代机制,最近被访问的节点在尾部,最远被访问的节点在头部.那么自然可以实现LRU缓存,当然它的实现和下面这两个方法有关。

3.1 afterNodeInsertion方法

在插入元素操作之后,不光会调用linkNodeLast方法,在成功插入节点的情况下,在最后还会调用afterNodeInsertion方法,并传递evict=true(构造器中插入节点是传递evict=false)。同样HashMap同样只提供一个空实现。

比如put方法,存在两个情况,一种是替换value,一种是插入新节点,如果替换value,那么肯定访问到了某个节点,此时调用afterNodeAccess,如果是插入新节点,那么肯定是调用afterNodeInsertion方法,这两个方法不可能同时调用!

在LinkedHashMap重写的实现中,当内部的removeEldestEntry()方法返回 true 时会移除最远最久未访问的节点,也就是链表首部节点 head。evict 只有在构建 Map 的时候才为 false,在单独调用方法时为 true。

这个方法在插入节点之后调用,明显是因为LUR缓存容量有限制,新插入节点之后有可能需要移除最远最久未访问的节点。

/**
 * HashMap提供的空实现
 *
 * @param evict 构造器中传递false,单独调用方法传递true
 */
void afterNodeInsertion(boolean evict) {
}
​
/**
 * LinkedHashMap重写的实现
 *
 * @param evict 构造器中传递false,单独调用方法传递true
 */
void afterNodeInsertion(boolean evict) {
​
    LinkedHashMap.Entry<K, V> first;
    //如果evict为true,并且大链表头节点不为null,并且removeEldestEntry(first)方法返回true
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        //那么调用removeNode移除头节点,这一移除方法中具有afterNodeRemoval方法
        removeNode(hash(key), key, null, false, true);
    }
}

3.2 removeEldestEntry方法

我们看到afterNodeInsertion方法内部调用了removeEldestEntry方法并以返回值作为是否需要移除头节点的判断条件之一。

removeEldestEntry方法是LinkedHashMap 自己的方法,并且还是一个抽象方法。 默认返回false,如果需要让它返回true或者根据代码返回,需要继承 LinkedHashMap 并且覆盖这个方法的实现。

该方法在实现 LRU 的缓存中特别有用,在该方法中可以设置缓存容量,然后比较节点总数和缓存容量的大小,当节点总数超过缓存容量时可以返回true(因为新增节点成功之后会调用afterNodeInsertion方法),然后通过移除最近最久未使用的节点(头节点),从而保证缓存空间足够,并且缓存的数据都是热点数据。

/**
 * 移除最近最少被访问条件之一,通过覆盖此抽象方法可实现不同策略的缓存
 *
 * @param eldest 大链表头节点
 * @return true,移除  false,不移除
 */
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
    return false;
}

2 使用LinkedList实现LRU缓存

当我们基于 LinkedHashMap实现缓存时,通过继承LinkedHashMap并且覆写removeEldestEntry方法,再构造对象是设置accessOrder为true,可以实现自定义策略的 LRU 缓存。比如我们可以根据节点数量判断是否移除最近最少被访问的节点,或者根据节点的存活时间判断是否移除该节点等。

案例:

/**
 * 简单的LRU缓存,通过继承LinkedHashMap来实现
 */
class LRUCache<K, V> extends LinkedHashMap<K, V> {
    /**
     * 缓存容量
     */
    private int maxEntries;
​
    /**
     * 构造器
     *
     * @param maxEntries 最大容量
     */
    LRUCache(Integer maxEntries) {
        //调用父类构造器
        super(maxEntries, 0.75f, true);
        this.maxEntries = maxEntries;
    }
​
    /**
     * 通过重写removeEldestEntry方法,加入一定的条件,满足条件返回true。
     *
     * @param eldest 大链表头节点
     * @return true,表示允许移除头节点;false,表示不允许移除头节点
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        //如果节点数量大于LRU缓存容量,那么返回true
        return size() > maxEntries;
    }
​
    /**
     * 测试
     */
    public static void main(String[] args) {
        //新建LRUCache,容量为5,首先循环存放十次
        LRUCache<Integer, Integer> cache = new LRUCache<>(5);
        for (int i = 0; i < 10; i++) {
            cache.put(i, i * i);
        }
        System.out.println("调用10次插入方法后,缓存的内容======>");
        System.out.println(cache + "\n");
​
        System.out.println("访问键为7的节点后,缓存内容======>");
        cache.get(7);
        System.out.println(cache + "\n");
​
        System.out.println("访问键为1的节点后,缓存内容======>");
        cache.get(1);
        System.out.println(cache + "\n");
​
        System.out.println("插入键值为1的键值对后,缓存内容======>");
        cache.put(1, 1);
        System.out.println(cache);
​
        System.out.println("删除键为6的键值对后,缓存内容:");
        cache.remove(6);
        System.out.println(cache);
​
        System.out.println("插入键值为7的键值对后,缓存内容:");
        cache.put(7, 7);
        System.out.println(cache);
    }
}

如果有什么不懂或者需要交流,可以留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!