集合-Set-LinkedHashSet

5 阅读49分钟

概述

LinkedHashSet 是 Java 集合框架中一个独具匠心的设计——它继承自 HashSet,却借助 LinkedHashMap 实现了可预测的迭代顺序。其核心设计精妙之处在于:HashSet 预留了一个包级私有的钩子构造器,允许子类通过一个 dummy 标记参数替换底层 Map 实现,LinkedHashSet 正是利用这一机制,在完全不重写任何增删改方法的情况下,将底层存储从 HashMap 替换为 LinkedHashMap,从而获得了双向链表的顺序维护能力。 这种“子类化父类并替换底层依赖”的设计模式,堪称 Java 集合框架中代码复用的典范。本文将为您揭示从顺序保证的底层机制、构造器钩子的巧妙设计、核心操作的完整调用链路,到与 HashSet/TreeSet 的全方位对比、常见陷阱与面试高频考点的全链路认知体系。

  • 继承 HashSet 但委托 LinkedHashMap:通过 HashSet 的包级私有构造器传入 dummy 标记,底层实际创建 LinkedHashMap,实现代码复用的同时扩展顺序能力。
  • 双向链表维护插入顺序:每个节点通过 before/after 指针形成贯穿链,迭代时严格按照元素插入顺序输出。
  • 可选的访问顺序模式:底层 LinkedHashMap 可通过构造参数 accessOrder=true 启用,元素被访问后被移至链表尾部,实现简单的 LRU 策略。
  • 插入与查询效率:增删查时间复杂度 O(1)(平均),但略低于 HashSet(因需要额外维护链表指针)。
  • 与 HashSet 和 TreeSet 的场景边界:需要可预测顺序时选 LinkedHashSet,需要排序时选 TreeSet,仅需快速去重时选 HashSet。

全文组织架构

flowchart TB
    subgraph PART0["【全文概览】"]
        A["LinkedHashSet 核心架构与定位"]
    end

    subgraph PART1["Part 1:基础认知篇"]
        B1["模块 1:定义、核心特性与适用场景"]
        B2["模块 2:接口与继承体系"]
    end

    subgraph PART2["Part 2:存储与构造篇"]
        C1["模块 3:存储结构与底层依赖(源码剖析)"]
        C2["模块 4:构造方法(源码剖析)"]
    end

    subgraph PART3["Part 3:核心原理篇"]
        D1["模块 5:add 操作——顺序插入与去重"]
        D2["模块 6:remove 与 contains 操作"]
    end

    subgraph PART4["Part 4:顺序维护与迭代篇"]
        E1["模块 7:迭代顺序的保证机制"]
        E2["模块 8:访问顺序模式与 LRU 实现"]
    end

    subgraph PART5["Part 5:对比与陷阱篇"]
        F1["模块 9:LinkedHashSet vs HashSet vs TreeSet"]
        F2["模块 10:常见陷阱与最佳实践"]
    end

    subgraph PART6["Part 6:总结与面试篇"]
        G1["模块 11:注意事项与性能总结"]
        G2["模块 12:面试高频专题"]
    end

    A --> PART1
    A --> PART2
    A --> PART3
    A --> PART4
    A --> PART5
    A --> PART6

    classDef highlight fill:#2c3e50,stroke:#1a252f,color:#ecf0f1
    class A highlight

图注说明:全文以六大篇章递进式组织。

  • 第一层——全文总览根节点:以 LinkedHashSet 核心架构与定位作为全文入口,引出六大篇章的递进式知识体系。

  • 第二层——六大篇章的逻辑递进关系

    • Part 1 基础认知篇:建立对 LinkedHashSet 的定义、特性、适用场景和继承体系的基本认知,为后续深入源码分析奠定概念基础。
    • Part 2 存储与构造篇:深入底层存储结构和构造方法的源码细节,重点揭示 HashSet 的包级私有构造器钩子设计,这是理解 LinkedHashSet 实现机制的关键切入点。
    • Part 3 核心原理篇:详细剖析 add、remove、contains 三大核心操作的完整调用链路,展示 LinkedHashSet 如何通过父子类协作实现顺序维护。
    • Part 4 顺序维护与迭代篇:聚焦迭代顺序的保证机制和访问顺序模式,揭示双向链表的遍历原理以及 LRU 的实现方案。
    • Part 5 对比与陷阱篇:将 LinkedHashSet 与 HashSet、TreeSet 进行全方位对比,并总结开发中的常见陷阱与最佳实践。
    • Part 6 总结与面试篇:汇总性能特征、注意事项,并集中呈现面试高频专题,覆盖十大必考知识点。
  • 第三层——模块内部细分:每个 Part 包含两个模块,模块之间形成从理论到实践、从源码到应用的递进关系,确保每个主题都有充分的深度覆盖。

Part 1:基础认知篇

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

1.1 明确定义

LinkedHashSet 是继承自 HashSet 的、具有可预测迭代顺序(插入顺序或访问顺序)的、不允许重复元素的集合实现。 它在 Java 集合框架中位于 java.util 包下,实现了 SetCloneableSerializable 接口。从其名称可以看出,LinkedHashSet = Linked(链表)+ HashSet(哈希集合),它融合了哈希表的高效查找能力和双向链表的顺序维护能力。

1.2 核心特性列表

特性说明
可预测迭代顺序默认按元素首次插入顺序输出,可选按访问顺序输出
不允许重复元素基于元素的 hashCode()equals() 方法判断唯一性
允许 null 元素最多允许一个 null(与 Set 接口约定一致)
底层数据结构哈希表 + 双向链表(委托 LinkedHashMap 实现)
平均性能增删查 O(1),迭代 O(n),略低于 HashSet(需维护链表指针)
非线程安全无内建同步机制,多线程访问需外部同步
fail-fast 迭代器迭代过程中若结构被修改(非迭代器自身操作),抛出 ConcurrentModificationException

1.3 适用场景与反例场景

适用场景:

  • 需要保持原插入顺序的缓存:如缓存配置加载顺序,确保后续处理按配置文件中定义的次序执行。
  • 去重后仍按原始顺序处理:如从日志流中提取唯一 IP,但需要保留首次出现的先后顺序。
  • LRU 简单实现:利用底层 LinkedHashMap 的 accessOrder 功能,实现最近最少使用淘汰策略。
  • JSON/XML 配置去重但需保持声明顺序:解析配置文件时去重但需按声明顺序处理。

反例场景:

  • 需要元素自然排序:如字典序、数值排序 → 改用 TreeSet。
  • 仅需快速去重且不关心顺序:如海量数据快速判重 → 改用 HashSet(性能略优)。
  • 高并发场景:→ 改用 ConcurrentHashMap.newKeySet() 或 Collections.synchronizedSet()。

1.4 LinkedHashSet 特性与适用场景决策树

flowchart TD
    A["需要集合存储唯一元素"] --> B{"是否关心元素顺序?"}
    B -->|"不关心顺序"| C["使用 HashSet<br/>最快去重性能"]
    B -->|"关心顺序"| D{"需要哪种顺序?"}
    D -->|"插入顺序"| E["使用 LinkedHashSet<br/>默认构造"]
    D -->|"访问顺序(LRU)"| F{"是否需要<br/>访问顺序模式?"}
    F -->|"需要"| G["子类化 LinkedHashSet<br/>启用 accessOrder 或直接<br/>使用 LinkedHashMap"]
    F -->|"不需要"| E
    D -->|"自然排序/自定义排序"| H["使用 TreeSet"]

    style E fill:#2c3e50,stroke:#1a252f,color:#ecf0f1
    style A fill:#34495e,stroke:#1a252f,color:#ecf0f1
    style H fill:#7f8c8d,stroke:#1a252f,color:#ecf0f1
    style C fill:#7f8c8d,stroke:#1a252f,color:#ecf0f1
    style G fill:#7f8c8d,stroke:#1a252f,color:#ecf0f1

图注说明:决策树展示了 Set 实现类的选型逻辑。

  • 第一层——Set 选型的核心考量维度:判断“是否需要可预测的迭代顺序”是选择 HashSet、LinkedHashSet 或 TreeSet 的首要决策分支点。不关心顺序时直接选用 HashSet 获得最快性能,需要排序时转向 TreeSet,两者在需求明确时可直接决策。

  • 第二层——顺序类型的二次区分:当用户需要可预测顺序但不确定具体顺序类型时,进入二级决策分支

    • 插入顺序(Insertion-Order) 是最常见的需求场景,直接通过 LinkedHashSet 默认构造即可满足,图中以高亮节点进行强调。
    • 访问顺序(Access-Order) 是 LRU 缓存等特定场景的需求,LinkedHashSet 的公开 API 并不直接提供 accessOrder 参数入口,需要通过子类化并重写构造方法直接使用 LinkedHashMap 来实现。
    • 自然排序/自定义排序 始终由 TreeSet 处理,通过元素的 Comparable 接口或外部 Comparator 实现,时间复杂度为 O(log n)。
  • 第三层——与本文定位的精确对应:图中 LinkedHashSet 对应节点采用高亮样式,突出其在“需要顺序但不需排序”场景中的独特价值。此外,内存开销因素在选型时也不容忽视——LinkedHashSet 比 HashSet 多出双向链表指针的额外开销,每个元素约增加 8-16 字节(64 位 JVM 上),在大规模数据场景下应纳入考量。

模块 2:接口与继承体系

2.1 类声明与继承链

LinkedHashSet 的类声明极为简洁:

public class LinkedHashSet<E>
    extends HashSet<E>
    implements Set<E>, Cloneable, java.io.Serializable

继承 HashSet,而非直接继承 AbstractSet。这意味着 LinkedHashSet 获得了 HashSet 的所有行为——包括 add、remove、contains、size、iterator 等全部方法——而无需重写任何代码。它仅额外实现了 Cloneable 和 Serializable 接口,以支持克隆和序列化。

2.2 继承体系类图

classDiagram
    class Iterable {
        <<interface>>
    }
    class Collection {
        <<interface>>
    }
    class Set {
        <<interface>>
    }
    class AbstractCollection {
        <<abstract>>
    }
    class AbstractSet {
        <<abstract>>
    }
    class HashSet {
        -HashMap map
        -Object PRESENT
        +HashSet()
        #HashSet(int float boolean)
        +add(E e)
        +remove(Object o)
        +contains(Object o)
        +iterator()
    }
    class LinkedHashSet {
        +LinkedHashSet()
        +LinkedHashSet(int)
        +LinkedHashSet(int float)
        +LinkedHashSet(Collection)
        +spliterator()
    }
    class HashMap {
        +put(K V)
        +remove(Object)
        +containsKey(Object)
    }
    class LinkedHashMap {
        -Entry head
        -Entry tail
        -boolean accessOrder
        +LinkedHashMap(int float boolean)
        +get(Object)
        +newNode()
        +afterNodeAccess()
        +afterNodeInsertion()
        +afterNodeRemoval()
    }
    class LinkedHashMapEntry {
        -Entry before
        -Entry after
    }
    class HashMapNode {
        -int hash
        -K key
        -V value
        -Node next
    }

    Iterable <|.. Collection
    Collection <|.. Set
    AbstractCollection <|-- AbstractSet
    AbstractSet <|-- HashSet
    HashSet <|-- LinkedHashSet
    Set <|.. HashSet
    Set <|.. LinkedHashSet
    HashMap <|-- LinkedHashMap
    LinkedHashMap --* HashSet
    HashMapNode <|-- LinkedHashMapEntry
    LinkedHashMapEntry --* LinkedHashMap

图注说明:类图展示了 LinkedHashSet 的完整继承和委托关系。

  • 第一层——Set 规范继承链:从顶层接口 IterableCollectionSet,到抽象实现 AbstractCollectionAbstractSet,再到具体实现 HashSetLinkedHashSetLinkedHashSet 位于继承链的最末端,直接继承 HashSet,无需重写 AbstractSet 的任何方法。

  • 第二层——核心委托关系(HashSet → HashMap/LinkedHashMap)

    • HashSet 内部组合了一个 HashMap 实例(成员变量 map),所有 Set 操作均委托给该 Map。
    • 关键设计HashSet 提供了一个包级私有的特殊构造器 HashSet(int, float, boolean),其中第三个参数 dummy纯标记参数,内部实际被忽略。该构造器创建的是 LinkedHashMap 而非 HashMap,从而为 LinkedHashSet 提供了委托替换的钩子。图中以 --* 组合关系标注了 LinkedHashMap --* HashSet,明确指出两者的连接关系。
  • 第三层——链表节点继承体系

    • HashMap.Node 为基础节点,包含 hashkeyvaluenext 四个字段,用于解决哈希冲突。
    • LinkedHashMap.Entry 继承 HashMap.Node额外新增了 beforeafter 两个引用,用于维护双向链表的前后指针关系。
    • 红黑树节点 TreeNode 继承 LinkedHashMap.Entry,从而也具备了双向链表的能力(但非链表模式时不使用这些引用)。
  • 第四层——关键设计结论

    • LinkedHashSet 无任何自定义字段,完全依赖继承的 map 字段,仅通过构造器替换底层 Map 类型。
    • HashMap.put 等方法内部调用 newNode() 创建节点,而 LinkedHashMap 重写了 newNode() 方法返回 LinkedHashMap.Entry 实例,并在创建节点的同时调用 linkNodeLast() 维护双向链表。
    • afterNodeAccessafterNodeInsertionafterNodeRemoval 三个钩子方法在 HashMap 中为空实现,由 LinkedHashMap 重写以维护对应的链表操作逻辑。这三个钩子是 LinkedHashMap 在不破坏 HashMap 原有逻辑的前提下实现顺序维护的模板方法模式核心

Part 2:存储与构造篇

模块 3:存储结构与底层依赖(源码剖析)

3.1 LinkedHashSet 的无字段设计

一个经验丰富的 Java 开发者第一次阅读 LinkedHashSet 源码时,第一反应往往是惊讶——这个类竟然没有任何自己的成员变量。除了四个构造方法和一个 spliterator() 方法外,它完全是空白的:

// LinkedHashSet 源码概览(JDK 8)
public class LinkedHashSet<E>
    extends HashSet<E>
    implements Set<E>, Cloneable, java.io.Serializable {

    private static final long serialVersionUID = -2851667679971038690L;

    // 四个构造方法均调用 super(initialCapacity, loadFactor, true)
    public LinkedHashSet(int initialCapacity, float loadFactor) {
        super(initialCapacity, loadFactor, true);
    }

    public LinkedHashSet(int initialCapacity) {
        super(initialCapacity, .75f, true);
    }

    public LinkedHashSet() {
        super(16, .75f, true);
    }

    public LinkedHashSet(Collection<? extends E> c) {
        super(Math.max(2*c.size(), 11), .75f, true);
        addAll(c);
    }

    @Override
    public Spliterator<E> spliterator() {
        return Spliterators.spliterator(this, Spliterator.DISTINCT | Spliterator.ORDERED);
    }
}

与其它集合类的源码长度对比:HashSet 约 300+ 行源码(包含构造器、add、remove 等完整实现),LinkedHashMap 约 600+ 行(包含双向链表、LRU 相关逻辑),而 LinkedHashSet 总共仅约 50 行。这种极致的简洁,正是 JDK 设计者通过巧妙的重构手段实现零重复代码(Zero Boilerplate)的典范——既避免了在 LinkedHashSet 中重复实现一套“带顺序的 Set”逻辑,也避免了将 LinkedHashMap 的逻辑硬编码进 HashSet 造成耦合。

3.2 HashSet 的包级私有钩子构造器

这是整个设计中最巧妙的环节。HashSet 提供了一个包级私有(package-private)的构造器:

/**
 * Constructs a new, empty linked hash set.  (This package private
 * constructor is only used by LinkedHashSet.) The backing
 * HashMap instance is a LinkedHashMap with the specified initial
 * capacity and the specified load factor.
 *
 * @param      initialCapacity   the initial capacity of the hash map
 * @param      loadFactor        the load factor of the hash map
 * @param      dummy             ignored (distinguishes this
 *             constructor from other int, float constructor.)
 * @throws     IllegalArgumentException if the initial capacity is less
 *             than zero, or if the load factor is nonpositive
 */
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
    map = new LinkedHashMap<>(initialCapacity, loadFactor);
}

关键设计点分析:

  1. 访问权限为默认(包级私有):该构造器没有 public 修饰符,意味着只有 java.util 包内的类才能访问。LinkedHashSet 正好位于同一包内,可以直接调用。外部用户无法直接通过此构造器创建 HashSet 实例并替换底层 Map。

  2. dummy 参数——纯签名区分器:第三个参数 dummy 的值在方法体内完全未被使用。它的唯一作用是让编译器和 JVM 能够区分这个构造器签名与另一个公开构造器 HashSet(int initialCapacity, float loadFactor)。若没有 dummy 参数,两者签名完全相同,将导致编译错误(Constructor ambiguity)。Javadoc 中明确写道:“@param dummy ignored (distinguishes this constructor from other int, float constructor.)”。

  3. 底层创建 LinkedHashMap:与 HashSet 默认构造器创建 new HashMap<>() 不同,此构造器创建的是 new LinkedHashMap<>(initialCapacity, loadFactor)。由于 HashSet 内部通过多态调用 map 的方法,而 map 的实际运行时类型是 LinkedHashMap,因此所有操作自动获得双向链表维护能力。

  4. accessOrder 默认值为 false:此构造器创建的 LinkedHashMap 未传入第三个参数 accessOrder,因此默认使用插入顺序模式。这意味着标准 LinkedHashSet 只支持插入顺序,不直接支持访问顺序。这也是 LinkedHashSet 的一个明显设计限制。

3.3 LinkedHashMap.Entry 节点结构

LinkedHashMap 的条目节点是理解顺序维护的关键:

// 位于 LinkedHashMap.java
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);
    }
}

字段说明:

字段来源作用
hash继承自 HashMap.Node存储 key 的哈希值,用于哈希表的桶定位
key继承自 HashMap.Node存储的元素(Set 中为元素本身)
value继承自 HashMap.Node在 Set 中始终为 PRESENT 常量
next继承自 HashMap.Node解决哈希冲突,指向同一个桶中的下一个节点
beforeLinkedHashMap.Entry 新增指向双向链表中当前节点的前驱节点
afterLinkedHashMap.Entry 新增指向双向链表中当前节点的后继节点

3.4 LinkedHashMap 维护双向链表的关键字段与方法

classDiagram
    class LinkedHashMap {
        -transient Entry head
        -transient Entry tail
        -boolean accessOrder
        +void linkNodeLast(Entry p)
        +void afterNodeAccess(Node e)
        +void afterNodeInsertion(boolean evict)
        +void afterNodeRemoval(Node e)
    }
    class HashMap {
        +Node newNode(int hash, K key, V value, Node next)
        +void afterNodeAccess(Node p)
        +void afterNodeInsertion(boolean evict)
        +void afterNodeRemoval(Node p)
    }
    class Entry {
        -Entry before
        -Entry after
    }

    HashMap <|-- LinkedHashMap
    Entry --* LinkedHashMap

图注说明:LinkedHashMap 双向链表维护的核心组件。

  • 第一层——head 和 tail 引用head 指向双向链表的头节点(最早插入)tail 指向尾节点(最近插入或最近访问)。这两个引用是链表遍历的起始入口。注意:head 和 tail 在 LinkedHashSet 场景中始终指向实际元素节点,而非 Hash 桶的引用。

  • 第二层——accessOrder 标志字段:控制链表的排序模式。

    • false(默认):插入顺序模式——新节点追加到链表尾部,重复 put 不改变现有节点位置。
    • true访问顺序模式——任何访问操作(get、put 更新)都会将节点移动到链表尾部。
  • 第三层——钩子方法体系(模板方法模式的核心)

    • newNode() 重写创建 LinkedHashMap.Entry 并调用 linkNodeLast() 插入链表尾部。
    • afterNodeAccess(Node e):HashMap 中为空实现,LinkedHashMap 重写后在 accessOrder=true 时将访问节点移至链表尾部。
    • afterNodeInsertion(boolean evict):HashMap 中为空实现,LinkedHashMap 重写后检查是否需要移除最老条目(支持 LRU 的 removeEldestEntry 机制)。
    • afterNodeRemoval(Node e):HashMap 中为空实现,LinkedHashMap 重写后从双向链表中摘除被删除节点(更新 before/after 指针)。

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

4.1 四个公开构造器的统一调用链

LinkedHashSet 的四个公开构造器,无一例外地调用了 HashSet 的包级私有构造器

// 构造器 1:指定初始容量和负载因子
public LinkedHashSet(int initialCapacity, float loadFactor) {
    super(initialCapacity, loadFactor, true);  // dummy = true
}

// 构造器 2:指定初始容量,负载因子默认 0.75
public LinkedHashSet(int initialCapacity) {
    super(initialCapacity, .75f, true);        // dummy = true
}

// 构造器 3:默认构造器,容量 16,负载因子 0.75
public LinkedHashSet() {
    super(16, .75f, true);                     // dummy = true
}

// 构造器 4:从指定集合构造
public LinkedHashSet(Collection<? extends E> c) {
    super(Math.max(2*c.size(), 11), .75f, true);
    addAll(c);
}

关键设计推论:

  1. dummy 参数恒为 true:该参数在方法体内被忽略,但必须传入一个 boolean 值以匹配第一个构造器签名。JDK 设计者选择传入 true 纯粹是惯例。

  2. 容量计算中的经验公式 Math.max(2*c.size(), 11):第三个构造器的容量计算使用了 2*c.size() 作为基准。这确保了在 addAll 循环插入过程中,HashMap 不会因为负载因子 0.75 的扩容触发阈值而被过早扩容2 * size / 0.75 ≈ 2.67 * size,远大于实际元素数)。下限 11 是为极小的集合(如 size=1)保留合理的初始容量(HashMap 扩容触发阈值 threshold = capacity * loadFactor,若 capacity 过小则插入首个元素时即触发扩容)。

  3. 没有公开的 accessOrder 构造器:这是 LinkedHashSet 的一个显著局限性。标准 API 无法直接创建访问顺序模式的 LinkedHashSet。若需要此功能,必须通过反射或子类化。

4.2 构造演示 Demo

import java.util.*;

public class LinkedHashSetConstructionDemo {
    public static void main(String[] args) {
        // 1. 默认构造——插入顺序模式,容量 16,负载因子 0.75
        LinkedHashSet<String> set1 = new LinkedHashSet<>();
        set1.add("C");
        set1.add("A");
        set1.add("B");
        System.out.println("默认构造,迭代顺序: " + set1);  // [C, A, B]

        // 2. 指定初始容量
        LinkedHashSet<String> set2 = new LinkedHashSet<>(32);
        set2.add("X");
        set2.add("Y");
        set2.add("Z");
        System.out.println("指定容量,迭代顺序: " + set2);  // [X, Y, Z]

        // 3. 从集合构造——保留原集合的迭代顺序
        List<String> list = Arrays.asList("One", "Two", "Three", "Two");
        LinkedHashSet<String> set3 = new LinkedHashSet<>(list);
        System.out.println("从集合构造,去重且保留顺序: " + set3);  // [One, Two, Three]

        // 4. 重复 put 不改变顺序(accessOrder=false)
        set1.add("A");  // A 已存在
        System.out.println("重复添加 A 后迭代顺序: " + set1);  // [C, A, B],顺序不变

        // 5. null 元素的处理
        LinkedHashSet<String> set4 = new LinkedHashSet<>();
        set4.add(null);
        set4.add("Hello");
        set4.add(null);  // 重复 null,不会添加
        System.out.println("包含 null 的集合: " + set4);  // [null, Hello]
    }
}

// ===== 运行结果 =====
// 默认构造,迭代顺序: [C, A, B]
// 指定容量,迭代顺序: [X, Y, Z]
// 从集合构造,去重且保留顺序: [One, Two, Three]
// 重复添加 A 后迭代顺序: [C, A, B]
// 包含 null 的集合: [null, Hello]

Part 3:核心原理篇

模块 5:add 操作——顺序插入与去重(源码剖析)

5.1 继承链中的方法调用

LinkedHashSet 本身没有重写 add 方法,完全继承自 HashSet:

// HashSet.java
public boolean add(E e) {
    return map.put(e, PRESENT) == null;
}

由于构造器中已将 map 替换为 LinkedHashMap 实例,因此 map.put(e, PRESENT) 实际上调用的是 LinkedHashMap(继承自 HashMap)的 put 方法。PRESENT 是 HashSet 中定义的一个静态常量哑元对象:

// HashSet.java
private static final Object PRESENT = new Object();

Set 的元素作为 Map 的 Key 存储,所有 Key 共享同一个 Value(PRESENT 常量)。return 值的判断逻辑Map.put 返回旧 value 对象(若 Key 此前存在),若 Key 此前不存在则返回 null。因此 map.put(e, PRESENT) == nulltrue 表示元素是新插入的,方法返回 true;为 false 表示元素已存在(即重复),方法返回 false。这一设计同时实现了 Set 的去重语义和插入成功/失败的返回值规则。

5.2 HashMap.putVal 内部的关键钩子点

HashMap 的 put 方法内部调用 putVal 方法,其中有三个关键位置调用了子类可重写的钩子方法:

// HashMap.putVal 简化代码
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    // ... 桶定位逻辑 ...
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);  // 钩子点 1:创建新节点
    else {
        // ... 冲突处理(链表遍历或红黑树操作)...
        if (e != null) { // 找到了相同 key 的旧节点
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);    // 钩子点 2:访问节点后处理
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);     // 钩子点 3:插入完成后处理
    return null;
}

5.3 LinkedHashMap 对这些钩子的重写

钩子点 1——newNode() → linkNodeLast():

// LinkedHashMap.java
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;
}

// linkNodeLast 方法——双向链表尾插法
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;                     // 旧尾节点的后继指向新节点
    }
}

钩子点 2——afterNodeAccess(): 在插入顺序模式(accessOrder=false)下为空操作;在访问顺序模式(accessOrder=true)下才会将节点移到链表尾部。

钩子点 3——afterNodeInsertion(): 检查 removeEldestEntry 条件(用于 LRU 淘汰策略,默认返回 false 不淘汰)。

5.4 add 操作完整流程图

flowchart TD
    A["LinkedHashSet.add(E e)"] --> B["HashSet.add(e)<br/>return map.put(e, PRESENT)==null"]
    B --> C["LinkedHashMap.put(e, PRESENT)<br/>(继承自HashMap.put)"]
    C --> D["HashMap.putVal(hash, key, value, ...)"]
    D --> E{"该 key 的 hash<br/>对应的桶位是否为空?"}
    E -->|"空桶"| F["LinkedHashMap.newNode()"]
    F --> G["创建 LinkedHashMap.Entry 节点"]
    G --> H["linkNodeLast(新节点)<br/>追加到双向链表尾部"]
    H --> I{"链表此前为空?<br/>(tail == null)"}
    I -->|"是"| J["head = tail = 新节点"]
    I -->|"否"| K["更新 tail 和新节点的<br/>before/after 指针"]
    J --> L["afterNodeInsertion(evict)<br/>检查是否要淘汰最老节点"]
    K --> L
    L --> M["return null → add返回true<br/>✅ 插入成功"]

    E -->|"非空桶"| N{"沿链表/红黑树<br/>查找是否已有<br/>相同 key?"}
    N -->|"找到相同key"| O["更新 value 为 PRESENT<br/>afterNodeAccess(e)"]
    O --> P["return 旧value ≠ null<br/>❌ add返回false(重复元素)"]
    N -->|"未找到"| F

    style A fill:#2c3e50,stroke:#1a252f,color:#ecf0f1
    style H fill:#34495e,stroke:#1a252f,color:#ecf0f1
    style M fill:#27ae60,stroke:#1a252f,color:#fff
    style P fill:#e74c3c,stroke:#1a252f,color:#fff

图注说明:add 操作的六层调用链路与双向链表维护过程。

  • 第一层——LinkedHashSet.add 入口:调用链起点为 LinkedHashSet.add(E e),该方法完全继承自 HashSet,内部仅一行代码 return map.put(e, PRESENT) == null背景机制:因为构造器已将 map 替换为 LinkedHashMap 实例,map.put() 的实际运行时类型是 LinkedHashMap。

  • 第二层——HashMap.putVal 核心逻辑分流LinkedHashMap.put 继承自 HashMap.put,内部调用 putVal 方法。这是 HashMap 存储逻辑的核心,根据哈希桶位是否为空分流到两种处理路径。注意:LinkedHashMap 没有重写 putVal,零侵入式复用父类逻辑。

  • 第三层——空桶路径(新节点创建)

    • 步骤 A:调用 LinkedHashMap.newNode() —— 这是 LinkedHashMap 重写的钩子方法,创建的是 LinkedHashMap.Entry 类型节点(包含 before/after 指针)。
    • 步骤 B:调用 linkNodeLast() —— 这是维护插入顺序的关键方法,将新节点追加到双向链表尾部。
    • 步骤 C:判断链表是否为空(tail == null),首次插入同时设置 head 和 tail;后续插入只更新 tail 和指针关系。
  • 第四层——非空桶路径(冲突处理)

    • 步骤 A:遍历桶内的链表或红黑树结构,通过 hashCode()equals() 查找是否已有相同 Key 的元素。
    • 步骤 B:若找到相同 Key,只更新 Value(将旧的 PRESENT 替换为新的 PRESENT),不调用 newNode,不修改双向链表顺序(accessOrder=false 时)。
    • 步骤 C:若未找到,走空桶路径创建新节点。
  • 第五层——结果返回

    • 新插入返回 null → HashSet.add 判断 null == null → 返回 true插入成功
    • 重复元素返回旧 PRESENT(非 null)→ HashSet.add 判断 非null == null → 返回 false元素重复,拒绝插入
  • 第六层——关键设计结论

    • LinkedHashSet 的重复判断完全依赖 HashMap 的 hashCode()equals() 机制,而顺序维护完全是 LinkedHashMap 通过 newNode()linkNodeLast() 实现的附加行为,两者在 HashMap 的 putVal 方法中无缝衔接
    • 模板方法模式是理解这套机制的关键——HashMap 定义了算法骨架(putVal 流程),LinkedHashMap 通过重写 newNodeafterNodeAccessafterNodeInsertion 等方法在骨架的关键节点注入链表维护逻辑,实现“不修改框架即可扩展行为”的优雅设计。

模块 6:remove 与 contains 操作(源码剖析)

6.1 remove 操作——三层视角的调用链

LinkedHashSet 的 remove 同样继承自 HashSet:

// HashSet.java
public boolean remove(Object o) {
    return map.remove(o) == PRESENT;
}

第一层(Set 层):HashSet 委托给 Map 的 remove 方法,判断返回值是否为 PRESENT。

第二层(HashMap 层):HashMap.remove 查找并移除节点,关键钩子点在 afterNodeRemoval

// HashMap.java removeNode 方法(简化)
final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    // ... 查找节点 ...
    if (node != null && (!matchValue || (v = node.value) == value ||
                         (value != null && value.equals(v)))) {
        if (node instanceof TreeNode)
            ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
        else if (node == p)
            tab[index] = node.next;
        else
            p.next = node.next;
        ++modCount;
        --size;
        afterNodeRemoval(node);  // 钩子点:从双向链表中摘除节点
        return node;
    }
    return null;
}

第三层(LinkedHashMap 层):afterNodeRemoval 摘除双向链表中的节点引用:

// LinkedHashMap.java
void afterNodeRemoval(Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p =
        (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
    p.before = p.after = null;   // 清空被删除节点的指针,帮助 GC
    if (b == null)               // 被删除的是头节点
        head = a;                // 头节点后继成为新头节点
    else
        b.after = a;             // 前驱节点的后继跳过当前节点
    if (a == null)               // 被删除的是尾节点
        tail = b;                // 尾节点的前驱成为新尾节点
    else
        a.before = b;            // 后继节点的前驱跳过当前节点
}

6.2 contains 操作——纯查询,不改变链表

// HashSet.java
public boolean contains(Object o) {
    return map.containsKey(o);
}

contains 直接调用 LinkedHashMap.containsKey(继承自 HashMap),利用哈希查找定位桶位后遍历链表/红黑树进行匹配。此操作不涉及 newNode、afterNodeAccess、afterNodeInsertion 等任何钩子方法,不会改变双向链表的顺序。这是 LinkedHashSet 与访问顺序模式的关键区别——在插入顺序模式下,contains 不改变元素位置。

6.3 remove 操作流程图

flowchart TD
    A["LinkedHashSet.remove(Object o)"] --> B["HashSet.remove(o)<br/>return map.remove(o)==PRESENT"]
    B --> C["LinkedHashMap.remove(o)<br/>(继承自HashMap.remove)"]
    C --> D["HashMap.removeNode(hash, key, ...)"]
    D --> E{"通过hash定位桶位<br/>查找目标节点"}
    E -->|"未找到"| F["return null →<br/>remove返回false ❌"]
    E -->|"找到节点 node"| G["从哈希桶中摘除 node<br/>(修改next指针或<br/>红黑树结构调整)"]
    G --> H["afterNodeRemoval(node)<br/>🔑 LinkedHashMap重写的钩子"]
    H --> I{"判断 node 在<br/>双向链表中的位置"}
    I -->|"是 head(头节点)"| J["head = node.after<br/>(后继成为新头)"]
    I -->|"是 tail(尾节点)"| K["tail = node.before<br/>(前驱成为新尾)"]
    I -->|"中间节点"| L["node.before.after = node.after<br/>node.after.before = node.before"]
    L --> M["node.before = node.after = null<br/>(清空指针帮助GC)"]
    J --> M
    K --> M
    M --> N["return node →<br/>remove返回true ✅"]

    style A fill:#2c3e50,stroke:#1a252f,color:#ecf0f1
    style H fill:#34495e,stroke:#1a252f,color:#ecf0f1
    style N fill:#27ae60,stroke:#1a252f,color:#fff
    style F fill:#e74c3c,stroke:#1a252f,color:#fff

图注说明:remove 操作的核心在于双向链表的节点摘除。

  • 第一层——方法委托链LinkedHashSet.remove -> HashSet.remove -> LinkedHashMap.remove -> HashMap.removeNode,与 add 操作相同的委托模式。

  • 第二层——哈希桶层面摘除:HashMap.removeNode 首先通过哈希定位找到节点所在的桶,然后通过修改 next 指针从哈希冲突链(或红黑树)中摘除节点。这一步是 HashMap 层面的通用逻辑,不涉及双向链表

  • 第三层——双向链表层面摘除(核心钩子)afterNodeRemoval(node) 是 LinkedHashMap 重写的钩子方法,负责从双向链表中摘除目标节点。

    • 三种位置的边界处理
      • 若被删除节点是头节点(b == null):将 head 指向其后继节点 a
      • 若被删除节点是尾节点(a == null):将 tail 指向其前驱节点 b
      • 若被删除节点在链表中间:通过 b.after = aa.before = b 将前后节点双向连接,跳过被删除节点。
    • 最后一步:将 node.beforenode.after 均置为 null,断开节点与链表的全部引用,帮助 GC 回收。
  • 第四层——返回值传递:HashMap.removeNode 返回被删除的节点 node → HashSet.remove 判断 node == PRESENT(此处返回的是 node 对象本身,equals 为真)→ 返回 true ✅ 删除成功。若未找到目标节点,removeNode 返回 null → HashSet.remove 判断 null == PRESENT → 返回 false ❌。

Part 4:顺序维护与迭代篇

模块 7:迭代顺序的保证机制(源码剖析)

7.1 迭代器获取链路

LinkedHashSet 的 iterator() 方法继承自 HashSet:

// HashSet.java
public Iterator<E> iterator() {
    return map.keySet().iterator();
}

关键点在于 map.keySet().iterator() 的实际行为取决于 map 的运行时类型。map 是 LinkedHashMap 时,keySet().iterator() 返回的是 LinkedKeyIterator;当 map 是 HashMap 时,返回的是 KeyIterator。两者的遍历策略完全不同。

7.2 HashMap KeyIterator vs LinkedHashMap LinkedKeyIterator

HashMap 的 KeyIterator(无序迭代)遍历的是哈希表数组,从 table[0]table[length-1] 依次访问每个非空桶的链表节点,顺序由哈希分布决定,不可预测。

LinkedHashMap 的 LinkedKeyIterator(有序迭代)完全绕过哈希表,直接从 head 引用出发,沿 after 指针依次遍历双向链表中的每个节点:

// LinkedHashMap.java 内部类 LinkedKeyIterator
final class LinkedKeyIterator extends LinkedHashIterator
    implements Iterator<K> {
    public final K next() { return nextNode().getKey(); }
}

// LinkedHashIterator 核心逻辑
abstract class LinkedHashIterator {
    LinkedHashMap.Entry<K,V> next;        // 下一个要返回的节点
    LinkedHashMap.Entry<K,V> current;     // 当前节点
    int expectedModCount;

    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();  // fail-fast
        if (e == null)
            throw new NoSuchElementException();
        current = e;
        next = e.after;                   // 沿 after 指针移动到下一个节点
        return e;
    }
}

7.3 迭代顺序保证的时序图

sequenceDiagram
    participant Client as 客户端代码
    participant LHS as LinkedHashSet
    participant LHM as LinkedHashMap
    participant Iterator as LinkedKeyIterator
    participant DLList as 双向链表(head→tail)

    Client->>LHS: 1. add("C")
    LHS->>LHM: put("C", PRESENT)
    LHM->>DLList: linkNodeLast → head=tail=C
    Client->>LHS: 2. add("A")
    LHS->>LHM: put("A", PRESENT)
    LHM->>DLList: linkNodeLast → C⇄A, tail=A
    Client->>LHS: 3. add("B")
    LHS->>LHM: put("B", PRESENT)
    LHM->>DLList: linkNodeLast → C⇄A⇄B, tail=B
    Client->>LHS: 4. add("A") [重复]
    LHS->>LHM: put("A", PRESENT)
    LHM-->>LHS: 返回旧值≠null (重复)
    Note over DLList: 链表不变:C⇄A⇄B

    Client->>LHS: 5. iterator()
    LHS->>LHM: keySet().iterator()
    LHM->>Iterator: new LinkedKeyIterator()
    Note over Iterator: next = head (节点C)

    Client->>Iterator: 6. hasNext() → true
    Client->>Iterator: 7. next() → "C"
    Note over Iterator: next = head.after (节点A)

    Client->>Iterator: 8. next() → "A"
    Note over Iterator: next = A.after (节点B)

    Client->>Iterator: 9. next() → "B"
    Note over Iterator: next = null (到达链表尾部)

    Client->>Iterator: 10. hasNext() → false
    Note over Client: 输出顺序:C → A → B<br/>严格等于插入顺序

图注说明:时序图分为两个阶段——元素插入阶段和迭代遍历阶段。

  • 第一阶段——元素插入(步骤 1-4)

    • 步骤 1:插入 "C",链表为空,linkNodeLast 将 head 和 tail 都指向 C 节点。
    • 步骤 2:插入 "A",链表已有节点,linkNodeLast 将 A 追加到 C 之后,tail 指向 A。此时链表为 C ⇄ A 表示双向连接)。
    • 步骤 3:插入 "B",追加到尾部,链表变为 C ⇄ A ⇄ B,tail 指向 B。
    • 步骤 4(关键):重复插入 "A"——HashMap.put 通过 hashCode()equals() 判断已存在相同元素,putVal 返回旧值(非 null),不会触发 newNode() 和 linkNodeLast()链表保持 C ⇄ A ⇄ B 不变,A 的位置没有改变。
  • 第二阶段——迭代遍历(步骤 5-10)

    • 步骤 5LinkedKeyIterator 构造时直接将 next 指向 head(C 节点),完全绕过哈希表桶索引(HashMap 的 KeyIterator 从 table[0] 开始扫描桶位,两者遍历策略截然不同)。
    • 步骤 6-9:通过 e.after 指针沿双向链表依次访问:C → A → B,每次 next() 都返回当前节点的 key 并将 next 更新为 e.after
    • 步骤 10:B 的 after 为 null(B 是尾节点),hasNext() 返回 false。
  • 第三层——关键结论

    • 迭代顺序 = 首次插入顺序,不受 hashCode 分布影响,不受哈希表扩容影响,不被重复插入改变。
    • LinkedHashMap 的迭代复杂度仅与元素数量 n 有关(O(n)),而 HashMap 的迭代需要遍历整张哈希表(包括空桶),复杂度与 capacity + n 有关。LinkedHashMap 的双向链表结构可以跳过所有空桶,直接访问有效元素,在稀疏哈希表场景中迭代性能实际优于 HashMap
    • fail-fast 机制:迭代器构造时记录 modCount,若检测到结构修改(非迭代器自身的 remove 操作),nextNode() 立即抛出 ConcurrentModificationException。

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

8.1 LinkedHashSet 缺少公开 accessOrder 入口的设计限制

如前所述,LinkedHashSet 的四个公开构造器均调用 HashSet(int, float, boolean),该构造器创建的 LinkedHashMap 未传入 accessOrder 参数,因此默认为 false(插入顺序模式)。标准 LinkedHashSet 不支持访问顺序模式。

8.2 启用访问顺序模式的途径

途径一:子类化 LinkedHashSet 并调用父类构造器(行不通)

因为 HashSet 的包级私有构造器不对子包开放,LinkedHashSet 的子类无法直接调用。

途径二:使用反射(可行但不推荐)

// 不推荐的反射方式(仅为演示原理)
Constructor<HashSet> c = HashSet.class.getDeclaredConstructor(
    int.class, float.class, boolean.class);
c.setAccessible(true);
HashSet<String> set = c.newInstance(16, 0.75f, true);
// set 实际上是 LinkedHashSet 的行为(底层是 LinkedHashMap)

途径三:直接使用 LinkedHashMap(推荐)

若需要访问顺序功能,最标准的方式是直接使用 LinkedHashMap 并传入 accessOrder=true

// 推荐方式:直接使用 LinkedHashMap
LinkedHashMap<String, Object> map = new LinkedHashMap<>(16, 0.75f, true);
// 然后通过 map.keySet() 获得一个具有访问顺序的 Set 视图
Set<String> set = map.keySet();

或者使用 Collections.newSetFromMap 包装:

Set<String> accessOrderedSet = Collections.newSetFromMap(
    new LinkedHashMap<String, Boolean>(16, 0.75f, true));

8.3 afterNodeAccess 的源码分析

在访问顺序模式下,当元素被 get 或重复 put 时,afterNodeAccess 方法将其移动到链表尾部:

// LinkedHashMap.java
void afterNodeAccess(Node<K,V> e) {
    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)       // e 是头节点
            head = a;        // 后继成为新头
        else
            b.after = a;     // 前驱跳过 e
        if (a != null)       // e 不是尾节点
            a.before = b;    // 后继的前驱指向前驱
        else
            last = b;        // 理论上不会进入(前面已判断 tail != e)
        if (last == null)
            head = p;        // 列表为空
        else {
            p.before = last; // p 的前驱指向旧尾节点
            last.after = p;  // 旧尾节点的后继指向 p
        }
        tail = p;             // p 成为新尾节点
        ++modCount;
    }
}

关键判断 (last = tail) != e:如果被访问的元素已经是尾节点,则不需要移动。这是一个性能优化——避免了链表已就绪时的无谓操作。

8.4 LRU 缓存实现 Demo

import java.util.*;

/**
 * 基于 LinkedHashMap 的 LRU 缓存实现
 * 利用 accessOrder=true 和 removeEldestEntry 钩子实现自动淘汰
 */
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int maxCapacity;

    public LRUCache(int maxCapacity) {
        // 关键:accessOrder=true 启用访问顺序模式
        super(16, 0.75f, true);
        this.maxCapacity = maxCapacity;
    }

    /**
     * 重写 removeEldestEntry 方法
     * 当 size() 超过 maxCapacity 时,自动移除最老的条目
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > maxCapacity;
    }

    // ===== Demo 测试 =====
    public static void main(String[] args) {
        LRUCache<String, Integer> cache = new LRUCache<>(3);

        // 插入 3 个元素(达到容量上限)
        cache.put("A", 1);
        cache.put("B", 2);
        cache.put("C", 3);
        System.out.println("初始缓存: " + cache.keySet());  // [A, B, C]

        // 访问 A——A 被移到链表尾部
        cache.get("A");
        System.out.println("访问 A 后: " + cache.keySet());  // [B, C, A]

        // 访问 B——B 被移到链表尾部
        cache.get("B");
        System.out.println("访问 B 后: " + cache.keySet());  // [C, A, B]

        // 插入新元素 D——触发淘汰最老节点 C
        cache.put("D", 4);
        System.out.println("插入 D 后(淘汰最老): " + cache.keySet()); // [A, B, D]

        // 验证 C 已被淘汰
        System.out.println("C 是否仍在缓存: " + cache.containsKey("C")); // false
    }
}

// ===== 运行结果 =====
// 初始缓存: [A, B, C]
// 访问 A 后: [B, C, A]
// 访问 B 后: [C, A, B]
// 插入 D 后(淘汰最老): [A, B, D]
// C 是否仍在缓存: false

8.5 afterNodeAccess 触发移动的时序图

sequenceDiagram
    participant Client as 客户端
    participant LHM as LinkedHashMap<br/>(accessOrder=true)
    participant HM as HashMap.putVal
    participant Node as 双向链表节点

    Note over LHM: 当前链表:A ⇄ B ⇄ C<br/>head=A, tail=C

    Client->>LHM: get("B")
    LHM->>HM: 哈希查找,找到节点 B
    HM->>LHM: afterNodeAccess(B)
    Note over LHM: 判断 accessOrder=true<br/>且 tail(C) ≠ B

    LHM->>Node: 1. 从链表中间摘除 B
    Note over Node: b=A, a=C<br/>A.after = C<br/>C.before = A
    Note over Node: 链表变为:A → C

    LHM->>Node: 2. 将 B 追加到链表尾部
    Note over Node: last = tail = C<br/>B.before = C<br/>C.after = B
    Note over Node: tail = B

    Note over LHM: 最终链表:A ⇄ C ⇄ B<br/>head=A, tail=B
    LHM-->>Client: 返回 B 对应的 value

    Note over Client: B 从中间位置被移到了尾部<br/>迭代顺序变为 A → C → B

图注说明:访问顺序模式下 afterNodeAccess 的两阶段节点移动过程。

  • 第一层——触发条件检查afterNodeAccess 仅在 accessOrder=true被访问节点不是当前尾节点tail != e)时执行移动。双重检查避免了不必要的链表操作开销。在插入顺序模式(accessOrder=false)下,该方法直接返回,不做任何操作

  • 第二层——节点摘除阶段:将目标节点从双向链表的当前位置摘除。

    • 更新前驱节点的 after 指针,使其指向目标节点的后继节点。
    • 更新后继节点的 before 指针,使其指向目标节点的前驱节点。
    • 三种边界情况处理:若 B 是头节点(b == null),更新 head = a;若 B 是尾节点(理论上不会进入此分支,因为前面已检查 tail != e)。
  • 第三层——节点追加阶段:将摘除的节点追加到链表尾部。

    • 将节点 B 的 before 指向旧尾节点 C。
    • 将旧尾节点 C 的 after 指向节点 B。
    • 更新 tail = B
  • 第四层——LRU 的重要设计推论

    • 链表头部 = 最久未访问的元素(即将被淘汰)。
    • 链表尾部 = 最近访问的元素。
    • removeEldestEntry 钩子配合 afterNodeInsertion 在每次新元素插入后检查是否需要淘汰 head 节点。完整的 LRU 淘汰时序为:插入新元素 → putVal 执行 → afterNodeInsertion(evict) → 判断 removeEldestEntry(eldest=head) 是否返回 true → 若 true,调用 HashMap.removeNode 移除 head 节点。

Part 5:对比与陷阱篇

模块 9:LinkedHashSet vs HashSet vs TreeSet——有序性的三种维度

9.1 全方位对比表

维度HashSetLinkedHashSetTreeSet
底层数据结构哈希表(HashMap)哈希表 + 双向链表(LinkedHashMap)红黑树(TreeMap)
迭代顺序无序(取决于哈希分布)插入顺序或访问顺序自然排序或自定义排序
null 元素允许一个 null允许一个 null不允许(需要比较)
add 时间复杂度O(1) 平均O(1) 平均(略慢)O(log n)
remove 时间复杂度O(1) 平均O(1) 平均(略慢)O(log n)
contains 时间复杂度O(1) 平均O(1) 平均(略慢)O(log n)
迭代时间复杂度O(capacity + n)O(n)O(n)
内存开销最低中等(多两个指针/节点)较高(红黑树节点)
额外接口NavigableSet、SortedSet
排序能力有(Comparable/Comparator)
典型场景快速去重去重+保留顺序排序+去重

9.2 Set 选型决策树

flowchart TD
    START["需要存储不重复的元素集合"] --> Q1{"是否需要<br/>可预测的迭代顺序?"}
    Q1 -->|"不需要"| A1["HashSet<br/>✅ 最快去重性能<br/>✅ 最低内存开销<br/>⚠️ 迭代顺序不可预测"]
    Q1 -->|"需要"| Q2{"需要哪种顺序?"}
    Q2 -->|"排序顺序(字典序、数值序等)"| A2["TreeSet<br/>✅ 自动排序<br/>⚠️ O(log n) 增删查<br/>❌ 不允许 null"]
    Q2 -->|"插入顺序"| Q3{"是否需要访问顺序<br/>(LRU)功能?"}
    Q3 -->|"否(仅需插入顺序)"| A3["LinkedHashSet<br/>✅ O(1) 增删查<br/>✅ 插入顺序迭代<br/>⚠️ 内存开销略高"]
    Q3 -->|"是(需要LRU)"| Q4{"能否接受使用 Map?"}
    Q4 -->|"能"| A4["LinkedHashMap<br/>accessOrder=true<br/>✅ 完整 LRU 支持<br/>✅ 可子类化定制淘汰策略"]
    Q4 -->|"必须使用 Set"| A5["Collections.newSetFromMap<br/>+LinkedHashMap(accessOrder=true)<br/>或反射创建"]

    style A3 fill:#2c3e50,stroke:#1a252f,color:#ecf0f1
    style A1 fill:#7f8c8d,stroke:#1a252f,color:#ecf0f1
    style A2 fill:#7f8c8d,stroke:#1a252f,color:#ecf0f1
    style A4 fill:#7f8c8d,stroke:#1a252f,color:#ecf0f1
    style A5 fill:#7f8c8d,stroke:#1a252f,color:#ecf0f1

图注说明:扩展版决策树覆盖了访问顺序模式和必须使用 Set 接口的边界场景。

  • 第一层——基础维度判断:是否需要可预测的迭代顺序是选型的首要分水岭。不需顺序则直接选 HashSet(最优性能);需要排序则选 TreeSet(但要接受 O(log n) 性能代价和 null 不支持的限制)。

  • 第二层——顺序类型判断:这是模块 1 决策树的深化。需要插入顺序的场景进入第三层判断;需要排序的场景直接导向 TreeSet。

  • 第三层——LRU 需求的边界处理

    • 无需 LRU:LinkedHashSet 是标准答案。
    • 需要 LRU
      • 路径 A直接用 LinkedHashMap(推荐,LinkedHashMap 本身就是为 LRU 设计的,提供了 removeEldestEntry 等完整支持)。
      • 路径 B:若必须使用 Set 接口类型,可通过 Collections.newSetFromMap(new LinkedHashMap<>(16, 0.75f, true)) 将一个配置了 accessOrder 的 LinkedHashMap 包装为 Set 视图
      • 路径 C:反射调用 HashSet 的包级私有构造器创建底层的 LinkedHashMap(accessOrder=true) 为未公开行为,生产环境慎用。
  • 第四层——与各条路径的关键结论映射

    • HashSet:无顺序开销,纯粹的哈希表去重工具,适合海量数据唯一性校验、黑名单/白名单判重等场景。
    • LinkedHashSet:插入顺序 + O(1) 性能,适合配置去重但需保持声明顺序、日志溯源等场景。
    • TreeSet:排序去重二合一,适合排行榜、范围查询、有序字典等场景,但需注意元素必须实现 Comparable 或提供 Comparator,否则在运行时抛出 ClassCastException。

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

陷阱 1:误认为 LinkedHashSet 会排序

错误认知LinkedHashSet 的“有序”是指元素自动按自然顺序排列

真相:LinkedHashSet 的“有序”是指元素的迭代顺序与插入顺序一致,而非排序。若需要排序,应使用 TreeSet。

// ❌ 错误:期望排序结果
LinkedHashSet<Integer> set = new LinkedHashSet<>();
set.add(3);
set.add(1);
set.add(2);
// 期望输出 [1, 2, 3]?错!
System.out.println(set);  // 实际输出: [3, 1, 2] ← 按插入顺序

// ✅ 正确:需要排序用 TreeSet
TreeSet<Integer> sortedSet = new TreeSet<>();
sortedSet.add(3);
sortedSet.add(1);
sortedSet.add(2);
System.out.println(sortedSet);  // [1, 2, 3] ← 自然排序

陷阱 2:修改已存入元素的字段导致 hashCode 变化

这是所有基于哈希的集合类共有的陷阱,也是实际开发中定位难度极高的 Bug 之源。

// ❌ 错误:存入可变对象后修改参与 hashCode 的字段
class Person {
    String name;
    int age;
    public Person(String name, int age) { this.name = name; this.age = age; }
    @Override public int hashCode() { return Objects.hash(name, age); }
    @Override public boolean equals(Object o) { /* ... */ }
}

LinkedHashSet<Person> set = new LinkedHashSet<>();
Person p = new Person("Alice", 25);
set.add(p);           // hashCode 基于 name="Alice", age=25
p.age = 30;           // ⚠️ 修改字段,hashCode 变化!
set.contains(p);      // ❌ false!元素已“丢失”在错误的桶里
set.remove(p);        // ❌ false!无法删除

// ✅ 正确:使用不可变对象,或用不可变字段参与 hashCode/equals
// 推荐方案1:设计不可变类(final字段,无setter)
// 推荐方案2:若必须可变,存入集合后不要修改参与hashCode的字段
// 推荐方案3:仅用不可变字段(如UUID)参与hashCode/equals

原理深层解释:HashMap 在插入元素时根据当时的 hashCode() 计算桶位置并存放节点。元素被修改后,hashCode() 返回值变化,但节点仍停留在旧桶位置。后续的 containsremove 操作根据新 hashCode() 计算桶位置,查找到的是另一个桶,自然找不到该元素。hashCode() 依赖的字段一旦在对象存入哈希集合后被修改,就会造成对象在集合中“隐式丢失”——既无法查到也无法删除,直到垃圾回收前一直占用内存。

陷阱 3:多线程并发修改导致 fail-fast

// ❌ 错误:多线程下直接使用 LinkedHashSet
LinkedHashSet<Integer> set = new LinkedHashSet<>();
// 线程 A:遍历
new Thread(() -> {
    for (Integer i : set) {  // 迭代器创建时记录 modCount
        // ...
    }
}).start();
// 线程 B:修改
new Thread(() -> {
    set.add(100);  // modCount++,触发 ConcurrentModificationException
}).start();

// ✅ 正确方案一:使用 Collections.synchronizedSet 包装
Set<Integer> syncSet = Collections.synchronizedSet(new LinkedHashSet<>());

// ✅ 正确方案二:使用 ConcurrentHashMap.newKeySet()(但无插入顺序保证)
Set<Integer> concurrentSet = ConcurrentHashMap.newKeySet();

// ✅ 正确方案三:迭代时使用 synchronized 块
synchronized (set) {
    for (Integer i : set) {
        // 整个迭代过程在同步块内执行
    }
}

陷阱 4:启用访问顺序后迭代顺序反映访问频率

// ⚠️ 注意:accessOrder=true 时,迭代顺序会随访问而变化
LinkedHashMap<String, Integer> map = new LinkedHashMap<>(16, 0.75f, true);
map.put("A", 1);
map.put("B", 2);
map.put("C", 3);
System.out.println("初始: " + map.keySet());            // [A, B, C]

map.get("A");                                            // 访问 A
System.out.println("访问 A 后: " + map.keySet());        // [B, C, A]

map.put("B", 20);                                        // 更新 B(也是访问)
System.out.println("更新 B 后: " + map.keySet());        // [C, A, B]

// 结论:在 accessOrder=true 模式下,迭代顺序不再是插入顺序
// 而是反映“最近访问”程度,越晚访问的元素越靠后

最佳实践总结

  1. 存入集合的元素优先使用不可变类(如 String、Integer、LocalDate 等),或确保存入后不修改参与 hashCode/equals 的字段。
  2. 多线程环境下使用 Collections.synchronizedSet 包装,并在迭代时使用 synchronized 代码块。
  3. 需要 LRU 功能时优先考虑 LinkedHashMap,LinkedHashSet 不是为 LRU 设计的。
  4. 大容量场景注意设置合适的初始容量,减少扩容带来的性能开销。
  5. 不要依赖 LinkedHashSet 的 equals 方法比较顺序——Set 接口规范明确声明 equals 不比较元素顺序。

Part 6:总结与面试篇

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

11.1 时间复杂度总览

操作平均时间复杂度最坏时间复杂度说明
add(E e)O(1)O(n)最坏情况:哈希冲突严重导致桶内链表/红黑树遍历
remove(Object o)O(1)O(n)同上
contains(Object o)O(1)O(n)同上
iterator()O(1)O(1)仅获取迭代器,返回 LinkedKeyIterator 实例
iterationO(n)O(n)沿双向链表遍历,不受哈希表容量影响
size()O(1)O(1)直接返回继承的 size 字段

11.2 内存开销分析(基于 64 位 JVM,压缩 OOP)

数据结构每个元素的内存开销(估算)说明
HashSet~32 字节(HashMap.Node)hash + key + value + next 引用
LinkedHashSet~48 字节(LinkedHashMap.Entry)额外多 before + after 两个引用(各 8 字节)
TreeSet~40 字节(TreeMap.Entry)left + right + parent + color + key + value

当集合包含 100 万个元素时,LinkedHashSet 比 HashSet 额外消耗约 16MB 内存(仅节点对象层面,不含哈希表桶空间等)。 在实际应用中,需要在顺序需求和内存开销之间做出权衡——对内存敏感的大规模数据处理场景,若顺序非必须,优先使用 HashSet;对中等规模(百万级以下)且需要顺序保证的场景,LinkedHashSet 的额外开销通常是可以接受的。

11.3 场景选择速查

  • 🏃 【性能优先,不关心顺序】 → HashSet
  • 📋 【需要保留插入/添加顺序】 → LinkedHashSet
  • 🔢 【需要自然排序/范围查找】 → TreeSet
  • 🧵 【多线程高并发去重】 → ConcurrentHashMap.newKeySet()
  • 🗄️ 【LRU 缓存淘汰】 → LinkedHashMap (accessOrder=true) + removeEldestEntry
  • 📝 【统计首次出现顺序】 → LinkedHashSet

模块 12:面试高频专题

考点 1:LinkedHashSet 和 HashSet 的区别?迭代顺序如何保证?

标准回答

LinkedHashSet 是 HashSet 的子类,唯一区别在于 LinkedHashSet 内部使用 LinkedHashMap 存储元素,而 HashSet 使用 HashMap。LinkedHashMap 在 HashMap 的哈希表基础上维护了一条双向链表,所有 Entry 节点通过 before/after 指针串联。迭代时 LinkedKeyIterator 直接从链表头节点 head 出发沿 after 指针遍历,因此迭代顺序严格等于元素的首次插入顺序。HashSet 的 KeyIterator 遍历的是哈希表数组,顺序由哈希分布决定,不可预测。

追问模拟

  • 追问 1:重复 put 已存在的元素会改变它在链表中的位置吗? 答:在插入顺序模式(默认)下,不会。HashMap.putVal 发现 Key 已存在时,只更新 Value,调用 afterNodeAccess 钩子。而 LinkedHashMap.afterNodeAccess 仅在 accessOrder=true 时执行节点移动,默认 accessOrder=false 时为空操作。

  • 追问 2:如果元素被 remove 后再重新 add,它在迭代顺序中处于什么位置? 答:remove 将元素从双向链表中摘除(afterNodeRemoval 钩子),重新 add 会创建新节点并追加到链表尾部(linkNodeLast),因此该元素会从原来的位置移动到链表末尾,变为最后迭代到的元素。

  • 追问 3:HashMap 的迭代复杂度是 O(capacity+n),LinkedHashMap 呢? 答:LinkedHashMap 通过双向链表直接遍历有效元素,复杂度为 O(n),与哈希表容量无关。在容量远大于元素数量的稀疏哈希表场景中,LinkedHashMap 的迭代性能实际优于 HashMap——后者需要扫描大量空桶,而前者通过 tail 和 after 指针直接定位有效节点。

加分回答

可以从模板方法设计模式角度分析:HashMap 在 putVal 中预留了 newNode()、afterNodeAccess()、afterNodeInsertion()、afterNodeRemoval() 四个钩子方法(自身为空实现),LinkedHashMap 通过重写这些方法在不修改 HashMap 源码的前提下实现了双向链表维护。这是典型的开闭原则——对扩展开放、对修改关闭。

考点 2:LinkedHashSet 的底层实现原理?和 LinkedHashMap 的关系是什么?

标准回答

LinkedHashSet 继承自 HashSet,但它通过调用 HashSet 的包级私有构造器 HashSet(int initialCapacity, float loadFactor, boolean dummy) 将内部 map 字段初始化为 LinkedHashMap 实例(而非 HashMap)。LinkedHashSet 的所有操作(add、remove、contains、iterator)都直接继承自 HashSet,而 HashSet 的这些方法内部委托给 map 的对应方法。由于 map 的实际类型是 LinkedHashMap,因此所有操作自动获得了双向链表的顺序维护能力。LinkedHashSet 和 LinkedHashMap 的关系是适配器/装饰器模式——LinkedHashSet 将 LinkedHashMap 适配为 Set 接口,元素作为 Map 的 Key,共享同一个哑元 Value 对象 PRESENT。

追问模拟

  • 追问 1:LinkedHashSet 有没有自己重写的 add 方法? 答:没有。完全继承自 HashSet 的 add 方法,即 map.put(e, PRESENT) == null。创建新节点、维护链表的逻辑完全在 LinkedHashMap 内部完成。LinkedHashSet 的类体极其简洁(仅约 50 行源码)。

  • 追问 2:LinkedHashSet 为什么能复用 HashSet 的所有方法而不需要任何修改? 答:多态性。HashSet 内部通过 map 引用调用方法,编译时类型是 HashMap,运行时类型取决于构造器创建的实例类型。LinkedHashSet 通过构造器钩子将运行时类型替换为 LinkedHashMap,方法调用自动分派到子类实现。

  • 追问 3:PRESENT 为什么设计为一个静态常量,而不是每次 new Object()? 答:两个原因:一是节省内存(所有 key 共享一个 value 对象,避免为每个元素创建一个 Object);二是 PRESENT 作为引用相等(==)判断的锚点——HashSet.remove 判断 map.remove(o) == PRESENT,利用引用相等比 equals 更高效。

加分回答

可以引申到 JDK 中类似的适配器模式应用——TreeSet 和 TreeMap 的关系与之完全对称。整个 Java 集合框架中,Set 接口的三个主要实现(HashSet、LinkedHashSet、TreeSet)分别对应 Map 接口的三个实现(HashMap、LinkedHashMap、TreeMap),体现了“Set 是 Map 的 Key 视图”这一统一设计思想。

考点 3:HashSet 的构造器中的 dummy 参数是做什么的?

标准回答

dummy 是 HashSet 包级私有构造器 HashSet(int initialCapacity, float loadFactor, boolean dummy) 的第三个参数。它在构造器方法体内完全不被使用,唯一作用是与另一个公开构造器 HashSet(int initialCapacity, float loadFactor) 做签名区分,因为 Java 不允许仅靠返回值区分重载方法。被调用的构造器内部创建的是 new LinkedHashMap<>(initialCapacity, loadFactor) 而非 new HashMap<>(),从而为 LinkedHashSet 提供了底层存储替换的钩子。

追问模拟

  • 追问 1:为什么不设计一个 protected 的、返回 LinkedHashMap 的工厂方法? 答:其一,构造器方案更简洁——LinkedHashSet 只需调用 super() 即可完成底层存储初始化,无需再覆盖工厂方法。其二,包级私有的访问控制确保了只有 java.util 包内的子类(即 LinkedHashSet)能使用此机制,避免了外部随意替换带来的安全风险。

  • 追问 2:如果我自己写一个类继承 HashSet,能调用这个构造器吗? 答:如果自定义类不在 java.util 包内,则无法调用此包级私有构造器。编译器会报错 HashSet(int, float, boolean) is not public in HashSet

加分回答

可以提及类似的设计模式——标记参数(Flag/Tag Argument) 是一种 API 设计中的反模式,但此处 dummy 的使用属于构造器重载区分这一特殊场景的合理例外。同时指出 LinkedHashMap 自身也有类似设计:其构造器 LinkedHashMap(int, float, boolean accessOrder) 中的 accessOrder 参数是功能性参数而非纯标记参数,这体现了两个类在 API 设计上的差异。

考点 4:LinkedHashSet 如何实现 LRU 缓存?accessOrder 是什么?

标准回答

LinkedHashSet 的公开 API 不支持直接创建访问顺序模式的实例,但底层 LinkedHashMap 支持。要实现 LRU 缓存,推荐直接使用 LinkedHashMap,通过构造器传入 accessOrder=true 启用访问顺序模式,并重写 removeEldestEntry 方法定义淘汰条件。当 accessOrder=true 时,每次 get 或 put(更新场景)操作都会通过 afterNodeAccess 钩子将访问的节点移动到双向链表尾部,链表头部即为最久未访问的元素。removeEldestEntryafterNodeInsertion 在每次新元素插入后调用,返回 true 时自动移除头节点。

// LRU 缓存示例
class LRUCache<K,V> extends LinkedHashMap<K,V> {
    private int capacity;
    public LRUCache(int capacity) {
        super(16, 0.75f, true);  // accessOrder=true
        this.capacity = capacity;
    }
    @Override
    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return size() > capacity;
    }
}

追问模拟

  • 追问 1:LinkedHashSet 本身能否直接实现 LRU? 答:不能。LinkedHashSet 的构造器都调用 HashSet 包级私有构造器,该构造器未传入 accessOrder 参数,默认为 false。若确实需要,可以通过反射调用包级私有构造器或使用 Collections.newSetFromMap 包装访问顺序模式的 LinkedHashMap。

  • 追问 2:afterNodeAccess 在 accessOrder=false 时会做什么? 答:什么都不做。方法首行判断 if (accessOrder && (last = tail) != e),accessOrder 为 false 直接返回,避免无谓的链表操作。

加分回答

可以扩展讨论 LRU 的局限性——LRU 在突发流量下可能误淘汰热点数据(如某个热点数据短期未被命中,但即将被大量请求)。实际生产环境中,常见的改进方案包括:

  • LRU-K:记录最近 K 次访问的时间,而非仅记录最近一次。
  • W-TinyLFU:结合频率计数和 LRU 的混合策略(Caffeine Cache 的默认算法),解决 LRU 在扫描式访问下的“缓存污染”问题。
  • 两级缓存架构(L1 + L2):L1 用 LRU 淘汰,L2 用频率统计,兼顾内存效率和命中率。

考点 5:LinkedHashSet 和 TreeSet 的区别?分别适用于什么场景?

标准回答

维度LinkedHashSetTreeSet
底层实现LinkedHashMap(哈希表 + 双向链表)TreeMap(红黑树)
顺序保证插入顺序(或访问顺序)自然排序或自定义 Comparator 排序
时间复杂度O(1)(增删查)O(log n)(增删查)
null 元素允许不允许(需调用 compareTo 或 Comparator.compare)
额外接口SortedSet、NavigableSet(支持范围查询)
内存开销中等(额外两个引用)较高(红黑树节点含 parent/left/right/color)
适用场景去重 + 保留原始顺序排序去重、范围查找、排行榜

LinkedHashSet 适用于需要保留插入顺序的场景(如配置加载、日志溯源),TreeSet 适用于需要元素自动排序和范围查找的场景(如排行榜、数值范围查询、按字典序输出)。

追问模拟

  • 追问 1:TreeSet 的元素必须实现 Comparable 吗? 答:不一定。可以在构造 TreeSet 时传入 Comparator 实现。若两者都未提供,运行时会在第一次 add 时抛出 ClassCastException——因为 TreeMap.put 内部需要调用 compareToComparator.compare 来确定元素在红黑树中的位置。

  • 追问 2:LinkedHashSet 和 TreeSet 哪个迭代更快? 答:LinkedHashSet 迭代复杂度为 O(n),TreeSet 为 O(n),理论上两者同一量级。但 LinkedHashSet 的迭代沿双向链表的 after 指针顺序前进,TreeSet 需要通过红黑树中序遍历(递归或栈模拟),实际测试中 LinkedHashSet 的迭代通常比 TreeSet 快约 20-40%(取决于元素数量和 JVM 优化),因为链表指针跳转比树的递归/栈操作有更好的缓存局部性。

加分回答

可以延伸讨论二者在序列化行为上的差异:LinkedHashSet 的序列化会保留双向链表顺序(反序列化后顺序不变),TreeSet 的序列化保留元素的排序顺序(反序列化后自动重新构建红黑树,元素按排序顺序放置)。此外,LinkedHashMap 迭代不受 capacity 影响,而 TreeSet 的迭代性能与树结构深度相关。

考点 6:存入 LinkedHashSet 的元素需要重写 hashCode 和 equals 吗?

标准回答

是的,且两者必须同时重写且保持一致性。LinkedHashSet 的去重机制依赖底层 HashMap 的 hashCode()equals() 方法。hashCode() 决定元素存储在哪个哈希桶,equals() 判断两个哈希值相同的元素是否为同一对象。若只重写 equals() 不重写 hashCode(),则相等的两个对象可能落入不同哈希桶,无法去重。若只重写 hashCode() 不重写 equals(),则哈希冲突时无法准确判断相等性。

追问模拟

  • 追问 1:String 和 Integer 需要重写吗? 答:不需要。Java 标准库中的不可变类(String、Integer、LocalDate 等)都已正确重写了 hashCode()equals(),可以直接安全地存入 LinkedHashSet。

  • 追问 2:为什么 Object 的 hashCode 和 equals 不够用? 答:Object.hashCode 默认返回对象的内存地址(或 JVM 内部标识),Object.equals 默认比较引用相等(==)。对于值对象(如 Person、Order),我们期望具有相同字段值的两个对象被视为“相等”,因此必须重写。

加分回答

可以提及 hashCode()equals()契约(Contract)

  1. a.equals(b) 为 true,则 a.hashCode() 必须等于 b.hashCode()一致性要求)。
  2. a.hashCode() 不等于 b.hashCode(),则 a.equals(b) 必须为 false。
  3. a.hashCode() 等于 b.hashCode()a.equals(b) 不一定为 true(哈希冲突允许存在)。

违反此契约会导致基于哈希的集合(HashSet、LinkedHashSet、HashMap)出现不可预测的行为——元素可能“丢失”、去重失效或查找失败。建议使用 java.util.Objects.hash() 工具方法或 IDE 自动生成来避免实现错误。

考点 7:LinkedHashSet 的迭代器是 fail-fast 吗?为什么?

标准回答

是的。LinkedHashSet 的迭代器继承自 LinkedHashMap 的 LinkedKeyIterator,间接继承自 HashMap 的 HashIterator。HashIterator 在构造时记录当前集合的 modCount(修改计数器),每次 next() 调用时检查 modCount 是否与 expectedModCount 一致。若不一致(说明迭代过程中集合被外部修改),立即抛出 ConcurrentModificationException。这是 Java 集合框架的通用 fail-fast 设计,目的是尽早暴露并发修改问题,避免数据不一致导致的难以调试的错误。

追问模拟

  • 追问 1:fail-fast 是线程安全的保证吗? 答:不是。fail-fast 只是一种尽力而为的错误检测机制,不保证一定抛出异常。它不能替代真正的并发同步方案。

  • 追问 2:如何在迭代中安全地删除元素? 答:使用迭代器自身的 remove() 方法。HashIterator.remove() 会在删除后同步更新 expectedModCount = modCount,避免下次 next() 抛出异常。直接调用集合的 remove 方法则会触发 modCount 自增却不同步迭代器内的 expectedModCount,导致抛异常。

  • 追问 3:ConcurrentHashMap 的迭代器是 fail-fast 吗? 答:不是。ConcurrentHashMap 的迭代器是弱一致性(weakly consistent) 的,允许在迭代过程中出现并发修改,不会抛出 ConcurrentModificationException,但可能不反映最新的修改。这是两种不同的并发策略:fail-fast 尽早暴露问题,弱一致性容忍并发但结果可能不实时。

加分回答

可以提到 modCount 的本质是乐观锁版本号的简化版。HashMap 中任何结构性修改(put、remove、clear 等)都会使 modCount 自增,迭代器通过比较版本号检测并发修改。modCount 可能溢出(int 溢出回绕),但由于 expectedModCount 同步更新,不会导致错误的检测,这是一个微妙但正确的边界设计。

考点 8:LinkedHashSet 允许 null 元素吗?允许多个 null 吗?

标准回答

LinkedHashSet 允许一个 null 元素,但不允许多个 null。这是 Set 接口的通用约定——不允许重复元素,null 也不例外。底层实现中,HashMap 的 put 方法对 null key 做了特殊处理(hash 值固定为 0,存储在 table[0] 桶中),因此支持存储一个 null。第二次 add(null) 时,putVal 发现桶位中已存在 null key(通过 key == null 判断相等),返回旧值,LinkedHashSet 返回 false 表示重复。

补充说明:TreeSet 不允许 null 元素(除非构造时提供可处理 null 的 Comparator),因为 null 无法调用 compareTo 方法进行排序比较。这是三个 Set 实现类在处理 null 方面的重要差异。

追问模拟

  • 追问 1:HashMap 的 null key 是如何存储的? 答:HashMap 对 null key 做了特殊处理,将其 hash 值固定为 0,存储在 table[0] 索引的桶中。查找时也是先判断 key 是否为 null,若是则直接从 table[0] 桶中查找。

  • 追问 2:ConcurrentHashMap 允许 null key 吗? 答:不允许。ConcurrentHashMap 的设计者认为 null 在并发环境下容易导致歧义——get(key) 返回 null 无法区分是 key 不存在还是 key 对应的 value 本身就是 null。因此直接禁止 null key 和 null value,传入 null 会抛出 NullPointerException。

加分回答

可补充 Optional 的使用建议:在业务逻辑中,使用 Optional 作为 value(而非 null)可以更清晰地表达“值可能不存在”的语义,但需注意 Optional 不应作为 Map 的 key。

考点 9:如何让 LinkedHashSet 按访问顺序输出元素?

标准回答

标准 LinkedHashSet API 不支持直接创建访问顺序模式的实例。要实现此功能,有以下三种途径:

  1. 使用 Collections.newSetFromMap 包装访问顺序模式的 LinkedHashMap(推荐)
Set<String> set = Collections.newSetFromMap(
    new LinkedHashMap<String, Boolean>(16, 0.75f, true));
  1. 直接使用 LinkedHashMap 的 keySet 视图
LinkedHashMap<String, Object> map = new LinkedHashMap<>(16, 0.75f, true);
Set<String> set = map.keySet();

此方案的唯一限制是 keySet() 返回的是视图 Set,不支持 add 操作(会抛出 UnsupportedOperationException),若仅需迭代访问顺序的结果则完全可行。

  1. 反射调用 HashSet 的包级私有构造器(不推荐,非标准 API)

追问模拟

  • 追问 1:为什么 JDK 不直接提供一个 LinkedHashSet(int, float, boolean) 构造器? 答:设计权衡。LinkedHashSet 的定位是插入顺序的 Set 实现,访问顺序属于 LinkedHashMap 的高级特性。如果为 LinkedHashSet 添加 accessOrder 构造器,需要额外的构造器重载(原本已有四个构造器),且 Set 接口的访问语义不如 Map 直观。JDK 倾向于保持每个类的职责单一。

加分回答

可以提到 JDK 21 中引入了 SequencedCollection 接口,LinkedHashSet 实现了该接口,提供了 addFirstaddLastreversed() 等顺序操作方法。这些新接口进一步丰富了 LinkedHashSet 的顺序操作能力。

考点 10:解释 LinkedHashSet 中双向链表的作用和节点结构。

标准回答

双向链表的作用是在哈希表提供 O(1) 快速查找的同时,记录元素的插入顺序(或访问顺序),保证可预测的迭代顺序。节点类型是 LinkedHashMap.Entry,继承自 HashMap.Node,新增了 beforeafter 两个引用。next(继承自 Node)用于解决哈希冲突(同桶内链表),before/after 用于维护贯穿全部元素的插入顺序双向链表。这两种不同用途的链表在同一个节点上正交共存——next 链解决桶内冲突,before/after 链维护全局顺序。

追问模拟

  • 追问 1:节点同时参与两条链表,删除操作如何保证一致性? 答:删除操作分两步处理。第一步:HashMap.removeNode 处理哈希桶链表摘除(修改 next 指针),第二步:afterNodeRemoval 钩子处理双向链表摘除(修改 before/after 指针)。两者互不影响,保证了结构一致性。两条链表各自独立,操作互不干扰。

  • 追问 2:LinkedHashMap 的 Entry 为什么不直接继承 HashMap.Node 并使用自身字段? 答:这正是 JDK 的巧妙设计——通过继承复用 Node 的 hash/key/value/next 字段,避免重复定义。HashMap.Node 提供了基础的哈希桶节点能力,LinkedHashMap.Entry 在此基础上扩展双向链表能力,形成了递进式的节点类层次结构。红黑树节点 TreeNode 也继承自 LinkedHashMap.Entry,在树化时自动具备双向链表能力,可平滑降级为普通链表节点。

加分回答

可以对比 LinkedHashMap 与 C++ STL 中 std::unordered_map 的设计差异。C++ STL 的 unordered_map 基于开链法哈希表,不保证任何迭代顺序;而 LinkedHashMap 用双向链表解决了这个痛点。LinkedHashMap 的双向链表还带来了一个额外优势:迭代时间仅与元素数量成正比,与内部哈希表大小无关,非常适合需要对大型集合进行频繁迭代的场景。此外,LinkedHashMap 的双向链表默认不是循环链表——head.before 和 tail.after 均为 null,这与早期的 JDK 版本设计不同,JDK 7 之前曾使用循环链表(header 哑元节点),JDK 8 改为非循环设计以简化逻辑、减少边界判断。

结语

LinkedHashSet 是 Java 集合框架中“以小博大”的典范——仅约 50 行新增源码坐落在整个继承体系上,却通过三件法宝实现了顺序保证的完整语义:HashSet 的构造器钩子(类型替换)、LinkedHashMap 的双向链表(顺序维护)和 HashMap 的模板方法钩子(行为注入)。其本质可概括为:

LinkedHashSet = HashSet 的接口与去重语义 + LinkedHashMap 的存储与顺序能力

它的设计揭示了框架设计中一条核心原则——预留扩展点比预留功能更重要。HashSet 预留了构造器钩子和模板方法钩子,但并未定义“顺序”功能;LinkedHashSet 和 LinkedHashMap 仅填充了相关钩子,便获得了顺序能力。这种“框架定骨架,子类填血肉”的设计哲学,远比在 HashSet 中硬编码顺序逻辑更为灵活和可维护,也是理解 Spring、MyBatis 等主流框架扩展机制的通用思维模型。

参考源码版本:JDK 8(OpenJDK),涉及核心类 java.util.LinkedHashSetjava.util.HashSetjava.util.LinkedHashMapjava.util.HashMap