JDK1.8 Collection知识点与代码分析--LinkedHashMap


上一篇文章笔者对Collection包中非常重要的一个类HashMap进行了分析和总结, 如果你对HashMap的知识点有模糊, 建议先读一读JDK1.8 Collection知识点与代码分析–HashMap但是HashMap存在一些问题, 最重要的是HashMap不能够保证存储元素的有序性, 这一点是因为HashMap的遍历是按照桶顺序的, 而节点的插入顺序和桶的顺序无关, 并且还会在resize的时候进一步打乱. 那有没有既能够在常数时间进行增删改查又能够保持有序的Map呢, 于是就有了LinkedHashMap.

LinkedHashMapHashMap的子类, 所以其落脚点是HashMap, 在其基础上, 通过一个双向链表将节点按照插入顺序或访问顺序连接起来, 就能够以较低的成本达到上述的有序的目标.

同时, 因为其实现了按照访问顺序的排序, 所以也是天然的LRU容器, 官方的文档中就有建议, 可以用它来作为LRU缓存, 按照一定的规则删除最老的节点, 例如当size超过一个值时, 删除最长时间未访问的元素. 在文章的最后, 笔者也实现了一个基于LinkedHashMap的LRU管理的demo. 另一方面, 因为是HashMap的子类,LinkedHashMap也不能保证线程安全, 如果需要进行并发, 需要使用它的synchronized的装饰版本. 本文的所有源码分析都是基于JDK1.8版本.

Constructor

LinkedHashMapHashMap的构造器基本一致, 但是多了accessOrder参数, 当它为false时, 按照链表按照插入顺序排序, 当它为true时, 链表按照访问顺序排序

// 其他构造器都无法传入accessOrder参数
public LinkedHashMap(int initialCapacity,  float loadFactor, boolean accessOrder)

对元素的put, putIfAbsent, get, getOrDefault, compute , computeIfAbsent, computeIfPresent, merge操作(假设元素存在)都视作对元素的访问, replace只有元素被替换的时候才视作一次访问, putAll方法对传入的每个元素产生一次访问, 访问的顺序取决于输入的Map的iterator提供的顺序.

Entry

Entry类是HashMap.Node的子类, 它在Node的基础上增加了两个成员变量, beforeafter. 因此Entry类中有三个指针: beforeafter分别指向双向链表的前一个节点和后一个节点, 而next指向桶中的链的下一个节点.

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);
        }
    }

因此, LinkedHashMap的结构示意图大致是这样, 在这里插入图片描述
接下来的问题就是, 添加和删除节点时link是如何处理的, 以及, 当节点被访问时, 在链表中的顺序调整是怎样实现的, 以及, 如果要用它实现LRU应当怎么做.

添加和删除

putVal

LinkedHashMap没有重写put方法, 而是利用HashMapputVal的良好设计, 对afterNodeAccessafterNodeInsertion两个回调函数以及newNode方法进行重写.
我们首先回顾一下HashMap.putVal方法

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 ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null); // 注意这里不是new Node( )
        else {
            // 在桶的链表或树中查找key, 如果不存在也是调用newNode方法创建
            // 如果超过阈值, 进行树化
            }
            if (e != null) { // 如果key存在
                ...
                afterNodeAccess(e);
                return oldValue;
            }
        }
        // 递增modCount, 并判断是否扩容
        afterNodeInsertion(evict);
        return null;
    }

注意这里的newNode函数, 是设计模式中的工厂模式, 有普通Node和TreeNode两个版本, 在HashMap中, 内部就是简单的调用了new产生一个新的实例返回, 但是LinkedHashMap通过重写这两个函数, 除了返回一个新对象, 还将这个对象添加到链表尾.

此外, LinkedHashMap重写了afterNodeInsertion回调函数, 判断是否要将链表表头的元素删除(删除最老的节点), 重写了afterNodeAccess回调函数, 如果是按照访问顺序排序的话, 将被访问的节点移动到链表的末尾.

注意在JDK1.8以前, 这两个函数分别是Entry.recordAccessaddEntry, 1.8把这两个函数的逻辑部分移到putVal中, 而抽象出两个回调函数, 供子类重写, 逻辑上更加的清晰, 也符合面向对象中的开闭原则(开放扩展, 关闭修改).

removeNode

remove操作中, 类似的, HashMap提供了afterNodeRemoval回调函数, 而LinkedHashMap重写了它, 做了双向链表的删除节点操作.

按照访问顺序排序

之前提到了afterAccess回调函数中, 实现了将被访问节点移动到队尾的操作, 源码也就是对双向链表的一个操作.

 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;
        }
    }

然后在`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(first)函数就是判断第一个节点是否应该被删除的条件, 这个函数在源码中默认为返回false的.

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

重写它, 你就能获得力量就能够用来控制LRU策略. 所以最后, 我们一起来写一个demo用LinkedHashMap来手写一个LRU.

LinkedHashMap 手写LRU

public class MyLRUSample {
    public static void main(String[] args) {
        LinkedHashMap<Integer, String> LRU 
                = new LinkedHashMap<Integer, String>(16, 0.75F, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<Integer, String> eldest) {
                return size() > 4; // 当缓存超过4个时, 删除最不长访问的
            }
        };
        LRU.put(1, "1");LRU.put(2, "2");LRU.put(3, "3");LRU.put(4, "4");
        System.out.println("Original order in LRU");
        
        LRU.forEach((k,v)->System.out.println(k + ":" + v));
        LRU.get(3);LRU.get(4);LRU.put(2, "Two");LRU.get(3);
        System.out.println("After some accesses, the order in LRU");
        LRU.forEach((k,v)->System.out.println(k + ":" + v));
        
        LRU.put(5, "5");
        System.out.println("1 has been removed.");
        LRU.forEach((k,v)->System.out.println(k + ":" + v));
    }
}

输出:

Original order in LRU
1:1
2:2
3:3
4:4
After some accesses, the order in LRU
1:1
4:4
2:Two
3:3
1 has been removed.
4:4
2:Two
3:3
5:5

结果和预期一致, 同时注意到, 重写过的iterator的访问顺序是从链表头到链表尾的.

参考资料:
Map 综述(二):彻头彻尾理解 LinkedHashMap

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章