LinkedHashMap

LinkedHashMap 源码分析

HashMap 的遍历顺序不可预测,LinkedHashMap 用最小代价补上这一点:每个节点在哈希桶之外再挂进一条贯穿全表的双向链表,查找仍是哈希的 O(1),遍历严格按插入顺序(accessOrder=true 时按访问顺序)。
它自己几乎没有”算法”,全部秘密在于 HashMap 预留的三个回调钩子;accessOrder 加上重写 removeEldestEntry,就是一个现成的 LRU。

// 基于 JDK 25 (本地 D:/1ForCode/JAVA_Source), java.util.LinkedHashMap —— 骨架与链表原语
public class LinkedHashMap<K,V>
    extends HashMap<K,V>
    implements SequencedMap<K,V>   // JDK 21 起实现 SequencedMap:"有顺序"从此有了官方接口(reversed/putFirst/putLast)
{
    static class Entry<K,V> extends HashMap.Node<K,V> {
        Entry<K,V> before, after;   // 双向链表指针,独立于哈希桶的 next——同一个节点同时挂在两套结构上
        Entry(int hash, K key, V value, Node<K,V> next) {
            super(hash, key, value, next);
        }
    }

    transient LinkedHashMap.Entry<K,V> head;   // 最老的节点(eldest),LRU 淘汰从这里拿

    transient LinkedHashMap.Entry<K,V> tail;   // 最新的节点。早期版本用环形哨兵 header,后改为裸头尾双指针

    final boolean accessOrder;   // false=插入顺序(默认);true=访问顺序,get 也会挪节点 -> LRU 的总开关。final:中途不能改

    static final int PUT_NORM = 0;
    static final int PUT_FIRST = 1;   // JDK 21 的 putFirst/putLast 靠这个临时标志复用 put 全链路,而不是复制一份 putVal
    static final int PUT_LAST = 2;
    transient int putMode = PUT_NORM;

    // link at the end of list
    private void linkNodeAtEnd(LinkedHashMap.Entry<K,V> p) {   // JDK 17 里叫 linkNodeLast;21 起为 putFirst 加了头插分支
        if (putMode == PUT_FIRST) {
            LinkedHashMap.Entry<K,V> first = head;
            head = p;
            if (first == null)
                tail = p;
            else {
                p.after = first;
                first.before = p;
            }
        } else {
            LinkedHashMap.Entry<K,V> last = tail;
            tail = p;
            if (last == null)
                head = p;               // 空链表:头尾同指
            else {
                p.before = last;
                last.after = p;
            }
        }
    }

    // apply src's links to dst
    private void transferLinks(LinkedHashMap.Entry<K,V> src,
                               LinkedHashMap.Entry<K,V> dst) {   // 树化/退树时 HashMap 会换掉节点对象,链表指针必须跟着搬家
        LinkedHashMap.Entry<K,V> b = dst.before = src.before;
        LinkedHashMap.Entry<K,V> a = dst.after = src.after;
        if (b == null)
            head = dst;
        else
            b.after = dst;
        if (a == null)
            tail = dst;
        else
            a.before = dst;
    }

    Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {   // HashMap.putVal 造节点的工厂钩子:造完顺手挂上链表
        LinkedHashMap.Entry<K,V> p =
            new LinkedHashMap.Entry<>(hash, key, value, e);
        linkNodeAtEnd(p);
        return p;
    }

    TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {   // 桶树化后新插入走这里,链表照挂:遍历顺序不受树化影响
        TreeNode<K,V> p = new TreeNode<>(hash, key, value, next);
        linkNodeAtEnd(p);
        return p;
    }

    // ... replacementNode / replacementTreeNode 略:链表节点与树节点互转时新建对象 + transferLinks 搬指针

    public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {   // 唯一能打开 accessOrder 的构造器,其余构造器一律 false
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }
}
// 基于 JDK 25 (本地 D:/1ForCode/JAVA_Source), java.util.LinkedHashMap —— 三个回调钩子与 get
void afterNodeRemoval(Node<K,V> e) { // unlink    HashMap.removeNode 删完桶后回调:把节点从链表上摘下
    LinkedHashMap.Entry<K,V> p =
        (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
    p.before = p.after = null;      // 断引用,防止已删节点把整条链钉在内存里
    if (b == null)
        head = a;
    else
        b.after = a;
    if (a == null)
        tail = b;
    else
        a.before = b;
}

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 在构造/反序列化灌数据时是 false,半成品阶段不触发淘汰
    }
}

// Called after update, but not after insertion
void afterNodeAccess(Node<K,V> e) {
    LinkedHashMap.Entry<K,V> last;
    LinkedHashMap.Entry<K,V> first;
    if ((putMode == PUT_LAST || (putMode == PUT_NORM && accessOrder)) && (last = tail) != e) {   // JDK 17 的条件只有 accessOrder;已在尾部就什么都不做
        // move node to last
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        p.after = null;             // 先摘:下面四个分支处理 p 是头/是中间两种位置
        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;                 // 挪链表不改 size 但改遍历顺序,也算结构修改——accessOrder=true 时迭代中 get 会 fail-fast,经典坑
    } else if (putMode == PUT_FIRST && (first = head) != e) {
        // move node to first
        // ... 与上一分支镜像对称:把 p 摘下挂到 head,putFirst 覆盖已有 key 时走这里,略
    }
}

public V get(Object key) {
    Node<K,V> e;
    if ((e = getNode(key)) == null)   // 查找完全复用 HashMap.getNode,重写 get 只为了下面两行
        return null;
    if (accessOrder)
        afterNodeAccess(e);           // 读也"提鲜":LRU 里的 R 就是这一行
    return e.value;
}

// ... getOrDefault 同样补了 afterNodeAccess;containsKey 没重写——探测不算"使用",不影响 LRU 顺序

protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return false;   // 默认永不淘汰。做 LRU 只需重写这一个方法,链表代码一行不用碰
}
// 基于 JDK 25 (本地 D:/1ForCode/JAVA_Source), java.util.LinkedHashMap —— 顺序遍历与迭代器
public boolean containsValue(Object value) {
    for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after) {   // 顺链表走 O(size);HashMap 得扫整个桶数组 O(capacity)
        V v = e.value;
        if (v == value || (value != null && value.equals(v)))
            return true;
    }
    return false;
}

abstract class LinkedHashIterator {
    LinkedHashMap.Entry<K,V> next;
    LinkedHashMap.Entry<K,V> current;
    int expectedModCount;
    boolean reversed;               // JDK 21 的 reversed() 视图复用同一个迭代器,只是反着走

    LinkedHashIterator(boolean reversed) {
        this.reversed = reversed;
        next = reversed ? tail : 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)   // accessOrder=true 时迭代中 get 也会改 modCount,在这里炸 CME
            throw new ConcurrentModificationException();
        if (e == null)
            throw new NoSuchElementException();
        current = e;
        next = reversed ? e.before : e.after;   // 顺链表走,顺序与桶分布完全无关
        return e;
    }

    public final void remove() {
        Node<K,V> p = current;
        if (p == null)
            throw new IllegalStateException();
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        current = null;
        removeNode(p.hash, p.key, null, false, false);   // 走 HashMap.removeNode,回调 afterNodeRemoval 同步断链
        expectedModCount = modCount;
    }
}

一个开箱即用的 LRU 缓存,全部逻辑就靠上面的钩子:

代码块JAVA · 11 行收起展开
class LRUCache<K,V> extends LinkedHashMap<K,V> {
    private final int capacity;
    LRUCache(int capacity) {
        super(16, 0.75f, true);   // accessOrder=true:get/put 都算"使用",用过的沉到尾部
        this.capacity = capacity;
    }
    @Override
    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return size() > capacity;  // 超容量就淘汰链表头(最久未访问的那个)
    }
}

原理串讲

以 accessOrder=true 的 LRU 场景走一遍完整链路。
put(k, v) 没有被 LinkedHashMap 重写,走的仍是 HashMap.putVal:定位桶之后若是新 key,putVal 调用工厂方法 newNode——LinkedHashMap 重写了它,new Entry 之后顺手 linkNodeAtEnd(p) 把节点接到 tail;
putVal 收尾处回调 afterNodeInsertion(true),它取出 head(最老节点)去问 removeEldestEntry(first),你重写的这个方法返回 true 时当场 removeNode 淘汰,而 removeNode 删完桶又回调 afterNodeRemoval 把节点从链表摘下——一次 put 的”进一个、出一个”就此闭环。
若 key 已存在,putVal 不造新节点,覆盖 value 后回调 afterNodeAccess(e),把 e 摘下重挂到 tail。
读路径同理:get 先用父类 getNode 按哈希定位(O(1) 不变),命中且 accessOrder 为 true 时调 afterNodeAccess 提鲜。
head 于是永远沉淀着最久未用的节点,淘汰只需 O(1) 取头。

为什么用回调钩子而不重写 put/remove?HashMap 的 putVal/removeNode 里预埋了 afterNodeAccess/afterNodeInsertion/afterNodeRemoval 三个空方法(模板方法模式),LinkedHashMap 只填钩子,哈希定位、扩容、树化那一大坨逻辑全部原样复用;HashMap 也因此完全不需要知道子类在维护什么结构。
代价是钩子永远躺在 HashMap 的热路径上,好在对 HashMap 实例这些是空方法,JIT 内联后近乎免费。

为什么 afterNodeAccess++modCount?挪链表不改 size,但迭代器是顺着 after 链走的:如果 get 把一个尚未遍历到的节点挪去尾部,这个节点会被遍历两次;把已遍历过的挪走则顺序错乱。
所以顺序变更必须视为结构修改,宁可 fail-fast 也不给出错误结果——反过来记:accessOrder=true 的 map 在 for-each 里调 get 必炸 ConcurrentModificationException。

为什么 afterNodeInsertion 要带 evict 参数?LinkedHashMap(Map) 构造和反序列化都靠 putMapEntries 灌数据,此时传 evict=false:对象还是半成品,不该触发用户重写的淘汰逻辑(此时子类字段可能还没初始化)。只有正常的 put 才传 true。

树化为什么不破坏顺序?HashMap.TreeNode 在继承链上是 TreeNode -> LinkedHashMap.Entry -> HashMap.Node,树节点天生带 before/after;桶内节点在链表形态与树形态之间转换时新建对象,由 transferLinks 把两根指针原位接管。
桶里怎么折腾是哈希的事,全局遍历顺序只认这条双向链表。

设计取舍

  • 每个节点多背 before/after 两根指针,换来确定性遍历顺序,且遍历成本从 HashMap 的 O(capacity) 降到 O(size)——大容量稀疏表里反而更快。
  • HashMap.TreeNode 继承 LinkedHashMap.Entry:普通 HashMap 的树节点也白背两根指针。JDK 注释明说这是刻意为之——树桶本就罕见,用一点空间换掉两套节点类的复杂度。
  • accessOrder=true 之后 get 是写操作:并发读也会写坏链表,包一层 Collections.synchronizedMap 时 get 同样要进锁;这也是它只配当单机小缓存的原因。
  • removeEldestEntry 只在插入后被调用、一次最多淘汰一个,是”容量恒定”钩子,做不了按时间过期;要过期、要并发、要命中率统计,生产上直接上 Caffeine。
  • putFirst/putLast 用临时字段 putMode 改写 linkNodeAtEnd/afterNodeAccess 的行为来复用 put 全链路,代价是这个字段线程不安全——本类本来就不是线程安全的,索性不设防。

延伸阅读