基于LinkedHashMap实现LRU

Posted by kyle on March 25, 2019

LRU(Least Recently Used)是内存管理中的一种页面置换算法,在工程中,也往往被用作缓存的淘汰算法,如在Redis中用来选择缓存中需要淘汰的值。

它的算法核心思想是:

  • 写入、读取缓存时,该缓存都算作被访问一次
  • 缓存被访问时,更新其访问时间
  • 当缓存大小超过容量时,淘汰更新时间最远的缓存

具体实现的时候,我们不需要记录其访问时间,可以通过队列来标识访问顺序,比如将最近访问的节点插入队头,那么每次淘汰的时候都是选择淘汰队尾节点。

LinkedHashMap是Java核心内库里实现的一种有序哈希表,继承自HashMap,并实现了Map接口。它与HashMap在结构上主要有两点区别:

  • LinkedHashMap多了两个Entry节点:before、after,以实现链表的双向遍历
  • 引入了对节点顺序的控制,新增了一个accessOrder布尔属性:当它为false时,和HashMap一样,新插入的节点在发生哈希冲突时通过拉链法存在链尾;当它为true时,每次访问(get\put)某个节点时,都会将这个节点重新放置于链尾。

在LeetCode上刷LRU的时候,看到一种绝妙的算法,提交者直接利用LinkedHashMap,短短十来行便实现了LRU。

他的算法如下:

class LRUCache extends LinkedHashMap<Integer, Integer> {
    private int capacity;
    
    public LRUCache(int capacity) {
        super(capacity, 0.75f, true);
        this.capacity = capacity;
    }

    public int get(int key) {
        return super.getOrDefault(key, -1);
    }

    public void put(int key, int value) {
        super.put(key, value);
    }
    
    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        return size() > capacity;
    }
}

基本上,这位大佬啥都没干,把核心的处理逻辑都交给了LinkedHashMap自己来处理。

比对下自己的代码,为了实现LRU,我定义的数据结构如下:

    ListNode cacheListHead;
    ListNode cacheListTail;

    Map<Integer, Integer> cache = new HashMap<>();
    int count = 0;
    int capacity;

ListNode是我构造的链表节点,我采取的是维护一个访问队列的方式,来“标识”访问顺序,最近访问节点插入队头,每次淘汰队尾节点。cache这个HashMap用来存储键值对缓存,count标识当前缓存数据数目,capacity代表缓存总容量。

仔细思考下,每次我get数据的时候,都要从cacheListHead开始遍历到队尾,如果以空间换时间,将cache对象进行下改造:其Key仍旧代表缓存数据的Key值,但Value不直接存储缓存的数据,而是改成存储ListNode,那么我就可以直接通过Key来获取到相应的节点。

    ListNode cacheListHead;
    ListNode cacheListTail;

    Map<Integer, ListNode> cache = new HashMap<>();
    int count = 0;
    int capacity;

这样,通过Key值查找对应节点的算法复杂度直接降成了O(1)。但此时还有一个问题,由于一开始我设计的ListNode只是单向节点,这样即便我找到了对应的节点,仍旧无法快速的调整这个节点的位置。所以我将ListNode改成了双向节点:

    public static class ListNode {
        public int val;
        public ListNode pre;
        public ListNode next;
        public ListNode(int val, ListNode pre, ListNode next) {
            this.val = val;
            this.pre = pre;
            this.next = next;
        }
    }

这样一来,我实现方法中的数据结构不也变成了一个LinkedHashMap么!!!

接下来,我研究了下LinkedHashMap的实现。

LinkedHashMap有个三参数的构造函数,

    public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }

initialCapacity初始容量,和HashMap一致,初始容量是16;loadFactor加载因子,默认0.75,用于求取扩容阈值,扩容阈值 = 容量 * 加载因子;accessOrder指定排序方式:true按访问顺序排序,false按插入顺序排序。

在LinkedHashMap中没有重写HashMap的put方法,只是在HashMap的对象发起put的时候,会调用afterNodeAccess()方法,LinkedHashMap重写了afterNodeAccess()

    void afterNodeAccess(Node<K,V> e) { // move node to last
        LinkedHashMap.Entry<K,V> last;
        if (accessOrder && (last = tail) != e) {
            LinkedHashMap.Entry<K,V> p =
                (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
            p.after = null;
            if (b == null)
                head = a;
            else
                b.after = a;
            if (a != null)
                a.before = b;
            else
                last = b;
            if (last == null)
                head = p;
            else {
                p.before = last;
                last.after = p;
            }
            tail = p;
            ++modCount;
        }
    }

可以看到,当accessOrder为true的时候,会将访问的节点移到链尾。

而在get的时候,LinkedHashMap重写了HashMap的get方法,

    public V get(Object key) {
        Node<K,V> e;
        if ((e = getNode(hash(key), key)) == null)
            return null;
        if (accessOrder)
            afterNodeAccess(e);
        return e.value;
    }

其原理与get一致,也是在最后通过afterNodeAccess()方法来重置节点的位置。

在搞清了LinkedHashMap如何调整节点访问顺序之后,就到了最关键的地方了:如何按照LRU算法的要求淘汰最近最少使用的数据?

在HashMap put处理逻辑的最后,HashMap调用了afterNodeInsertion()方法,而LinkedHashMap重写了这个方法。

    void afterNodeInsertion(boolean evict) { // possibly remove eldest
        LinkedHashMap.Entry<K,V> first;
        if (evict && (first = head) != null && removeEldestEntry(first)) {
            K key = first.key;
            removeNode(hash(key), key, null, false, true);
        }
    }

在可以看到大佬的代码里面重写了removeEldestEntry()方法。当当前数据节点数目大于cache容量的时候,便会移除跳跃表中的首节点。根据LinkedHashMap最新访问节点置于链表尾部的规则,链首节点刚好是LRU算法规则中需要被淘汰的节点。

总结:
LinkedHashMap本身的特质,让它可以作为LRU算法绝佳的实践。

TIPS:HashMap的扩容规则
容量:存储值的节点数组大小
扩容阈值:put之后,若当前数据节点数大于这个值,便会开始扩容

HashMap的扩容规则主要定义在resize()方法里,LinkedHashMap直接继承了这个方法,大致规则如下:
1、新的扩容阈值初始化为0
2、旧容量大于等于\( 2^{ 30 } \)时,扩容阈值提升为\( 2^{ 31 } \)
3、旧容量小于\( 2^{ 30 } \)时,容量扩容2倍,此时若旧容量的2倍大于等于16但小于\( 2^{ 30 } \),扩容阈值也提升至旧阈值的2倍
4、旧容量小于等于0,且旧扩容阈值大于0,容量扩容为旧扩容阈值大小
5、旧容量小于等于0,且旧扩容阈值小于等于0,容量设置为16,新扩容阈值 = 初始容量 * 加载因子(暂时发现只有调用无参数构造函数时会存在这种情况)
6、若经过上述判断处理,得到的新扩容阈值仍为0,则若新容量小于\( 2^{ 30 } \)且新容量 * 加载因子也小于\( 2^{ 30 } \),则新扩容阈值 = 新容量 * 加载因子;否则,新扩容阈值 = \( 2^{ 31 } \)
7、新建一个大小为新容量值的节点数组,在此基础上,对旧节点数组的数据进行重新HASH计算,然后写入新数组中