深入理解 LinkedHashMap

前言

  第一次看见 LinkedHashMap,还是暑假看《Java 核心技术卷 I》的集合那一章时,里面说了,LinkedHashMap 可以用访问顺序对元素进行迭代,并且还可以基于 LinkedHashMap 来实现一个 LRU 策略。第二次看见它,就是做力扣的这道题了:146. LRU 缓存机制,我当时直接用 LinkedHashMap 来做了。今天,我们就从源码角度看看,LinkedHashMap 到底为啥可以按访问顺序迭代元素,又是怎么可以做一个 LRU 的。

LinkedHashMap 基本结构

public class LinkedHashMap<K,V>
    extends HashMap<K,V>
    implements Map<K,V>
{
    // 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);
        }
    }
    // 双向链表的头尾节点
    transient LinkedHashMap.Entry<K,V> head;
    transient LinkedHashMap.Entry<K,V> tail;
    // 是否按访问顺序迭代
    final boolean accessOrder;
}
复制代码

  先看下 LinkedHashMap 的基本结构和属性,可见,它是继承了 HashMap 类的,同时也实现了 Map接口,故可以使用 HashMap 的方法和属性。LinkedHashMap 有自己的节点结构:内部类 Entry,可以看到,此Entry继承了 HashMap 中的Node,同时 LinkedHashMap 还有Entry类型的 head 和 tail 两个指针。看到这,我们猜想,这个 LinkedHashMap 应该维护了一个双向链表结构,但是具体怎么维护的,之后再说。最后,是accessOrder用来表示 LinkedHashMap 是否按访问顺序组织链表结构。

LinkedHashMap 初始化

public LinkedHashMap(int initialCapacity, float loadFactor) {
    super(initialCapacity, loadFactor);
    accessOrder = false;
}

public LinkedHashMap(int initialCapacity) {
    super(initialCapacity);
    accessOrder = false;
}

public LinkedHashMap() {
    super();
    accessOrder = false;
}

public LinkedHashMap(Map<? extends K, ? extends V> m) {
    super();
    accessOrder = false;
    putMapEntries(m, false);
}
// 可以指定 accessOrder
public LinkedHashMap(int initialCapacity,
                     float loadFactor,
                     boolean accessOrder) {
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder;
}
复制代码

  初始化这块,和那些常用的类一样,都有好几个版本的构造函数,注意到,只有最后一个函数可以指定accessOrder。然后构造函数都是通过调用父类 HashMap 的构造函数,大家可以去找找 HashMap 的博客看看,不多赘述了。

newNode 方法

  要说 LinkedHashMap 的其它方法,必须先说下newNode,先回顾下 HashMap 里的newNode

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);
    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);
        // 其它一堆代码

// newNode 方法
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
    return new Node<>(hash, key, value, next);
}
复制代码

  比方说 HashMap 的putVal方法中newNode出现了两次,一次是哈希到对应的桶里发现桶是空的,这肯定要新建节点了,一次是遍历到当前桶的末尾了,新建节点到桶的单链表的末尾。这个newNode也很简单,就是新建个 HashMap 的节点。这都是 HashMap 的操作,这里简单提一下。LinkedHashMap 重写了这个函数,让我们看一下:

Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p =
        new LinkedHashMap.Entry<>(hash, key, value, e);
    // 新建的节点插入到链表尾部
    linkNodeLast(p);
    return p;
}  
// 新建的节点插入到链表尾部
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;
    }
}
复制代码

  这个 Entry,之前已经说了,继承了 HashMap 的 Node 类。之后会说到,LinkedHashMap 自己并没有重写 put 相关的方法。那么假如 LinkedHashMap 调用了一个putVal这样的方法,遇到要新建节点的情况时,调用newNode,一方面,Entry 是 HashMap 的 Node 的子类,可以放到 HashMap 的桶 + 链表结构里,另一方面,这个 Entry 节点又会被组织到 LinkedHashMap 特有的双向链表结构尾部。

  也就是说,LinkedHashMap 其实维护了两个结构:一个是 HashMap 的桶+链表或红黑树,另一个是自己的双向链表结构。这个链表结构用来提供那些迭代顺序之类的操作。

get 过程

  put 和 get 是 Map 家族最常用的操作,首先看看 get,LinkedHashMap 重写了两个 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;
 }

 public V getOrDefault(Object key, V defaultValue) {
    Node<K,V> e;
    if ((e = getNode(hash(key), key)) == null)
        return defaultValue;
    if (accessOrder)
        afterNodeAccess(e);
    return e.value;
}
复制代码

  可见,两个 get 完成了之后,都会运行下面的代码:

if (accessOrder)
    afterNodeAccess(e);
复制代码

  看一下这个代码在干什么:

void afterNodeAccess(Node<K,V> e) { // move node to last
    LinkedHashMap.Entry<K,V> last;
    // accessOrder 为 true,且节点不在链表尾
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        // 把节点 p(就是e)从原链表删除
        p.after = null;
        if (b == null)
            head = a;
        else
            b.after = a;
        if (a != null)
            a.before = b;
        else
            last = b;
        // 把 p 插到链表尾部
        if (last == null)
            head = p;
        else {
            p.before = last;
            last.after = p;
        }
        tail = p;
        ++modCount;
    }
}
复制代码

  这个代码看着很长,其实很简单,就是把参数传入的节点移到链表尾,分了两个步骤,先移除,再插到链表尾。双向链表的常见操作,有问题可以看这篇博客:LinkedList 源码解析

  所以说,当accessOrder设置为 true 的时候,进行 get 操作,并且得到了key对应的值(也就是找到了),那么会把对应节点移到 LinkedHashMap 维护的双向链表的尾部。

put 过程

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) {
    // 为了 put 而做的一堆操作
    afterNodeInsertion(evict);
    return null;
}
复制代码

  LinkedHashMap 自己并没有重写 put 方法,所以放上了 HashMap 的 put 方法。大家当时看 HashMap 源码的时候,估计都会很疑惑,这个afterNodeInsertion(evict);是干啥的?这个其实就是给 LinkedHashMap 用的。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);
    }
}  
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return false;
}
复制代码

  新插入了一个节点之后,会进行if (evict && (first = head) != null && removeEldestEntry(first))这一长串的判断,判断为真,会将链表的头节点删除。

  看一下这个判断条件,首先看 evict。

public V putIfAbsent(K key, V value) {
    return putVal(hash(key), key, value, true, true);
}
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}  
// 其它调用 putVal 的函数
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {}
复制代码

  这个 evict 主要还是看调用 putVal 的方法给传的是什么。除了初始化的大部分情况下都是 true。

  (first = head) != null 就是维护的链表结构不是空的。

  removeEldestEntry(first),这个函数默认是返回 false:

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

  不过它是 protected 的,我们可以通过继承来重写它。

  不管怎样,总之,每次插入了新节点之后,都会进行判断,看看是否需要删除链表的头节点。

问题解答

怎么按访问顺序迭代?

  看了上面这些函数的解析,我们可以对一些问题进行回答了,首先是如何按访问顺序迭代。从 get 方法的解析里,可以看出,每次 get 之后,会调用afterNodeAccess,如果你将accessOrder设置为 true,是会将此访问节点插到链表尾的,当然,如果没有设置,根据 newNode 方法源码中的linkNodeLast,会按插入顺序维护此链表。看一个简单的使用例子:

public class someInterface {
    public static void main(String[] args){
        // accessOrder 设置为 true
        LinkedHashMap<String,Integer> testLink = new LinkedHashMap<>(10,0.75f,true);
        testLink.put("a",1);
        testLink.put("b",2);
        testLink.put("c",3);
        testLink.get("b");
        Iterator<Map.Entry<String,Integer>> iterator = testLink.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry<String,Integer> next = iterator.next();
            System.out.println("key:" + next.getKey() + " value:" + next.getValue());
        }
    }
}
复制代码

  这里我们初始化了一个 LinkedHashMap 对象,accessOrder 设置为 true,put 了之后调用了 get,看一下运行结果:

image.png

  和我们预期的访问顺序是相符的,最后访问了 b 这个键,因此它位于链表尾。

  按访问顺序访问的关键除了 accessOrder 的设置和afterNodeAccess的操作,其实还有 LinkedHashMap 独特的迭代器:

public Set<Map.Entry<K,V>> entrySet() {
    Set<Map.Entry<K,V>> es;
    return (es = entrySet) == null ? (entrySet = new LinkedEntrySet()) : es;
}

final class LinkedEntrySet extends AbstractSet<Map.Entry<K,V>> {
    // 其它函数
    public final Iterator<Map.Entry<K,V>> iterator() {
        return new LinkedEntryIterator();
    }
    // 其它函数
}
final class LinkedEntryIterator extends LinkedHashIterator
    implements Iterator<Map.Entry<K,V>> {
    public final Map.Entry<K,V> next() { return nextNode(); }
}
// 迭代的关键所在
abstract class LinkedHashIterator {
    LinkedHashMap.Entry<K,V> next;
    LinkedHashMap.Entry<K,V> current;
    int expectedModCount;

    LinkedHashIterator() {
        // 初始化 next
        next = head;
        expectedModCount = modCount;
        current = null;
    }

    public final boolean hasNext() {
        return next != null;
    }
    // 迭代的核心代码
    final LinkedHashMap.Entry<K,V> nextNode() {
        LinkedHashMap.Entry<K,V> e = next;
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        if (e == null)
            throw new NoSuchElementException();
        current = e;
        // 下一个 next,迭代
        next = e.after;
        return e;
    }
}
复制代码

  经过一系列冗长的调用,最终迭代的关键还是 LinkedIterator 的 nextNode 方法。next 被初始化为 head,并且通过next = e.after;设置下一个 next 的值。这种机制就决定了:LinkedHashMap 的迭代器是按自己维护的这个双向链表来迭代的,和传统的 HashMap 很不同。

如何实现 LRU?

public class someInterface {
    public static void main(String[] args){
        LinkedHashMap<String,Integer> testLink = new LinkedHashMap<>(10,0.75f,true){
        // 重写 removeEldestEntry
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, Integer> eldest) {
                return size() > 3;
            }
        };
        testLink.put("a",1);
        testLink.put("b",2);
        testLink.put("c",3);
        testLink.get("a");
        testLink.put("d",4);
        Iterator<Map.Entry<String,Integer>> iterator = testLink.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry<String,Integer> next = iterator.next();
            System.out.println("key:" + next.getKey() + " value:" + next.getValue());
        }
    }
}
复制代码

  咱们上面对 put 分析过了,put 完成之后,会在afterNodeInsertion里判断是否需要删除链表头节点,由于正常情况下,if (evict && (first = head) != null && removeEldestEntry(first))的前两个判断条件都是 true,而 removeEldestEntry 默认为返回 false,所以,我们得重写这个函数。在例子里,我们通过return size() > 3;设置了最大容量为 3。如果容量大于 3,再插入新节点时,就会移除链表头节点,而根据前面的分析,链表尾节点就是最久没被使用的节点。我们看看这段代码的执行情况:

image.png

  中间调用了testLink.get("a");,所以最终 b 的节点被删了,符合 LRU 的原则。

总结

  • LinkedHashMap 继承了 HashMap,拥有 HashMap 的功能
  • LinkedHashMap 维护了两个数据结构,一是 HashMap 的结构,二是用来做迭代的双向链表
  • LinkedHashMap 独特的迭代器设计和一些函数的重写,导致迭代器按双向链表迭代,并且若没有设置 accessOrder,则按插入顺序迭代,否则,按访问顺序迭代
  • 通过重写removeEldestEntry可以实现 LRU 的功能

猜你喜欢

转载自juejin.im/post/7036990792912601124