LinkedHashMap源码分析及LRU缓存的实现

前言

LRU(Least Recently Used )即最近最少使用,是一种置换算法,也就是说在需要的时候,把最近最少使用的元素移除。

LRU缓存就是一种基于LRU思想的缓存技术,即在向缓存中加入元素的时候,如果给定的缓存空间已满,那么就把缓存中最近最少使用的元素移除,把新元素放入进去。比如你书桌上放了一摞书,每买一本都放上边,你会偶尔抽出其中一本翻阅,看完后,就把它放到最上边。当你觉得书摞得太高了,就选择把最底层的书抽出来放到一边,因为最底层的是你最近最久未看的。

网上对用LinkedHashMap实现LRU缓存算法的博客有很多,在这篇文章的最后,我也会基于LinkedHashMap实现一个简单的LRU缓存。如果单看实现的话很简单,但如果只是会实现总会感觉不踏实,其实这里边有很多值得学习的地方。这里我会先从LinkedHashMap的源码入手,通过源码分析它是如何在HashMap的基础上实现Linked的。如果只想看实现可以直接从目录跳转过去。

源码分析

HashMap

要说LinkedHashMap就绕不过HashMap,因为从源码中(甚至是命名中)可以看到,LinkedHashMap是继承自HashMap的。对HashMap有过了解的人都知道,它先是用自己的内部结点类来封装键值对数据,然后使用一个数组来保存对应的键值对(key-value)结点。

	// HashMap内部结点类,实现了Map.Entry接口
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        // 注意,这个next并不是维护插入顺序的!
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
        // ...省略一些方法
    }
	// 保存结点的数组
	transient Node<K,V>[] table;

HashMap顾名思义,是通过hash函数计算来直接把数据映射到数组上的,它没有链接(Linked)。要实现链接,其实想法也很简单,比如说,如果按照插入顺序维护一个单向链表,那么只需要给Node类添加一个指向后继结点的 next 引用(这里不深究字面含义,理解为指针、引用都可),并且在每次插入(put)的时候,把新结点挂载到链表后边;删除(remove)的时候,把结点从链表中删除即可。

注意:源码中可以看到其实HashMap.Node其实有一个next字段,但这个字段和维护插入顺序没关系。由于HashMap是用拉链法解决hash冲突的,这个next是用于当结点发生冲突时,维护拉链那个链的,这里可以忽略。

LinkedHashMap

按照上边的猜想,去看LinkedHashMap的源码

    static class Entry<K,V> extends HashMap.Node<K,V> {
        Entry<K,V> before, after;
        Entry(int hash, K key, V value, Node<K,V> next) {
            super(hash, key, value, next);
        }
    }
    /**
     * The head (eldest) of the doubly linked list.
     */
    transient LinkedHashMap.Entry<K,V> head;

    /**
     * The tail (youngest) of the doubly linked list.
     */
    transient LinkedHashMap.Entry<K,V> tail;
    /**
     * The iteration ordering method for this linked hash map: <tt>true</tt>
     * for access-order, <tt>false</tt> for insertion-order.
     *
     * @serial
     */
    final boolean accessOrder;

可以看到LinkedHashMap确实有个自己的Entry内部类,并继承自HashMap.Node。这个类也很简单,只是比HashMap.Node多了两个字段:beforeafter,顾名思义,这是维护链表顺序的,并且可以得知LinkedHashMap维护的是双向链表,并且还有两个headtail字段,标记双向链表的头和尾。此外还有个accessOrder属性,说明LinkedHashMap还可以选择维护元素的访问顺序,这就是和LRU挂上钩了。

但是当我们想去看LinkedHashMap是如何在put的时候维护链表的,会发现它压根没重写任何 put方法,所以在使用它的时候,其实调用的还是HashMapput,那么看HashMapput方法:

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null); // (1)
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e); // (2)
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict); // (3)
        return null;
    }

暂时不需要了解 put的实现细节,只关注标注释的(1)、(2)、(3)。

维护插入顺序

查看(1)处: 这里调用newNode将输入的键值对封装成了内部的HashMap.Node对象,并放到数组中。但是注意,LinkedHashMap重写了newNode方法。查看其实现:

  Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
      LinkedHashMap.Entry<K,V> p =
          new LinkedHashMap.Entry<K,V>(hash, key, value, e);
      linkNodeLast(p);
      return p;
  }

它这里是调用了自身的LinkedHashMap.Entry的构造函数,来构造结点。所以此时结点已经带有了beforeafter字段。并且在构建好结点后,执行了linkNodeLast方法。

  // link at the end of list
  private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
      LinkedHashMap.Entry<K,V> last = tail;
      tail = p;
      if (last == null)
          head = p;
      else {
          p.before = last;
          last.after = p;
      }
  }

即把结点链接到双向链表的尾部。所以在使用LinkedHashMap进行put的时候,会调用父类的put方法,但是在新建结点的时候,会调用它重写的newNode方法,对结点偷梁换柱。由于LinkedHashMap.Entry extends HashMap.Node,所以这个子类结点是可以直接放到父类的结点数组中的。只重写了这个方法,就实现了在插入时候维护结点插入顺序。妙啊。

维护访问顺序

查看(2)处:这里调用afterNodeAccess 方法,顾名思义,这里是访问完结点后的回调,肯定是用来维护结点访问顺序的。查看其定义:

    // Callbacks to allow LinkedHashMap post-actions
    void afterNodeAccess(Node<K,V> p) { }
    void afterNodeInsertion(boolean evict) { }
    void afterNodeRemoval(Node<K,V> p) { }

这里有一堆三个空方法,包括注释(3)处的。这里注释说是为了让LinkedHashMap执行回调操作。这也可以理解了,因为HashMap不需要维护链接顺序,所以它自己就放个空方法在这了。当子类实现这些方法的时候,就会去调用子类的。这点和newNode类似,都是基于 Java 多态的设计。

查看子类 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;
        }
    }

这里看着乱糟糟的一堆,其实就是把刚访问过的入参结点p,插入到双向链表的尾部,以示最近访问。并且这里还使用了accessOrder字段,只有在这个字段设置为 true 的时候,即需要维护访问顺序的时候才执行。

那有插入就有删除,类似地,也就可以理解 afterNodeRemoval 方法的实现了,把待删除的结点从链表去删除即可。

现在LinkedHashMap能可选择地维护插入顺序和访问顺序了,那么怎么实现LRU呢?

移除最老的结点

查看(3)处的 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);
        }
    }

这个方法会在满足条件下,删除双向链表的第一个结点,也就是最老的结点(因为之前分析新访问过的和新插入的结点是在尾部)。这里先暂时忽略evict,可以认为它在使用中是 true。所以这里只要存在头结点,并且 removeEldestEntry 方法返回 true ,就会删除最老的结点。

查看 removeEldestEntry 的默认实现:

    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
    }

可以看到在LinkedHashMap中,该方法默认返回 false 也就是不删除。所以我们要想实现自己的LRU缓存,只需要把我们的删除条件写在该方法中即可,那么每次插入元素的时候,都会在 (3)处执行这个回调,并最终根据 removeEldestEntry 方法来判断是否需要删除最老的元素。

利用LinkedHashMap实现LRU缓存

有了上边的分析,其实代码写起来就很简单了。

只需要继承LinkedHashMap,并重写其 removeEldestEntry 方法,其中设置要触发的删除条件。其它的,源码已经帮我们搞定了。

public class LRUCache<K, V> extends LinkedHashMap<K, V> {
	// size标记这个缓存的大小,即可以缓存多少个元素
    private int size;

    public LRUCache(int initialCapacity, float loadFactor, int size) {
    	// 这里调用带accessOrder的构造方法,并将其设置为true
    	// 表示按照访问顺序维护
        super(initialCapacity, loadFactor, true);
        this.size = size;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
    	// 当元素个数大于size的时候就返回true,说明此时条件满足,就会去删除最老的结点
        return this.size() > size;
    }

    public static void main(String[] args) {
    	// 初始容量和负载因子就使用默认值。
        LRUCache<Integer, Integer> cache = new LRUCache<>(16, 0.75f, 5);
        for (int i = 0; i < 5; i++) {
            cache.put(i, i);
        }
        System.out.println(cache);
        cache.put(5, 5);
        System.out.println(cache);
        cache.get(1);
        System.out.println(cache);
    }
}

比如这里把缓存个数设置为5,即当缓存中已有5个元素的时候,再插入会删除最老的元素。输出如下:

{0=0, 1=1, 2=2, 3=3, 4=4}
{1=1, 2=2, 3=3, 4=4, 5=5}
{2=2, 3=3, 4=4, 5=5, 1=1}

当插入5的时候,最老的0被删除了;访问完1后,1被提到了最后边,实现了按访问顺序。

扩展

HashMap.TreeNode为什么继承自LinkedHashMap.Entry

其实上边已经实现了LRU缓存,但是其实源码中还有一些比较有意思的设计。HashMap在1.8以后,引入了红黑树设计,即当hash冲突比较严重,导致拉链比较长的时候,会转换为红黑树。那么LinkedHashMap对于红黑树,是如何维护链表顺序的呢?查看HashMap.TreeNode源码:

    /**
     * Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
     * extends Node) so can be used as extension of either regular or
     * linked node.
     */
    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
        // ... 省略
    }

如果源码看不明白,可以看看类图:
在这里插入图片描述
(红线表示从属内部类关系,蓝线表示继承关系)
???小朋友,你是否有很多的问号。TreeNode作为HashMap的内部类,却继承自LinkedHashMap.EntryTreeNode:我父亲是我兄弟Node的儿子???

按理说,TreeNode应该继承自HashMap.Node比较好理解。但是其实这里也是为了实现链接考虑。假想如果那样的话,对于LinkedHashMap有两种思路:

  1. 它的EntryNode实现了扩展,使其具有链表特点,那么它就还需要一个TreeEntry继承TreeNode,使其具有红黑树的特点,并且要继承自身的Entry,使其具有链表特点。但是这就和 “Java不能多继承” 冲突了。
    在这里插入图片描述
  2. 如果让 LinkedHashMap.Entry 直接继承 HashMap.TreeNodeHashMap.TreeNode直接继承 HashMap.Node,倒也可以实现需求。但是那样的话,如果使用LinkedHashMap,所有结点都将具有TreeNode结点的属性,而转换红黑树的操作是很少发生的,也就是说我们大多数时候都是白白带着一些没用的属性。这里在HashMap中注释也说了,TreeNode结点是常规Node结点的2倍大,所以只在必要的时候才转换。
    在这里插入图片描述
	 /**
	 * ...
     * Because TreeNodes are about twice the size of regular nodes, we
     * use them only when bins contain enough nodes to warrant use
     * (see TREEIFY_THRESHOLD). And when they become too small (due to
     * removal or resizing) they are converted back to plain bins.  
     * ...
     * /

所以,采用这样一种蛇皮操作实现了:在不需要引入红黑树的,结点就是常规结点,没有红黑树的内容。引入红黑树的时候,TreeNode既是继承自Node(父亲是LinkedHashMap.Entry,爷爷是它),保证了数组元素的统一;又在需要的时候有了链表的特点。妙啊。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值