LinkedHashMap 源码分析

57 阅读8分钟

本文首发于公众号:JavaArchJourney

LinkedHashMap介绍

LinkedHashMap 继承自 HashMap 类并且实现了 Map 接口。与 HashMap 相比,LinkedHashMap 提供了额外的功能:它通过双向链表维护了元素的插入顺序或访问顺序。

LinkedHashMap特点如下:

  • 顺序性:默认情况下,LinkedHashMap 会按照元素的插入顺序进行排序。可以在构造函数中通过传递一个额外的布尔参数来控制是否按照访问顺序对元素进行排序(即最近最少使用,Least Recently UsedLRU),通常用于实现 LRU 缓存的实现。
  • 性能LinkedHashMap 在大多数操作上的性能几乎和 HashMap 一样快,包括 get 和 put 操作。但是,由于需要维护元素的顺序,它的时间开销略高于 HashMap
  • 内存占用:相比 HashMapLinkedHashMap 需要额外的内存来维护链表节点,因此其内存占用通常会更高一些。

LinkedHashMap的类继承结构如下:

LinkedHashMap源码分析

由于 LinkedHashMap 继承自 HashMap,因此可以先阅读《HashMap源码分析》了解 HashMap的机制原理。

以 JDK 1.8 为例,对 LinkedHashMap 源码实现进行分析如下:

存储结构

LinkedHashMap存储结构:

《HashMap源码分析》我们已经知道 HashMap 的存储结构是 数组 + 链表/红黑树,数组上的 Node 可能是链表节点( HashMap.Node ),也可能是红黑树节点( HashMap.TreeNode ):

现在 LinkedHashMap 又新增了双向链表的维护,其节点类关系如下图所示:

数据结构代码:

public class LinkedHashMap<K,V>
        extends HashMap<K,V>
        implements Map<K,V>
{
    /**
     *  LinkedHashMap 的 Entry 继承自 HashMap.Node
     */
    static class Entry<K,V> extends HashMap.Node<K,V> {
        /**
         * 通过继承 HashMap.Node 包含了键值对的基本信息
         * 增加了两个额外的引用:before 和 after,用于构建双向链表,以维护元素之间的顺序关系
         */
        Entry<K,V> before, after;
        Entry(int hash, K key, V value, Node<K,V> next) {
            super(hash, key, value, next);
        }
    }

    /**
     * 链表节点 HashMap.Node
     */
    static class Node<K,V> implements Map.Entry<K,V> {
        /**
         * 哈希值
         */
        final int hash;
        /**
         * 键
         */
        final K key;
        /**
         * 值
         */
        V value;
        /**
         * 指向下一个节点
         * 链表节点HashMap.Node / 红黑树节点HashMap.TreeNode(当链表长度超过 8 且数组长度大于 64 时)
         */
        HashMap.Node<K,V> next;

        // 其他省略...
    }

    /**
     * 红黑树节点 HashMap.TreeNode
     */
    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        /**
         * 父节点
         */
        TreeNode<K,V> parent;
        /**
         * 左节点
         */
        TreeNode<K,V> left;
        /**
         * 右节点
         */
        TreeNode<K,V> right;
        /**
         * 在LinkedHashMap的上下文中使用的,用于维护元素插入顺序或访问顺序的双向链表
         */
        TreeNode<K,V> prev;
        /**
         * 节点颜色
         */
        boolean red;

        // 省略其他红黑树操作:增删查改、平衡旋转等...
    }

    /**
     * 双向链表的头部(即最老的元素)
     * transient 表示它不会被序列化
     */
    transient LinkedHashMap.Entry<K,V> head;

    /**
     * 双向链表的尾部(即最新的元素)
     * transient 表示它不会被序列化
     */
    transient LinkedHashMap.Entry<K,V> tail;

    /**
     * 决定迭代顺序是基于访问顺序还是插入顺序
     * 如果为 true,则按照最近最少使用(LRU)的顺序排序
     * 如果为 false,则保持插入顺序
     */
    final boolean accessOrder;
}

LinkedHashMap 存储结构图示:

构造方法

主要是增加 accessOrder 的设置。 accessOrder 默认值为 false,表示按插入顺序排序;显式设置为 true 后,则按照最近最少使用(LRU)的顺序排序。

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

创建节点

当添加新键值对时,LinkedHashMap 使用自己版本的 newNode() / newTreeNode() 方法来创建 Entry 节点,并将其插入到双向链表中。

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

    TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {
        // 创建新的树节点
        TreeNode<K,V> p = new TreeNode<K,V>(hash, key, value, next);
        // 将新创建的节点添加到双向链表的末尾
        linkNodeLast(p);
        // 返回这个新创建的节点
        return p;
    }

    /**
     * 将新节点添加到双向链表末尾
     */
    private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
        // 获取当前双向链表的尾部节点,保存到 last
        LinkedHashMap.Entry<K,V> last = tail;
        // 更新尾部指针:将 tail 更新为新节点 p
        tail = p;
        // 如果 last 为 null,说明这是链表中的第一个节点,因此同时设置 head 也为 p
        if (last == null)
            head = p;
        // 如果不是第一个节点,则调整前一个节点(last)的 after 指针指向新节点 p,并设置新节点 p 的 before 指针指向 last。
        else {
            p.before = last;
            last.after = p;
        }
    }

在每次插入新节点之后(put()putIfAbsent() 等),都会调用 afterNodeInsertion 方法,判断是否需要删除最老节点(即 eldest entry)。
removeEldestEntry() 方法:这是一个可以被子类覆盖的方法,默认返回 false。如果需要基于某些条件自动删除最老的条目(例如实现固定大小的缓存),可以通过重写该方法并根据条件返回 true 来实现。

// 参数 evict 一般为 true,表示可以执行淘汰逻辑
void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    // 是否删除由 removeEldestEntry(first) 决定,默认返回 false
    // 用户可以通过继承 LinkedHashMap 并重写 removeEldestEntry() 方法来自定义淘汰策略(比如限制缓存大小)
    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;
}

总结afterNodeInsertion + 自定义 removeEldestEntry():用于实现固定大小缓存。

访问节点

默认情况下,LinkedHashMap 使用插入顺序( accessOrder=false )排序,若显式设置 accessOrder=true ,则在每次访问节点时,都会触发回调 afterNodeAccess 方法来维护基于访问顺序的链接列表,即当一个条目被访问时,该条目会被移动到双向链表的末尾,表示它是最近访问的。

触发场景

  • get()方法:当调用 get(Object key) 方法来获取一个键对应的值时,如果该键存在于映射中,并且 LinkedHashMap 是按照访问顺序排序的(即 accessOrdertrue),那么 afterNodeAccess 方法就会被调用,将访问的节点移到链表的末尾。
  • put()putVal() 方法:当使用 put(K key, V value) 方法插入一个新的键值对时,如果键已经存在,则更新其值,并且如果 accessOrdertrue,也会触发 afterNodeAccess 方法来调整节点的位置。
  • replace()方法:类似于 put(),如果使用 replace(K key, V value)replace(K key, V oldValue, V newValue) 来替换现有键的值,并且 accessOrder 设置为 true,这同样会导致 afterNodeAccess 被调用。
  • compute, merge, replaceAll 等更复杂的操作:如果这些操作导致了已存在键值对的更新,并且 accessOrdertrue,那么 afterNodeAccess 也会被调用。
  • 迭代器的 next() 方法:虽然这不是直接的 API 调用入口,但在使用迭代器遍历 LinkedHashMap 时,若启用了访问顺序(accessOrder=true),那么每次通过迭代器访问下一个元素时,都会触发 afterNodeAccess 方法。
public V get(Object key) {
    Node<K,V> e;
    if ((e = getNode(hash(key), key)) == null)
        return null;
    // accessOrder=true 时,才回调 afterNodeAccess 方法
    if (accessOrder)
        afterNodeAccess(e);
    return e.value;
}

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的后向链接
        p.after = null;  
        if (b == null)
            // 如果p是头节点,则head更新为a
            head = a;  
        else
            // 前驱指向后继
            b.after = a;  
        if (a != null)
            // 后继指向前驱
            a.before = b;  
        else
            // 如果a为null,说明p原来是尾部,现在新的尾部是b
            last = b;  
        if (last == null)
            // 如果链表此时为空,则设置p为头
            head = p;  
        else {
            // 插入到链表末尾
            p.before = last;  
            last.after = p;
        }
        // 更新tail为p
        tail = p;  
        // 结构性修改计数器+1
        ++modCount;  
    }
}

总结accessOrder=true + afterNodeAccess:用于实现 LRU 缓存

删除节点

当一个节点从哈希表中被删除时,也要从双向链表中摘除它。
HashMap.remove()resize() 等操作中,当某个节点从 HashMap 中移除时( removeNode() ),就会触发 afterNodeRemoval 方法。

void afterNodeRemoval(Node<K,V> e) { // unlink
    LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
    // 清空当前节点的前后指针
    p.before = p.after = null;
    if (b == null)
        // 如果前驱为空,则当前节点是头节点,删除后新头为a
        head = a;
    else
        // 否则前驱的next指向后继
        b.after = a;  
    if (a == null)
        // 如果后继为空,则当前节点是尾节点,删除后新尾为b
        tail = b;  
    else
        // 否则后继的prev指向前驱
        a.before = b;  
}

实现 LRU 缓存的简单示例

public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int maxSize;

    public LRUCache(int maxSize) {
        // accessOrder = true
        super(16, 0.75f, true);  
        this.maxSize = maxSize;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        // 超出容量就删除最老元素
        return size() > maxSize;
    }
}

核心原理总结

  • 继承自HashMapLinkedHashMap 继承了 HashMap 的所有功能,包括快速查找、插入和删除操作(平均时间复杂度为 O(1))。
  • 双向链表维护顺序:除了使用哈希表存储数据外,LinkedHashMap 还通过一个双向链表来维护元素的顺序。这个双向链表可以基于插入顺序或者访问顺序(LRU 缓存策略的基础)。
  • 支持两种排序方式
    • 插入顺序:默认情况下,LinkedHashMap 会按照元素的插入顺序进行排序。
    • 访问顺序(LRU):如果在构造函数中设置了 accessOrder 参数为 true,则 LinkedHashMap 将按照最近最少使用的顺序(即 LRU)对双向链表中的元素进行排序。
  • 主要流程
    • 节点创建:无论是普通的 put() 操作还是由于哈希冲突导致的树化节点创建(newNode()newTreeNode()),都会调用 linkNodeLast() 方法将新节点添加到双向链表的末尾。
    • 节点访问:当调用 get() 方法访问某个节点时,若 accessOrdertrue,则该节点会被移动到双向链表的末尾,实现 LRU 缓存策略。
    • 节点移除:在从映射中移除节点时,afterNodeRemoval() 方法会被调用来更新双向链表,确保其一致性。
    • 自动淘汰最老条目:通过重写 removeEldestEntry() 方法并结合 afterNodeInsertion() 方法,可以根据一定的条件(如缓存大小限制)自动移除最老未使用的条目。
  • 实现 LRU 缓存:通过设置 accessOrder=true 并重写 removeEldestEntry() 方法,可以很容易地实现一个简单的 LRU 缓存机制。