集合-Map-LinkedHashMap

6 阅读39分钟

概述

在 Java 集合框架的 Map 家族中,LinkedHashMap 占据着一个独特而精妙的生态位。它直接继承自 HashMap,不仅完整继承了哈希表近乎 O(1) 的高效存取性能,更在其内部编织了一张贯穿所有节点的双向链表,从而在混沌的哈希桶之外,维护了一个可预测的迭代顺序。 这种设计使得 LinkedHashMap 成为连接“无序效率”与“有序需求”之间的完美桥梁,无论是需要按插入顺序遍历的业务场景,还是实现经典的 LRU(最近最少使用)缓存策略,它都是不二之选。本文将深入 JDK 8 源码,层层剥开 LinkedHashMap 通过重写钩子方法来无侵入地维护双向链表的精巧设计,彻底揭示其顺序维护的底层奥秘。

  • 继承 HashMap 的双向链表扩展:LinkedHashMap 并非另起炉灶,而是通过继承 HashMap 并重写 newNode 等关键方法,将哈希桶中的普通节点替换为包含 before/after 指针的 Entry,巧妙地构建了一个贯穿全部节点的双向链表。
  • 两种顺序模式:通过 accessOrder 字段,LinkedHashMap 支持两种迭代顺序——插入顺序(默认)保持元素首次被 put 的顺序;访问顺序accessOrder=true)则在每次 getput 更新时,将访问的元素移至链表尾部,为 LRU 缓存奠定基础。
  • 钩子方法体系:LinkedHashMap 的优雅之处在于,它仅仅通过重写 HashMap 预留的 afterNodeAccessafterNodeInsertionafterNodeRemoval 三个回调钩子,就实现了对双向链表的全生命周期管理,对 put/get/remove 等核心操作毫无侵入性。
  • LRU 缓存的天然支持:通过结合 accessOrder=true 并重写 removeEldestEntry 方法,开发者可以极其简洁地实现一个固定容量、自动淘汰最久未使用元素的缓存,这是 LinkedHashMap 最经典的应用模式。
  • 性能与内存的开销:其所有基本操作(putgetremove)的平均时间复杂度与 HashMap 一致,均为 O(1)。但作为代价,每个节点需额外维护两个指针引用,带来了少量的内存开销和链表维护的微小时间成本。

本文将按以下架构展开:

graph TD
    subgraph Part1["Part1 基础认知篇"]
        M1["模块1 定义 核心特性与适用场景"]
        M2["模块2 接口与继承体系"]
    end

    subgraph Part2["Part2 存储与构造篇"]
        M3["模块3 存储结构与核心字段"]
        M4["模块4 构造方法"]
    end

    subgraph Part3["Part3 核心原理篇"]
        M5["模块5 双向链表的维护 钩子方法体系"]
        M6["模块6 插入顺序模式下的 put 与遍历"]
        M7["模块7 访问顺序模式与 LRU 实现"]
        M8["模块8 get 与 remove 操作"]
    end

    subgraph Part4["Part4 迭代与序列化篇"]
        M9["模块9 迭代器 基于链表的顺序遍历"]
    end

    subgraph Part5["Part5 对比与陷阱篇"]
        M10["模块10 vs HashMap vs TreeMap"]
        M11["模块11 常见陷阱与最佳实践"]
    end

    subgraph Part6["Part6 总结与面试篇"]
        M12["模块12 注意事项与性能总结"]
        M13["模块13 面试高频专题"]
    end

    Intro["概述"] --> Part1
    Part1 --> Part2
    Part2 --> Part3
    Part3 --> Part4
    Part4 --> Part5
    Part5 --> Part6

图表说明: 这张 LinkedHashMap 深度解析全景架构图 清晰地展示了本文的六大篇章及十三个核心模块的递进关系。

  • 第一层:Part 1 基础认知篇
    • 作为文章的起点,本篇章旨在为读者建立对 LinkedHashMap 的宏观认知。模块 1 通过定义、特性、适用与不适用场景的决策树,精准界定其定位。模块 2 则从类继承体系出发,揭示其与 HashMap 的父子关系,为后续的源码分析打下“它是谁”的基础。
  • 第二层:Part 2 存储与构造篇
    • 在有了宏观认知后,本篇章深入到 LinkedHashMap 的内部。模块 3 将重点剖析其存储单元 Entry 和核心字段 head/tail/accessOrder,并用类图展示其结构。模块 4 则讲解如何通过构造器来配置这些核心字段,特别是 accessOrder,决定其行为模式。
  • 第三层:Part 3 核心原理篇
    • 这是全文的心脏地带,我们将深度结合 JDK 8 源码,逐步拆解 LinkedHashMap 最核心的钩子方法体系。模块 5 会总览性地介绍三个核心钩子。模块 6模块 7 分别聚焦于“插入顺序”和“访问顺序”这两种模式下,putget 等操作如何与钩子方法联动以维护链表。模块 8 则专注于 getremove 操作本身。
  • 第四层:Part 4 迭代与序列化篇
    • 本篇章视角转向数据输出。模块 9 将深入迭代器源码,揭示 LinkedHashMap 如何绕过哈希表的随机性,直接利用其维护的双向链表进行可预测的、高效的顺序遍历。
  • 第五层:Part 5 对比与陷阱篇
    • 在理解原理后,本章将 LinkedHashMap 放在更广阔的技术生态中进行横向与纵向审视。模块 10 通过一个选型决策树,全方位对比它与 HashMap、TreeMap 的差异。模块 11 则结合实践,集中剖析开发者在使用中可能落入的陷阱,并提供最佳实践。
  • 第六层:Part 6 总结与面试篇
    • 这是文章的收尾与升华。模块 12 进行性能与内存的总结,给出最终的场景推荐。模块 13 作为独立专题,将前文所有技术核心提炼为可以被考核的面试问题,并从标准回答到加分回答进行分层梳理,实现从技术原理到工程实践的闭环。

Part 1:基础认知篇

模块 1:定义、核心特性与适用场景

1.1 是什么?

LinkedHashMap 是 Java 集合框架中,直接继承自 java.util.HashMap 的一个 Map 实现类。它在拥有 HashMap 所有特性的基础上,通过内部维护一个贯穿所有节点的双向链表,保证了迭代顺序的可预测性。 这种顺序可以是元素首次插入的顺序,也可以是元素最近被访问的顺序(从最老到最新)。它是线程不安全的,允许一个 null 键和多个 null 值。

1.2 核心特性列表

  • 可预测的迭代顺序:这是 LinkedHashMap 存在的根本意义。遍历 Map 时,元素的输出顺序是确定的,不再是 HashMap 那种依赖于哈希桶分布的伪随机顺序。
  • 双重顺序模式
    • 插入顺序(默认):迭代顺序与元素被 put 到 Map 中的先后顺序一致。重复 put 已存在的 Key 不会改变其在链表中的位置。
    • 访问顺序:通过构造参数 accessOrder=true 启用。在此模式下,每次对某个元素的 getput(更新操作)访问,都会将该元素移动到内部双向链表的尾部。这使得链表头部始终是最久未被访问或插入的元素。
  • 基本操作性能putgetremovecontainsKey 等核心操作的时间复杂度与 HashMap 一致,平均为 O(1)。额外的链表维护操作是常量时间的指针调整,不影响整体数量级。
  • Null 值支持:Key 和 Value 均可为 null,行为与 HashMap 一致。
  • 非线程安全:其所有方法均未做同步处理。若需在多线程环境下使用,必须使用 Collections.synchronizedMap(new LinkedHashMap(...)) 进行包装。
  • Fail-Fast 机制:迭代器在遍历过程中,如果 Map 结构被非迭代器自身的方法修改,会立即抛出 ConcurrentModificationException

1.3 适用场景决策树

下图展示了一个关于何时选择 LinkedHashMap 的决策流程:

graph TD
    Start["需要 Map 存储键值对"] --> Q1{"是否需要可预测的迭代顺序?"}
    
    Q1 -->|"否"| UseHashMap["使用 HashMap 关注极致性能 无需顺序"]
    
    Q1 -->|"是"| Q2{"需要哪种顺序?"}
    
    Q2 -->|"自然排序或自定义排序"| UseTreeMap["使用 TreeMap 关注键的排序"]
    
    Q2 -->|"插入顺序或访问顺序"| Q3{"是否是实现缓存?"}
    
    Q3 -->|"否,只是按插入顺序输出"| UseLinkedHashMap_Insert["使用 LinkedHashMap 默认插入顺序 配置解析 数据归档等"]
    
    Q3 -->|"是,需要淘汰旧数据"| Q4{"淘汰策略是什么?"}
    
    Q4 -->|"最近最少使用"| UseLinkedHashMap_LRU["使用 LinkedHashMap 访问顺序 并重写 removeEldestEntry"]
    
    Q4 -->|"其他复杂策略"| CustomCache["考虑自定义缓存方案 或使用 Caffeine 或 Guava Cache"]

图表说明: 这张 LinkedHashMap 适用场景决策树 帮助您在 Map 选型时做出准确判断。

  • 第一层:需要 Map 存储键值对:这是我们的起点,明确基本的数据结构需求。
  • 第二层:是否需要可预测的迭代顺序?
    • 这是区分 HashMap 与“有序 Map”(LinkedHashMap, TreeMap)的核心分水岭。如果答案是否定的,直接选择 HashMap,因为它没有任何维护顺序的开销,性能最纯粹。
  • 第三层:需要哪种顺序?
    • 这一步在“有序 Map”内部做选择。如果需要的是基于 Key 的自然顺序或自定义比较器排序,那么 TreeMap 是唯一选择。如果需求是插入顺序或访问顺序,则进入 LinkedHashMap 的决策分支。
  • 第四层:是否是实现缓存?
    • 这一步进一步细分 LinkedHashMap 的使用模式。如果不是用于缓存,仅仅是为了按插入顺序遍历,那么使用默认构造的 LinkedHashMap 即可。
    • 如果是用于缓存,则接着判断淘汰策略。LinkedHashMap 原生支持 LRU(最近最少使用) 策略,通过 accessOrder=true 和重写 removeEldestEntry 可轻松实现。对于 LFU、基于时间过期等更复杂的策略,则需考虑第三方缓存库。

模块 2:接口与继承体系

LinkedHashMap 的设计严格遵循了“对扩展开放,对修改关闭”的原则。它没有重复造轮子去实现哈希算法,而是聪明地站在了 HashMap 的肩膀上。

2.1 继承关系图

classDiagram
    class Map {
        <<interface>>
        +put(K V)
        +get(Object)
        +remove(Object)
        +size()
    }

    class AbstractMap {
        <<abstract>>
        +toString()
        +clone()
    }

    class HashMap {
        +put(K V)
        +get(Object)
        +remove(Object)
        +size()
        #Node[]
        #newNode(int K V Node)
        #afterNodeAccess(Node)
        #afterNodeInsertion(boolean)
        #afterNodeRemoval(Node)
    }

    class LinkedHashMap {
        -Entry head
        -Entry tail
        -boolean accessOrder
        +LinkedHashMap()
        +LinkedHashMap(int float boolean)
        +get(Object)
        +newNode(int K V Node)
        +afterNodeAccess(Node)
        +afterNodeInsertion(boolean)
        +afterNodeRemoval(Node)
        -linkNodeLast(Entry)
    }

    class Entry {
        -Entry before
        -Entry after
    }

    class Node {
        -int hash
        -K key
        -V value
        -Node next
    }

    Map <|.. AbstractMap
    AbstractMap <|-- HashMap
    HashMap <|-- LinkedHashMap
    Node <|-- Entry
    HashMap --> Node
    LinkedHashMap --> Entry

图表说明: 这张 LinkedHashMap 核心继承与内部结构类图 揭示了其代码复用和扩展的两种主要方式:继承关系内部节点扩展

  • 第一层:继承体系

    • Map 接口:定义了所有 Map 应遵守的行为契约。
    • AbstractMap 抽象类:提供 Map 接口的骨架实现,如 toStringclone 等公共方法。
    • HashMap:实现哈希表的核心逻辑,包括桶数组 table、哈希算法、put/get/resize 等复杂处理。同时,它预留了三个重要的钩子方法afterNodeAccessafterNodeInsertionafterNodeRemoval,供子类覆写。
    • LinkedHashMap:作为 HashMap 的子类,它几乎全盘复用了父类的数据存取和哈希计算的逻辑,但通过重写预留的钩子方法和 newNode 方法,无缝地插入了双向链表维护的行为。
  • 第二层:内部节点扩展体系

    • HashMap.Node:哈希桶链表的基础节点,包含 hashkeyvalue 和用于解决哈希冲突的 next 单向指针
    • LinkedHashMap.Entry:它继承自 HashMap.Node,并在其基础上增加了 beforeafter 两个双向指针。这个继承设计至关重要,它意味着:
      • 数据结构映射:在内存中,每个 Entry 节点同时存在于两个逻辑结构中。它通过 next 指针,作为哈希桶链表的一部分;同时,它通过 beforeafter 指针,作为全局双向链表的一部分。
      • 代码复用:HashMap 的所有代码都在操作 Node 类型,LinkedHashMap 传入的是 Entry 子类型,由于多态,HashMap 的代码可以不加修改地工作。LinkedHashMap 只需在自己的钩子方法中,将节点强转为 Entry 并操作其 before/after 指针即可。
    • LinkedHashMap 的核心字段headtail 指针分别指向这个全局双向链表的头和尾,accessOrder 则决定了链表的行为模式。

Part 2:存储与构造篇

模块 3:存储结构与核心字段(源码剖析)

3.1 双向链表的节点:LinkedHashMap.Entry

LinkedHashMap 的存储结构是“哈希桶数组 + 双向链表”的完美结合。我们首先来看这个结合的微观表现——节点的设计。

// JDK 8 LinkedHashMap.java

// 静态内部类,继承自 HashMap.Node
static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after; // 双向链表的前驱和后继指针
    // 构造方法,直接复用父类 HashMap.Node 的构造器
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

源码分析

  • 继承 HashMap.NodeEntry 获得了 hashkeyvaluenext 四个字段。其中 next 用于解决哈希冲突,形成哈希桶中的单链表或树结构(TreeNode 也继承自 Entry)。
  • 新增 before, after:这两个 Entry<K,V> 类型的引用构成了贯穿所有节点的双向链表骨架。它们完全不关心节点的哈希值或存储位置,只关心节点之间的先后顺序。

3.2 维护链表的关键字段

在 LinkedHashMap 类中,定义了三个核心字段来控制这个双向链表:

// JDK 8 LinkedHashMap.java

/**
 * 双向链表的头节点(最老的节点)。
 * 在插入顺序下,是最先插入的节点;在访问顺序下,是最久未被访问的节点。
 */
transient LinkedHashMap.Entry<K,V> head;

/**
 * 双向链表的尾节点(最新的节点)。
 * 在插入顺序下,是最后插入的节点;在访问顺序下,是最近被访问的节点。
 */
transient LinkedHashMap.Entry<K,V> tail;

/**
 * 迭代顺序的标志位。
 * true:  访问顺序(get/put 会将被访问元素移到链表尾)
 * false: 插入顺序(默认)
 */
final boolean accessOrder;

字段详解

  • headtail:这两个指针分别指向双向链表的头和尾,是遍历整个 Map 的入口和出口。transient 关键字表示它们不参与默认序列化。
  • accessOrder:这是一个 final 字段,一旦在构造器中设定,就无法更改。它决定了 LinkedHashMap 的行为模式。所有维护链表顺序的逻辑,都会检查这个字段。

3.3 双结构并存的内存模型

classDiagram
    direction LR
    class LinkedHashMap {
        -table: Node[]
        -head: Entry
        -tail: Entry
    }

    class table ["table (哈希桶数组)"]
    class Bucket0 ["bucket[0]"]
    class Bucket1 ["bucket[1]"]
    class BucketN ["bucket[n]"]

    class Node_A_Entry["Entry A (hash=5)"]
    class Node_B_Entry["Entry B (hash=1)"]
    class Node_C_Entry["Entry C (hash=5)"]

    LinkedHashMap *-- table
    table --> Bucket0
    table --> Bucket1
    table --> BucketN

    Bucket1 --> Node_B_Entry : next
    BucketN --> Node_A_Entry : next
    Node_A_Entry --> Node_C_Entry : next

    LinkedHashMap *-- Node_A_Entry : head
    Node_A_Entry --> Node_B_Entry : after
    Node_B_Entry --> Node_C_Entry : after
    Node_C_Entry --> Node_A_Entry : before (隐含)
    LinkedHashMap *-- Node_C_Entry : tail

图表示意说明: 这张图展示了三个 Entry(A, B, C)如何同时存在于哈希桶和双向链表中。假设插入顺序为 A -> B -> C,且 A 和 C 的哈希码指向了同一个桶 bucket[n]

  • 第一层:哈希桶视角(水平方向)

    • 通过哈希算法,A 和 C 落入 bucket[n],它们通过 next 指针形成的单链表解决冲突。B 单独落入了 bucket[1]
    • 这一结构完全由 HashMap 的机制维护,保证了 get 操作的快速查找。
  • 第二层:双向链表视角(垂直/弧形箭头)

    • 根据插入顺序,A 是最先插入的,B 是第二个,C 是最后插入的。
    • head 指向 A,A 的 after 指向 B,B 的 after 指向 C,C 为 tail。同时,每个节点的 before 指针也指向前一个节点。
    • 这一独立于哈希桶的结构由 LinkedHashMap 的钩子方法维护,保证了迭代的有序性。
  • 关键结论:一个 LinkedHashMap.Entry 节点,是哈希桶单链表和全局双向链表的交叉点。LinkedHashMap 的巧妙之处就在于它完美地分离了这两个关注点,让 HashMap 负责快速存取,而自己专注于顺序维护。


模块 4:构造方法(源码剖析)

LinkedHashMap 提供了五个构造器,其核心都是调用父类 HashMap 的构造器来初始化哈希表结构,然后设置自己的特有字段。

// JDK 8 LinkedHashMap.java

// 1. 默认构造器
public LinkedHashMap() {
    super(); // 调用 HashMap(),初始容量16,负载因子0.75
    accessOrder = false; // 默认为插入顺序
}

// 2. 指定初始容量
public LinkedHashMap(int initialCapacity) {
    super(initialCapacity); // 调用 HashMap(int initialCapacity)
    accessOrder = false;
}

// 3. 指定初始容量和负载因子
public LinkedHashMap(int initialCapacity, float loadFactor) {
    super(initialCapacity, loadFactor); // 调用 HashMap(int, float)
    accessOrder = false;
}

// 4. 从另一个 Map 构造
public LinkedHashMap(Map<? extends K, ? extends V> m) {
    super(); // 初始化为默认容量和负载因子
    accessOrder = false;
    putMapEntries(m, false); // 批量插入,会调用newNode,新节点按传入Map迭代顺序链入
}

// 5. 关键构造器:指定顺序模式
public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder; // 设定访问顺序模式
}

构造器详解与 Demo

  • 前四个构造器都将 accessOrder 设置为 false,即插入顺序模式
  • 第五个构造器是启用 LRU 缓存的关键,它允许我们将 accessOrder 设置为 true
import java.util.LinkedHashMap;
import java.util.Map;

public class LinkedHashMapConstructorDemo {
    public static void main(String[] args) {
        // 1. 默认插入顺序
        Map<Integer, String> defaultMap = new LinkedHashMap<>();
        defaultMap.put(3, "C");
        defaultMap.put(1, "A");
        defaultMap.put(2, "B");
        System.out.println("插入顺序: " + defaultMap); 
        // 输出: 插入顺序: {3=C, 1=A, 2=B} (迭代顺序与插入顺序一致)

        // 2. 指定初始容量和负载因子,访问顺序
        Map<Integer, String> accessOrderMap = new LinkedHashMap<>(16, 0.75f, true);
        accessOrderMap.put(3, "C");
        accessOrderMap.put(1, "A");
        accessOrderMap.put(2, "B");
        System.out.println("初始访问顺序: " + accessOrderMap);
        // 输出: 初始访问顺序: {3=C, 1=A, 2=B}

        // 关键:访问已存在的key
        accessOrderMap.get(1); 
        System.out.println("访问1之后: " + accessOrderMap);
        // 输出: 访问1之后: {3=C, 2=B, 1=A} (1被移到了尾部)
        
        // 更新已存在的key
        accessOrderMap.put(2, "BB");
        System.out.println("更新2之后: " + accessOrderMap);
        // 输出: 更新2之后: {3=C, 1=A, 2=BB} (2被移到了尾部)
    }
}

Part 3:核心原理篇

模块 5:双向链表的维护——钩子方法体系(源码剖析)

HashMap 的设计者预见到了子类可能需要响应节点的增、删、改、查事件,因此在 putValremoveNodegetNode 等核心方法中,预留了三个回调方法。LinkedHashMap 正是通过重写这些“钩子”,以“横切”的方式,非侵入式地将双向链表的维护逻辑织入到了 HashMap 的操作流程中。

5.1 put 操作中的钩子方法调用链

当一个 put 操作发生时,HashMap 的 putVal 方法内部会按特定顺序触发多个钩子。下面的流程图清晰地展示了这一过程,并标注了责任方的切换。

flowchart TD
    Start(LinkedHashMap.put key, value) --> CallSuperPutVal[调用 HashMap.putVal]
    
    subgraph HashMap.putVal 流程
        PutValStart[开始] --> HashCalc[计算 hash 值]
        HashCalc --> CheckExist{检查桶中<br/>是否已存在 Key?}
        
        CheckExist -- 否 --> CreateNode[调用 newNode 创建节点]
        CreateNode --> AddToBucket[将节点放入哈希桶]
        
        CheckExist -- 是 --> UpdateValue[更新节点的 value 值]
        UpdateValue --> AfterAccess[调用 afterNodeAccess]
        
        AddToBucket --> AfterInsertion[调用 afterNodeInsertion]
        AfterInsertion --> PutValEnd[putVal 结束]
        AfterAccess --> PutValEnd
    end

    subgraph LinkedHashMap 钩子方法
        NewNodeImpl["重写 newNode:<br/>1. 调用构造器 new Entry<>(...)<br/>2. 调用 linkNodeLast 将<br/>新 Entry 链入双向链表尾部"]
        AfterAccessImpl{"重写 afterNodeAccess:<br/>1. 判断 accessOrder == true?<br/>2. 是,将传入节点<br/>移至双向链表尾部"}
        AfterInsertionImpl["重写 afterNodeInsertion:<br/>1. 调用 removeEldestEntry<br/>2. 若返回true,移除头节点"]
    end
    
    CreateNode -.-> NewNodeImpl
    AfterAccess -.-> AfterAccessImpl
    AfterInsertion -.-> AfterInsertionImpl
    
    PutValEnd --> End(put 操作完成)

图表说明: 这张 put 操作中钩子方法调用时序图 揭示了 LinkedHashMap 如何在不上报的情况下接管控制权。

  • 第一层:LinkedHashMap.put 调用
    • LinkedHashMap 自身没有重写 put 方法。当调用 put 时,实际上直接进入了 HashMap.putVal 的逻辑。这是“复用”的体现。
  • 第二层:HashMap.putVal 的标准流程
    • 流程进行哈希计算和查找。根据 Key 是否存在,分为两个分支:
      • Key 不存在(新增节点):HashMap 调用 newNode() 方法来创建一个新的 Node 实例。
      • Key 已存在(替换旧值):HashMap 在更新完 value 后,会调用 afterNodeAccess(e),通知“节点 e 被访问了”。
    • 在新增节点放入桶后,HashMap 还会调用 afterNodeInsertion(evict),通知“有新节点插入了”。
  • 第三层:LinkedHashMap 的钩子实现(责任方切换)
    • 重写 newNode:这是双向链表建立的起点。LinkedHashMap 重写的 newNode 方法返回了一个 LinkedHashMap.Entry 实例,并立即调用了 linkNodeLast 方法,将这个新节点链接到了双向链表的尾部。这一步确保了新插入的元素在链表中处于“最新”的位置。
    • 重写 afterNodeAccess:当 Key 已存在时触发。该方法会检查 accessOrder如果 accessOrdertrue,它会执行一系列 before/after 指针的调整,将该节点从链表当前位置“摘除”,并移动到链表尾部,使其成为最新的节点。
    • 重写 afterNodeInsertion:该方法是一个扩展点,默认调用 removeEldestEntry(first)如果该方法返回 true,则会直接移除链表的头节点(最老的节点)。这是一个为 LRU 缓存而生的钩子。

5.2 核心钩子方法源码详解

1. newNodelinkNodeLast

// LinkedHashMap.java
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
    // 1. 创建 LinkedHashMap.Entry 实例
    LinkedHashMap.Entry<K,V> p =
        new LinkedHashMap.Entry<>(hash, key, value, e);
    // 2. 将新节点链接到双向链表的尾部
    linkNodeLast(p);
    // 3. 返回该节点,该节点将由 HashMap 用于放入哈希桶
    return p;
}

// 链接节点到尾部
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail; // 获取当前尾节点
    tail = p; // 新节点成为尾节点
    if (last == null)
        // 如果之前的尾节点为null,说明链表为空,新节点也同时是头节点
        head = p;
    else {
        // 否则,建立双向连接
        p.before = last;
        last.after = p;
    }
}

2. afterNodeAccess

// LinkedHashMap.java
void afterNodeAccess(Node<K,V> e) { // 将节点移至链表尾部
    LinkedHashMap.Entry<K,V> last;
    // 只有在accessOrder为true,且当前节点e不是尾节点时,才需要移动
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        // 1. 从链表中“摘除”节点p
        p.after = null;
        if (b == null)
            // p是头节点,则将它的后继设为新的头节点
            head = a;
        else
            b.after = a;
        if (a != null)
            a.before = b;
        else
            // p是尾节点?此情况理论上在开头 `tail != e` 已排除
            last = b;
        // 2. 将节点p链接到尾部
        if (last == null)
            head = p;
        else {
            p.before = last;
            last.after = p;
        }
        // 3. 更新尾指针
        tail = p;
        ++modCount; // 结构修改次数+1,触发fail-fast
    }
}

3. afterNodeInsertion

// LinkedHashMap.java
void afterNodeInsertion(boolean evict) { // evict在HashMap中为true
    LinkedHashMap.Entry<K,V> first;
    // 如果 removeEldestEntry 返回 true,则移除头节点(最老元素)
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        // 调用 HashMap的removeNode方法移除节点,会触发afterNodeRemoval
        removeNode(hash(key), key, null, false, true);
    }
}

// 默认永远返回 false,不执行淘汰
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return false;
}

4. afterNodeRemoval

// LinkedHashMap.java
void afterNodeRemoval(Node<K,V> e) { // 从双向链表中删除节点e
    LinkedHashMap.Entry<K,V> p =
        (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
    // 断开节点p的前后连接
    p.before = p.after = null;
    // 更新其前驱和后继的指针,将p从链表中摘除
    if (b == null)
        head = a;
    else
        b.after = a;
    if (a == null)
        tail = b;
    else
        a.before = b;
}

模块 6:插入顺序模式下的 put 与遍历(源码剖析)

accessOrder=false 时,LinkedHashMap 工作在插入顺序模式。这是最纯粹、最易于理解的模式。

6.1 put 操作的全生命周期

我们跟踪一个全新的 Key 从 put 进入 Map 到最终在内存中落位的全过程。

sequenceDiagram
    participant Client
    participant LHM as LinkedHashMap
    participant HM as HashMap.putVal
    participant Bucket as 哈希桶数组
    participant DLL as 全局双向链表

    Client ->> LHM: put("K1", "V1")
    LHM ->> HM: super.putVal("K1", "V1")
    
    HM ->> HM: 计算 hash("K1") = index
    HM ->> Bucket: 检查 table[index] 无冲突
    Bucket -->> HM: null

    HM ->> LHM: newNode(hash, "K1", "V1", null)
    Note over LHM: 重写的钩子方法
    LHM ->> LHM: new Entry<>(...)
    LHM ->> DLL: linkNodeLast(newEntry)
    Note over DLL: tail从null变为newEntry,<br/>head也指向newEntry
    DLL -->> LHM: 
    LHM -->> HM: 返回 newEntry
    
    HM ->> Bucket: table[index] = newEntry
    HM ->> LHM: afterNodeInsertion(true)
    Note over LHM: 重写的钩子方法,<br/>removeEldestEntry返回false,无操作
    
    HM -->> LHM: 
    LHM -->> Client: 返回 null (无旧值)
    
    Client ->> LHM: put("K2", "V2")
    Note over Client,LHM: 重复上述过程...

图表说明: 这张 插入顺序下 put 操作的完整时序图 动态展示了新增一个键值对时,内存中各组件如何协作。

  • 第一层:Client 调用 LHM.put
    • 请求进入 LinkedHashMap,但被直接委托给了父类 HashMap.putVal
  • 第二层:HashMap 执行哈希查找
    • HashMap 进行哈希计算,定位到桶索引,假设此时无冲突,桶为空。
    • 因为 Key 不存在,HashMap 需要创建一个新的节点。它不会自己去 new,而是调用 newNode 钩子。
  • 第三层:LinkedHashMap 接管节点创建(钩子激活)
    • 控制权回到 LinkedHashMap 重写的 newNode 方法中。
    • 关键步骤 1newNode 内部创建了 LinkedHashMap.Entry 实例,该实例同时持有 next(此处为 null)和 before/after 指针。
    • 关键步骤 2:紧接着调用 linkNodeLast。此时双向链表为空,因此 headtail 都指向了这个新 Entry。它现在是链表上的第一个节点。节点创建完毕,返回给 HashMap。
  • 第四层:HashMap 完成存放并触发后续钩子
    • HashMap 将拿到的 Entry 节点引用放入到 table[index] 的桶位中。
    • HashMap 接着调用 afterNodeInsertion(true),这又是一个钩子。在默认实现中,removeEldestEntry 返回 false,所以这里什么也不会发生

关键结论:在插入顺序模式下,新节点通过 newNode -> linkNodeLast 被加到链表尾部。afterNodeAccess 永远不会生效(因为 accessOrder 为 false)。所以节点的相对顺序从首次插入后就不再改变。

6.2 基于链表的迭代

LinkedHashMap 的迭代器(KeySetValuesEntrySet 的迭代器)直接遍历双向链表,完全绕开了哈希桶。

// LinkedHashMap.java 内部抽象类
abstract class LinkedHashIterator {
    LinkedHashMap.Entry<K,V> next;       // 下一个要返回的节点
    LinkedHashMap.Entry<K,V> current;    // 当前节点
    int expectedModCount;                // 用于fail-fast

    LinkedHashIterator() {
        next = head; // 从头节点开始遍历
        expectedModCount = modCount;
    }

    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 = e.after; // 关键:通过after指针移动到下一个节点!
        return e;
    }
    // ... 省略 remove 相关代码
}

// Entry 迭代器
final class LinkedEntryIterator extends LinkedHashIterator
    implements Iterator<Map.Entry<K,V>> {
    public final Map.Entry<K,V> next() { return nextNode(); }
}

源码分析nextNode() 方法中的 next = e.after; 就是迭代有序性的秘密。它让迭代完全遵循了双向链表的顺序,而该顺序正是由我们之前分析的钩子方法体系所维护的。


模块 7:访问顺序模式与 LRU 实现(源码剖析)

accessOrder=true 时,LinkedHashMap 的行为发生了质变,afterNodeAccess 钩子被激活,使得每一次对元素的“访问”都会改变其在双向链表中的位置。

7.1 get 操作的指针变化过程

下面的时序图展示了在一个 accessOrder=true 的 Map 中,执行 get(1) 时,节点 1 从链表中间移动到尾部的完整过程。假设链表初始状态为 head -> A(1) -> B(2) -> C(3) -> tail

sequenceDiagram
    participant Client
    participant LHM_Get as LinkedHashMap.get
    participant HM_GetN as HashMap.getNode
    participant LHM_Access as LinkedHashMap.afterNodeAccess

    Client ->> LHM_Get: get(1)
    LHM_Get ->> HM_GetN: super.getNode( hash(1) )
    Note over HM_GetN: 通过哈希桶快速找到节点 A(1)
    HM_GetN -->> LHM_Get: 返回节点 A
    
    Note over LHM_Get: accessOrder == true
    LHM_Get ->> LHM_Access: afterNodeAccess(A)
    
    activate LHM_Access
    Note over LHM_Access: 1. 从原位置“摘除”节点 A
    LHM_Access ->> LHM_Access: b = A.before (null), a = A.after (B)
    LHM_Access ->> LHM_Access: A.after = null
    Note over LHM_Access: b == null,所以 head = a (即B)
    LHM_Access ->> LHM_Access: a.before = b (即B.before = null)
    
    Note over LHM_Access: 2. 将节点 A 链接到尾部
    LHM_Access ->> LHM_Access: last = tail (C)
    LHM_Access ->> LHM_Access: p.before = last (A.before = C)
    LHM_Access ->> LHM_Access: last.after = p (C.after = A)
    LHM_Access ->> LHM_Access: tail = p (tail = A)
    LHM_Access ->> LHM_Access: modCount++
    deactivate LHM_Access
    
    LHM_Get -->> Client: 返回 A.value
    Note over Client: 链表变为<br/>head -> B(2) -> C(3) -> A(1) -> tail

图表说明: 这张 访问顺序下 get 操作的指针操作时序图 详尽地展示了 afterNodeAccess 被触发时,一系列精密的指针“手术”。

  • 第一层:get 调用与节点查找
    • Client 调用 LinkedHashMap.get(1)
    • LinkedHashMap.get 首先调用父类 HashMap.getNode,利用哈希表 O(1) 的高效性找到键为 1 的节点 A。此步骤证明了查找性能不受链表影响。
  • 第二层:触发访问后回调
    • LinkedHashMap 的 get 方法拿到节点 A 后,判断 accessOrder == true。条件成立,则调用 afterNodeAccess(A)
  • 第三层:afterNodeAccess 执行“摘除”手术(步骤1)
    • 记录节点 A 的前驱 b(null)和后继 a(B)。
    • 将节点 Aafter 指针置为 null,切断它与后继的联系。
    • 由于 bnull,说明 A 是头节点,因此将 head 指针指向 a(即 节点 B 成为新的头节点)。
    • a(节点 B)的 before 指针置为 null。至此,节点 A 被完全从链表上“摘”了下来。
  • 第四层:afterNodeAccess 执行“重链接”手术(步骤2)
    • 记录当前尾节点 last(节点 C)。
    • 将节点 Abefore 指针指向 last(C)。
    • last(C)的 after 指针指向 A
    • 关键一步:将 tail 指针更新为 A,使节点 A 成为新的尾节点。
  • 最终结果:链表的顺序从 A -> B -> C 变为了 B -> C -> A。最近被访问的 A 去到了链表尾部,相应地,最久未访问的 B 沉淀到了链表头部。

7.2 实现 LRU 缓存

LRU(Least Recently Used,最近最少使用)缓存是 LinkedHashMap 的终极应用。实现一个固定容量的 LRU 缓存,只需两步:

  1. 设置 accessOrder = true
  2. 重写 removeEldestEntry 方法,定义何时移除最老的元素。
import java.util.LinkedHashMap;
import java.util.Map;

// 一个最大容量为3的LRU缓存
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int MAX_CAPACITY;

    public LRUCache(int capacity) {
        // 关键1: 设置 accessOrder = true
        // 使用容量+1,并设置负载因子为1,避免不必要的扩容开销
        super(capacity, 0.75f, true);
        this.MAX_CAPACITY = capacity;
    }

    // 关键2: 重写此方法,当Map大小超过我们设定的最大容量时,返回true
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > MAX_CAPACITY;
    }

    public static void main(String[] args) {
        LRUCache<Integer, String> cache = new LRUCache<>(3);
        cache.put(1, "A");
        cache.put(2, "B");
        cache.put(3, "C");
        System.out.println("初始: " + cache); // {1=A, 2=B, 3=C}

        // 访问1,1被移到尾部
        cache.get(1); 
        System.out.println("访问1后: " + cache); // {2=B, 3=C, 1=A}

        // 插入4,将触发淘汰最老元素(即2)
        cache.put(4, "D");
        System.out.println("插入4后: " + cache); // {3=C, 1=A, 4=D}

        // 更新3,3被移到尾部
        cache.put(3, "C-updated");
        System.out.println("更新3后: " + cache); // {1=A, 4=D, 3=C-updated}
        
        // 插入5,淘汰最老元素(1)
        cache.put(5, "E");
        System.out.println("插入5后: " + cache); // {4=D, 3=C-updated, 5=E}
    }
}

源码逻辑还原

  1. put(4, "D") 触发 HashMap.putVal
  2. newNode 创建新节点并 linkNodeLast 到尾部。
  3. afterNodeInsertion(true) 被调用。
  4. afterNodeInsertion 内部,调用我们重写的 removeEldestEntry。此时 size() 为 4,大于 MAX_CAPACITY (3),返回 true
  5. 因此,removeNode(hash(key), ...) 被调用,参数 key 是链表的头节点 head,即最久未使用的元素 (2, "B")。
  6. remove 操作又会触发 afterNodeRemoval,将节点 2 从双向链表中移除。一个完美的 LRU 闭环就此完成。

模块 8:getremove 操作(源码剖析)

8.1 get 操作流程

// LinkedHashMap.java
public V get(Object key) {
    Node<K,V> e;
    // 1. 调用父类HashMap的getNode查找节点
    if ((e = getNode(hash(key), key)) == null)
        return null;
    // 2. 如果开启了访问顺序,则触发钩子
    if (accessOrder)
        afterNodeAccess(e);
    return e.value;
}

流程图如下:

flowchart TD
    Start[LinkedHashMap.get] --> CallSuper[调用 HashMap.getNode]
    CallSuper --> Found{找到节点 e?}
    Found -- 是, e!=null --> CheckOrder{accessOrder == true?}
    CheckOrder -- 是 --> CallAccess[调用 afterNodeAccess, 将e移至链表尾部]
    CheckOrder -- 否 --> ReturnVal
    CallAccess --> ReturnVal[返回 e.value]
    Found -- 否, e==null --> ReturnNull[返回 null]

图表说明: 这张 get 操作流程图 极其简洁,因为它极大地复用了父类逻辑。

  • 核心复用:查找节点的核心逻辑 getNode 是 HashMap 的,性能为 O(1)。
  • 条件增强:唯一的增强是在成功找到节点后,加了一个 if (accessOrder) 的判断。如果为 true,就调用 afterNodeAccess,实现了“访问即更新”的语义。如果为 false,get 方法的行为与 HashMap 完全相同。

8.2 remove 操作流程

LinkedHashMap 没有重写 remove 方法,完全复用 HashMap 的 remove。但 HashMap 的 removeNode 方法在成功删除节点后,会调用钩子 afterNodeRemoval,这就给了 LinkedHashMap 修改自己数据结构的机会。

flowchart TD
    Start[LinkedHashMap.remove key] --> CallSuper[调用 HashMap.removeNode]
    subgraph HashMap.removeNode 流程
        FindAndRemove[查找并移除节点 e] --> IsRemoved{移除成功?}
        IsRemoved -- 是 --> CallRemovalHook[调用 afterNodeRemoval e]
        IsRemoved -- 否 --> End
    end
    CallRemovalHook -.-> |钩子回调| DLL_Remove[LinkedHashMap.afterNodeRemoval:<br/>将e从双向链表中摘除]
    CallRemovalHook --> End[remove 操作结束]
    DLL_Remove --> End

关键结论:LinkedHashMap 的 remove 之所以能保证双向链表的正确,完全依赖于 HashMap 在删除节点后主动回调的 afterNodeRemoval 钩子。LinkedHashMap 在此钩子中完成了将其从双向链表中摘除的清理工作。


Part 4:迭代与序列化篇

模块 9:迭代器——基于链表的顺序遍历

正如模块 6.2 所分析的,LinkedHashMap 的所有集合视图迭代器都基于其内部的双向链表。

flowchart TD
    Start[创建迭代器] --> Init[iterator.next = this.head]
    
    subgraph 调用 next 遍历
        Next[调用 iterator.next] --> FetchNext[next = next.next]
        FetchNext --> GetValue[return next.key/value/entry]
        GetValue --> UpdateNext[next = e.after]
        UpdateNext --> More{next == null?}
        More -- 否 --> Next
        More -- 是 --> End[遍历结束]
    end

    subgraph 内存结构
        Head[head] --> A((Entry A))
        A -- after --> B((Entry B))
        B -- after --> C((Entry C))
        C -- after --> null
        Tail[tail] --> C
    end

    Init --> A

图表说明: 这张 迭代器遍历流程图 展示了从 headtail 的顺序访问过程,并与内存中的链表结构相映射。

  • 第一层:初始化
    • 在迭代器(如 LinkedKeyIterator)的构造器中,next 指针被初始化为 this.head,即指向全局双向链表的头节点。
  • 第二层:迭代过程
    • hasNext() 方法仅仅检查 next 是否为 null
    • nextNode() 方法首先记录当前 next 指向的节点 e,然后关键的一步是执行 next = e.after,将迭代器的游标沿着链表的 after 指针移动到下一个节点。
  • 第三层:数据结构映射
    • 该流程与右侧的内存结构图完美对应。迭代器从 Entry A 开始,通过 after 指针走到 Entry B,再走到 Entry C,最后遇到 null 结束。
  • 核心结论:无论哈希桶的内部结构多么复杂,迭代器只认双向链表这一条路,因此能保证严格按链表顺序输出。这种实现不仅保证了顺序,还避免了对哈希桶数组的扫描,从而在遍历时不会访问那些空的桶位,效率高于 HashMap 的迭代器。

Part 5:对比与陷阱篇

模块 10:LinkedHashMap vs HashMap vs TreeMap——有序性的三种维度

Java 集合框架中这三个 Map 实现,分别代表了 Map 有序性的三个不同维度。

flowchart TD
    Start["选择 Map 实现"] --> Q1{"是否需要 key-value 映射?"}
    Q1 -->|"是"| Q2{"key 是否需要排序?"}
    
    Q2 -->|"需要按 key 的自然顺序或比较器排序"| TreeMap["TreeMap"]
    Q2 -->|"不需要排序"| Q3{"迭代时是否需要可预测的顺序?"}
    
    Q3 -->|"不需要"| HashMap["HashMap"]
    Q3 -->|"需要"| Q4{"需要哪种可预测的顺序?"}
    
    Q4 -->|"插入顺序或访问顺序"| LinkedHashMap["LinkedHashMap"]
    
    subgraph 特性对比
        TreeMap_feat["TreeMap: 基于红黑树 Key 有序 (SortedMap) O(log n) 基本操作 内存开销大 适用于需要排序或范围查询的场景"]
        HashMap_feat["HashMap: 基于哈希表 迭代顺序不可预测 O(1) 基本操作 内存开销最小 适用于无需关心顺序的常规缓存和映射"]
        LinkedHashMap_feat["LinkedHashMap: 基于哈希表+双向链表 迭代顺序可预测(插入/访问) O(1) 基本操作 略慢于HashMap 内存开销中等(每个节点多两个指针) 适用于LRU缓存和顺序敏感的场景"]
    end

    TreeMap --> TreeMap_feat
    HashMap --> HashMap_feat
    LinkedHashMap --> LinkedHashMap_feat

图表说明: 这张 Map 选型决策树与特性对比图 全景式地展示了 LinkedHashMap 在 Map 家族中的独特地位。

  • 第一层:排序维度的抉择
    • 首要问题是 Key 是否需要排序。如果需要,TreeMap 是唯一选择。它基于红黑树实现,保证了 Key 的自然顺序或比较器顺序,并天然支持范围查询。代价是 O(log n) 的时间性能。
  • 第二层:预测性维度的抉择
    • 如果不需要排序,则进入“无序 vs 可预测顺序”的抉择。如果无需可预测的迭代顺序,HashMap 是最轻量、最快(但因无序而无法用于特定场景)的选择。
    • 如果需要可预测的迭代顺序(最常见的是“先 put 先出”或 LRU),LinkedHashMap 就是不二之选。它在保持 O(1) 高性能的同时,通过微小的内存和性能开销,换来了宝贵的顺序性。
  • 核心结论:三者的本质区别在于用什么样的开销换取了什么样的顺序。HashMap 用最低开销换取最大性能,但无顺序;TreeMap 用最大开销(时间与空间)换取 Key 的排序能力;而 LinkedHashMap 则用中等开销在性能和顺序之间找到了一个极佳的平衡点。

模块 11:常见陷阱与最佳实践

陷阱 1:Key 可变性导致的“幽灵”节点

错误代码

class MutableKey {
    int id;
    String name;
    // equals和hashCode依赖于id和name
}

public static void main(String[] args) {
    Map<MutableKey, String> map = new LinkedHashMap<>();
    MutableKey key = new MutableKey(1, "A");
    map.put(key, "Value1");
    
    // 错误操作:修改了 Key 中影响 hashCode 或 equals 的字段
    key.id = 2; 
    
    // 后果1: 无法通过新 key 或旧 key 找到该值
    System.out.println(map.get(key)); // 极大概率输出 null
    // 后果2: 迭代器仍然能遍历到这个节点(因为链表指针未变),但其数据已不可访问
    map.forEach((k, v) -> System.out.println(k + " -> " + v)); // 会打印出这个“幽灵”节点
    // 后果3: 该节点无法被 remove(key) 移除,造成内存泄漏
}

最佳实践:永远使用不可变对象(如 StringInteger,或自定义的 final 类)作为 Map 的 Key。

陷阱 2:accessOrder=true 下遍历时执行 get

在访问顺序模式下,遍历过程本身(通过迭代器)不算是“访问”,不会改变顺序。但如果遍历的代码体内有 get 操作,就会立即改变 Map 的结构。

// 错误演示
Map<Integer, String> map = new LinkedHashMap<>(16, 0.75f, true);
map.put(1, "A"); map.put(2, "B"); map.put(3, "C");

// 尝试在迭代中访问,期望正常结束
for (Integer key : map.keySet()) {
    if (key == 1) {
        map.get(2); // 危险操作!get(2)会将2移到链表尾部
    }
    System.out.print(key + " ");
}
// 该循环可能会输出 "1 3 ",跳过了2,因为2被移到了尾部
// 在某些版本或情况下,甚至可能因为ConcurrentModificationException而直接崩溃。

最佳实践:在 accessOrder=true 模式下,绝对不要在迭代遍历keySetentrySetvalues 的过程中对 Map 执行 getput(更新)操作。如果必须做,请先拷贝一份快照再遍历。

陷阱 3:默认容量不足导致的频繁扩容

在实现 LRU 缓存时,我们通常将容量设得很小。但 LinkedHashMap 默认的容量是 16,负载因子是 0.75。

// 不推荐:容量为5,但仍会经历从默认16容量扩张的过程
new LRUCache(5); 
// 推荐:精确设置容量和负载因子,避免不必要的扩容
new LRUCache(5, 1.0f); // 设置负载因子为1,将扩容机会降到最低

在构造 LRU 缓存时,为了精确控制内存,最好在构造器中明确指定 int initialCapacity 并使用较高的负载因子(如 1.0f),避免在达到期望容量之前就因负载因子而触发扩容。

陷阱 4:多线程环境下的 ConcurrentModificationException

错误代码

Map<Integer, String> map = new LinkedHashMap<>(); // 非线程安全
// 线程1: 迭代
new Thread(() -> map.forEach((k,v) -> { 
    try { Thread.sleep(10); } catch (Exception e) {}
})).start();
// 线程2: 修改
new Thread(() -> map.put(4, "D")).start();
// 几乎100%会抛出 ConcurrentModificationException

解决方案

  1. 使用 Collections.synchronizedMap(new LinkedHashMap(...)),但对迭代仍需手动加锁。
  2. 直接使用 java.util.concurrent.ConcurrentHashMap(但会失去顺序)。
  3. 如果需要并发且有序,可将 ConcurrentHashMap 与额外的 List 或 Queue 结合使用,或直接使用 Guava 等库提供的线程安全缓存。

Part 6:总结与面试篇

模块 12:注意事项与性能总结

时间复杂度总结表

操作平均时间复杂度说明
getO(1)哈希查找 O(1) + (可选)移动链表 O(1)
putO(1)哈希插入 O(1) + 链接链表 O(1) + (可选)淘汰 O(1)
removeO(1)哈希删除 O(1) + 摘除链表 O(1)
containsKeyO(1)纯哈希操作
迭代O(n)直接遍历全局双向链表,效率极高
cloneO(n)需要重建哈希表和链表

内存开销分析

  • 每个节点除了存储 keyvaluehashnext(继承自 HashMap.Node)外,还多存储了 beforeafter 两个对象引用。
  • 在 64 位 JVM 且开启指针压缩的情况下,每个引用占用 4 字节。因此每个节点额外开销为 8 字节。对于大型 Map,这个开销会变得显著。

推荐使用场景

  • 需要按插入顺序输出:数据库查询结果的映射、解析配置文件并保留顺序。
  • 实现 LRU 缓存:在存储空间有限的情况下,自动维护数据的“冷热”程度。
  • 需要稳定迭代顺序的单元测试:避免因哈希顺序不确定导致的测试不稳定。

模块 13:面试高频专题

1. LinkedHashMap 和 HashMap 的区别是什么?有序性如何保证?

  • 标准回答:最核心的区别是 LinkedHashMap 能保证迭代顺序,而 HashMap 不能。LinkedHashMap 继承自 HashMap,复用了它的所有哈希操作,但其内部通过重写 newNodeafterNodeInsertionafterNodeAccessafterNodeRemoval 等钩子方法,额外维护了一条贯穿所有节点的双向链表。迭代时,LinkedHashMap 遍历的是这个有序的双向链表,而非哈希桶数组,从而保证了顺序。
  • 追问模拟:“这个有序性分哪些情况?”
    • 回答:分两种,通过构造器中的 accessOrder 参数控制。默认为 false,是插入顺序,即迭代顺序与 put 的先后顺序一致。设为 true 后,是访问顺序,任何对元素的 getput(更新)操作,都会将该元素移动到内部链表的尾部,头部就是最久未被访问的元素。
  • 加分回答:LinkedHashMap 的这种设计是典型的模板方法模式。HashMap 定义了算法骨架(putValgetNode 等),并预留了 afterNode* 钩子作为扩展点。LinkedHashMap 作为子类实现了这些钩子,从而在不改变父类核心算法的情况下,增加了新的行为(维护顺序)。这体现了开闭原则的强大威力。

2. LinkedHashMap 的内部结构是什么?双向链表如何与哈希桶结合?

  • 标准回答:其底层数据结构是“数组 + 单向链表/红黑树 + 双向链表”的综合体。它的静态内部类 Entry<K,V> 继承了 HashMap.Node<K,V>,因此除了拥有 keyvaluehash 和用于解决哈希冲突的 next 指针外,还额外增加了 beforeafter 两个指针。所有 Entry 节点通过 next 指针形成哈希桶的结构,同时又通过 beforeafter 指针横跨全局,形成一条双向链表。一个节点同时存在于这两个维度之中。
  • 追问模拟:“为什么说一个节点同时存在两个结构中?”
    • 回答:当一个新节点被 put 进来时,HashMap 的逻辑会把它挂载到对应桶的单链表/红黑树上(next 连接),而 LinkedHashMap 的 newNode 钩子又会把它链接到全局双向链表的尾部(before/after 连接)。从查找角度看,通过 Key 的哈希值能快速在桶中找到它;从迭代角度看,从 head 出发能沿着 after 指针按顺序遍历到它。它的内存地址只有一份,但被两种逻辑结构所引用。

3. LinkedHashMap 如何实现 LRU 缓存的原理?removeEldestEntry 的用法?

  • 标回答:LRU 实现依赖于两个核心点。第一,构造器设置 accessOrder=true,这样每次 getput 更新操作都会将被访问的元素移到双向链表的尾部,自然而然地,链表头部沉淀下来的就是最久未被访问的元素。第二,重写 removeEldestEntry(Map.Entry eldest) 方法。该方法在新节点插入后被 afterNodeInsertion 调用,当它返回 true 时,Map 就会删除当前链表头节点(即 eldest 参数)。通常我们在此方法中判断 size() > 最大容量
  • 追问模拟:“如果我不重写 removeEldestEntry,只设置 accessOrder=true,会发生什么?”
    • 回答:那么 Map 就只是一个具有“访问顺序”迭代能力的普通 Map,不会自动淘汰任何元素。其容量会随着元素的插入而无限增长,直到内存溢出。accessOrder 负责维护顺序,而 removeEldestEntry 负责定义淘汰的触发边界,两者缺一不可。
  • 加分回答:这种实现是 LRU 最简单的版本。在生产环境中,更推荐使用 Guava Cache 或 Caffeine,因为它们支持更细粒度的过期策略(如基于时间的过期、基于大小的回收、异步刷新等),并且在并发性能上做了大量优化。

4. afterNodeAccessafterNodeInsertion 方法分别什么时候被调用?

  • 标准回答
    • afterNodeInsertion(boolean evict):在 putVal 方法中,当成功插入一个新节点后调用。HashMap 会传入 true 作为 evict 参数(除非是在反序列化时)。
    • afterNodeAccess(Node<K,V> e):它在两个地方被调用:一是在 putVal 中,如果 Key 已存在,更新 Value 之后被调用;二是在 get 方法中,成功查找到节点且 accessOrdertrue 时被调用。
  • 追问模拟:“那 afterNodeRemoval 呢?它在哪里被调用?”
    • 回答:它在 removeNode 方法中,当节点被成功从哈希桶中移除之后被调用,用于通知 LinkedHashMap 进行链表上的清理工作。

5. LinkedHashMap 的迭代器遍历顺序是什么?如果遍历中调用 get 会发生什么?(accessOrder 下)

  • 标准回答:遍历顺序严格遵循其内部双向链表的顺序。如果 accessOrderfalse,就是插入顺序;如果为 true,就是从最久未使用到最近被使用的顺序。在 accessOrder=true 模式下,如果在使用迭代器遍历(如 keySet().iterator())的过程中,直接调用 map.get(),这个 get 会触发 afterNodeAccess,从而改变链表结构和顺序。这可能导致迭代器跳过一些元素,或者重复返回某些元素,甚至可能因为检查到 modCount 变化而直接抛出 ConcurrentModificationException

6. 请手写一个利用 LinkedHashMap 实现固定大小的 LRU 缓存示例。

  • 答案参考 模块 7.2LRUCache 示例代码。
  • 加分回答:可以进一步扩展,比如在 removeEldestEntry 中加入回调,用于在淘汰元素时执行持久化或资源清理操作。也可以重写 getput 方法,增加统计命中率的逻辑,为缓存的性能分析提供数据支持。

7. LinkedHashMap 的扩容机制?与 HashMap 有何异同?

  • 标准回答:LinkedHashMap 完全复用 HashMap 的 resize() 方法来进行扩容。当元素数量超过当前容量与负载因子的乘积(即阈值)时,就会触发扩容。扩容过程包括新建一个更大的桶数组,并将原有节点通过哈希算法重新分散到新的桶中(即 rehash)。关键差异在于:HashMap 只负责重建哈希桶的 next 链,而 LinkedHashMap 维护的全局 before/after 双向链表在整个扩容过程中完全不受影响,其节点间的相对顺序保持不变。因为扩容只操作节点的 next 指针,不触碰 before/after 指针。

  • 追问模拟:“扩容后,双向链表的顺序会乱掉吗?”

    • 回答:绝对不会。resize() 只遍历哈希桶数组,通过 next 指针重新分配节点位置。双向链表独立于桶结构,其头尾指针和节点的前后关系在扩容前后完全一致。因此,扩容是 LinkedHashMap 对存储结构的透明优化,不会破坏任何顺序约定。
  • 追问模拟:“树化与双向链表的关系呢?”

    • 回答:在 JDK 8 中,当某个桶的链表长度超过 8 且容量达到 64 时,HashMap 会将该桶的 Node 链表转换为 TreeNode 红黑树。TreeNode 作为 LinkedHashMap.Entry 的子类,同样持有 before/after 指针。树化过程中,全局双向链表依然被保留,只是桶内部的查找结构从单向链表变成了树。这意味着即使发生树化,迭代顺序依旧稳定。
  • 加分回答:这种设计再次印证了关注点分离的优势。HashMap 管理“空间-存取”效率(桶数组和 next 链),LinkedHashMap 专门管理“时间-顺序”约束(双向链表)。两者的修改互不干扰,使得 LinkedHashMap 可以在不触动父类复杂扩容逻辑的前提下,悄无声息地保持着自己的秩序。

8. LinkedHashMap 允许 null 键和值吗?与 HashMap 一致吗?

  • 标准回答:是的,LinkedHashMap 允许 一个 null 键和多个 null,这与 HashMap 的行为完全一致。在内部实现上,null 键会被哈希到桶数组的第 0 号桶,并在双向链表中作为一个普通的 Entry 节点存在。顺序维护对 null 键没有特殊处理——如果是插入顺序,null 键按其初次 put 的位置落在链表中;如果是访问顺序,对 null 键执行 get 同样会将它移至链表尾部。
  • 追问模拟:“如果在 LRU 缓存中使用 null 作为键,会不会有问题?”
    • 回答:技术上可以工作,但强烈不推荐。因为 null 作为键含义模糊,容易导致业务逻辑混乱,且在多数序列化、RPC 或某些缓存框架中,null 键可能被禁止或产生不可预知的行为。此外,null 键也增加了 NullPointerException 的风险。
  • 加分回答:在严格的工程实践中,更提倡使用 Optional 或专门的空对象模式来代替 null,尤其是在作为缓存 Key 的场景,这可以避免模棱两可的状态,并使缓存的语义更加清晰。

9. 为什么说 LinkedHashMap 占用更多内存?多在哪里?

  • 标准回答:LinkedHashMap 的内存开销主要集中在它的内部节点类 Entry<K,V> 上。每一个 Entry 除了继承自 HashMap.Nodekey, value, hash, next 四个字段外,还额外增加了两个对象引用字段:Entry<K,V> beforeEntry<K,V> after。在 64 位 JVM 且开启指针压缩(-XX:+UseCompressedOops)的情况下,每个引用占用 4 字节,因此每个节点比 HashMap 的 Node 多出 8 字节。如果 LinkedHashMap 中有 100 万个条目,仅这两个指针就会带来约 8 MB 的额外内存消耗。此外,类本身还有 headtail 两个 Entry 引用,其开销几乎可以忽略不计。
  • 追问模拟:“这个额外的内存开销会引发严重的性能问题吗?”
    • 回答:对大多数应用来说,这点开销是可接受的。但在内存敏感或数据量巨大(如千万级条目)的场景下,额外的内存意味着更高的 GC 频率和更严重的内存碎片。此时就需要权衡:是用这 8 字节换有序性,还是使用 HashMap 更节约内存。相比 TreeMap(每个节点还要存 parent, left, right, color 等),LinkedHashMap 的额外开销已经算小的。
  • 加分回答:在一些超高吞吐低延迟系统中,甚至可能会选择使用 LinkedHashMap 的轻量级替代方案,比如基于数组的双端队列加哈希表自己组合,以便更精细地控制内存布局。不过,对于绝大多数业务场景,LinkedHashMap 的“适度开销 + 顺序保证”依然是最具性价比的选择。

10. 请手写一个利用 LinkedHashMap 实现固定大小的 LRU 缓存示例。

  • 标准回答与示例代码
import java.util.LinkedHashMap;
import java.util.Map;

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

    public FixedSizeLRUCache(int maxSize) {
        // 参数说明:初始容量,负载因子,访问顺序模式
        // 使用 maxSize*2 作为初始容量以减少扩容,负载因子 0.75 保持空间与时间的平衡
        super(maxSize, 0.75f, true);
        this.maxSize = maxSize;
    }

    /**
     * 重写淘汰条件:当 Map 大小超过设定的最大容量时返回 true,
     * 这会触发自动移除最久未被访问(也即链表头)的元素。
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > maxSize;
    }

    // 测试
    public static void main(String[] args) {
        FixedSizeLRUCache<Integer, String> cache = new FixedSizeLRUCache<>(3);
        cache.put(1, "one");
        cache.put(2, "two");
        cache.put(3, "three");
        System.out.println(cache); // {1=one, 2=two, 3=three}

        cache.get(1); // 访问 1,使其成为最新
        cache.put(4, "four"); // 插入 4,2 作为最老元素被淘汰
        System.out.println(cache); // {3=three, 1=one, 4=four}
    }
}
  • 追问模拟:“initialCapacity 设多少最合适?为什么这里用了 maxSize?”

    • 回答:这里设为 maxSize 是为了避免在达到目标容量之前就因负载因子(0.75)而触发不必要的一次扩容。由于极大概率容量就稳定在 maxSize,用 maxSize 作为初始容量可直接安排足够的桶空间,避免插入过程中的扩容抖动。在生产中也可以直接指定 new LinkedHashMap(maxSize, 0.75f, true),或使用 (int) (maxSize / 0.75f) + 1 以完全杜绝扩容。
  • 追问模拟:“如果我想在淘汰前做点额外操作,比如将淘汰的数据写回数据库,该怎么扩展?”

    • 回答:直接在 removeEldestEntry 方法中,当判断到要返回 true 时,可以先从 eldest 参数中取出键和值进行持久化或回调,然后再返回 true。或者在重写的 remove 方法中捕获删除事件,但需注意避免循环调用。
  • 加分回答:这个实现是线程不安全的。要使其线程安全,可以使用 Collections.synchronizedMap(new FixedSizeLRUCache<>(...)),但这种方法在迭代时仍需手动同步。更优方案是使用并发安全的缓存库,如 Caffeine,它提供了真正的线程安全、自动淘汰、统计和监听功能,且在高并发下性能卓越。但在面试或简单场景下,手写 LRU 通过 LinkedHashMap 演示了对数据结构和继承特性的深刻理解。