集合-Set-HashSet

4 阅读26分钟

概述

HashSet 是 Java 集合框架中最常用的 Set 实现,它以“无序、不可重复”的语义为开发者提供了极致简洁的元素去重能力。然而,揭开这层表象,你会发现 HashSet 并非一个独立的数据结构,而是一个完全委托给 HashMap 的轻量级封装——它只是巧妙借用了 HashMap 键的唯一性来保证集合元素的唯一性。本文将带领你从 JDK 8 源码出发,深入剖析 HashSet 的委托机制、哈希冲突解决、序列化黑魔法以及 fail‑fast 行为,并通过大量可直接运行的 Demo 和 Mermaid 流程图,呈现一条从底层原理到最佳实践的完整认知链路。

核心知识点一览

  • 基于 HashMap 的委托实现:HashSet 内部组合一个 HashMap,元素作为 Key,所有 Value 共享同一个静态哑元对象 PRESENT,从而复用 HashMap 的哈希能力与去重逻辑。
  • add 方法的去重语义add(E e) 直接调用 map.put(e, PRESENT) == null,利用 HashMap 对相同 Key 的更新行为判断元素是否为新插入,返回值精确反映是否添加成功。
  • 散列表的访问性能:增删查平均时间复杂度为 O(1),但高度依赖 hashCode() 的离散度和负载因子,极端哈希冲突下链表退化为红黑树前查询可能恶化到 O(n)。
  • equals 与 hashCode 的契约绑定:自定义对象必须同时正确覆写 equals()hashCode(),否则相同业务语义的对象会产生不同哈希值或被误判为不相等的 Key,导致去重失效。
  • 无序性与非线程安全:遍历顺序由底层 HashMap 的桶位置和链表顺序决定,完全无保证;多线程并发修改可能造成数据覆盖丢失或触发 ConcurrentModificationException

全文组织架构图

flowchart TB
    subgraph Part1["Part 1: 基础认知篇"]
        A1["定义 核心特性与适用场景"]
        A2["接口与继承体系"]
    end
    subgraph Part2["Part 2: 存储与构造篇"]
        B1["存储结构与底层依赖"]
        B2["构造方法剖析"]
    end
    subgraph Part3["Part 3: 核心原理篇"]
        C1["add 操作与去重核心"]
        C2["remove 与 contains 操作"]
        C3["其他重要方法"]
    end
    subgraph Part4["Part 4: 迭代与序列化篇"]
        D1["迭代器与 fail-fast 机制"]
        D2["序列化与克隆机制"]
    end
    subgraph Part5["Part 5: 对比与陷阱篇"]
        E1["Set 三大实现类对比"]
        E2["常见陷阱与最佳实践"]
    end
    subgraph Part6["Part 6: 总结与面试篇"]
        F1["注意事项与性能总结"]
        F2["面试高频专题"]
    end

    Part1 --> Part2 --> Part3 --> Part4 --> Part5 --> Part6
    A1 --> A2 --> B1 --> B2 --> C1 --> C2 --> C3 --> D1 --> D2 --> E1 --> E2 --> F1 --> F2

架构图说明
一句话概括:全文按“认知→存储→原理→迭代/序列化→对比陷阱→总结面试”六大递进模块组织,形成从基础到深挖、从源码到实战的闭环认知路径。
逐层分解

  • 第一层(Part 1·基础认知篇):先明确 HashSet 是什么、有何特性、何时使用,再梳理其继承体系,建立全局观。
  • 第二层(Part 2·存储与构造篇):深入字段与构造器源码,揭示其完全依赖 HashMap 的委托本质和哑元对象设计。
  • 第三层(Part 3·核心原理篇):围绕 add / remove / contains 三个核心操作的完整调用链路,详解哈希扰动、桶定位、链表/红黑树判断等细节。
  • 第四层(Part 4·迭代与序列化篇):解释迭代器的 fail‑fast 保护机制以及 HashSet 如何通过定制序列化协议安全处理底层 HashMap。
  • 第五层(Part 5·对比与陷阱篇):通过选型决策树帮助读者在 HashSet / TreeSet / LinkedHashSet 中做出正确选择,并汇总 4 大典型陷阱及正确示例。
  • 第六层(Part 6·总结与面试篇):提炼性能调优要点,将全部面试考点集中整理,提供高分回答模板。
    HashSet 是 HashMap 的 Key 视图封装,其全部行为均通过转发实现,掌握 HashMap 就等于掌握了 HashSet。

Part 1:基础认知篇

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

定义
HashSet 是基于哈希表(实际为 HashMap)实现的 Set 接口,它不保证元素的迭代顺序不允许包含重复元素,并且允许包含最多一个 null 元素。该类非线程安全,若多线程并发访问,必须通过外部同步或 Collections.synchronizedSet 包装。

核心特性列表

  • 去重机制:利用 equals()hashCode() 双重判定,逻辑相等的对象只能存储一次。
  • 常数时间性能:在良好哈希分布下,addremovecontains 均提供 O(1) 平均时间。
  • 允许 null:由于 HashMap 支持一个 null 键,HashSet 也允许存入一个 null 元素。
  • 非线程安全:未使用任何同步手段,迭代器的 fail‑fast 只能尽力检测并发修改。
  • 无序性:由 HashMap 的哈希桶排列决定,不可预测。
  • 基于 HashMap:本质是 HashMap 的一个视图层,所有操作直接转发。

适用场景与反例

  • 适用:需要快速去重、不关心顺序的场景,例如用户标签集合、URL 黑名单、每日访问 IP 统计等。
  • 不适用:需要排序的场景(应改用 TreeSet);需要维持插入顺序的场景(应改用 LinkedHashSet);对内存极度敏感、又需极小开销判断存在性(可考虑布隆过滤器);需要线程安全且高性能的并发集合(应改用 ConcurrentHashMap.newKeySet()CopyOnWriteArraySet)。

底层本质一句话
HashSet 是 HashMap 的 Key 集合视图,所有 Value 被一个不可变哑元对象 PRESENT 统一填充。

特性与适用场景决策树

flowchart TD
    Start((需要存放不重复元素?)) -->|是| Order{需要排序?}
    Start -->|否| Other[考虑 List 或其他结构]
    Order -->|自然/比较排序| TreeSet[TreeSet]
    Order -->|插入顺序| LinkedHashSet[LinkedHashSet]
    Order -->|不关心| Nullable{允许 null?}
    Nullable -->|是| HashSet[HashSet]
    Nullable -->|否| NoNull[仍可用 HashSet, 但不存 null]
    HashSet --> ThreadSafe{多线程环境?}
    ThreadSafe -->|是| Wrap[使用 Collections.synchronizedSet 或 ConcurrentHashMap.newKeySet]
    ThreadSafe -->|否| Use[直接使用 HashSet]

决策树说明
一句话概括:该决策树帮助开发者从“是否需要去重”开始,逐步根据排序需求、null 容忍度、线程安全性选择正确的 Set 实现或包装器。
逐层分解

  • 第一层:确认是否需要元素唯一性,若否,应选用 List 等其它集合。
  • 第二层:在 Set 体系内,根据是否需要排序分流:需要大小排序则选择 TreeSet,需要维持插入顺序则选择 LinkedHashSet,其余默认走 HashSet。
  • 第三层:针对 HashSet 进一步判断是否允许 null,虽然 HashSet 允许 null,但若业务不允许,调用时须做前置校验。
  • 第四层:判断运行环境是否多线程,若是,必须外挂同步包装或替换为并发安全版本。
    数据结构映射:决策树中每个叶子节点对应一个具体集合类,箭头上的条件对应需求特性。
    源码方法对应HashSet 的无序性由 HashMap 的数组+链表/红黑树结构实现;TreeSet 源于 TreeMap 的红黑树排序;LinkedHashSet 源于 LinkedHashMap 的双向链表维护插入顺序。
    关键结论强调当你不关心顺序且无需线程安全时,HashSet 是去重需求的最佳默认选择。

模块 2:接口与继承体系

HashSet 继承自 AbstractSet,实现了 SetCloneableSerializable 接口。
AbstractSet 提供了 equals()hashCode()removeAll() 的模板实现,基于迭代器进行元素逐个比较,因此作为基类可以减少子类代码量。但 HashSet 本身并未重写 equals()hashCode(),直接沿用了 AbstractSet 的实现,这也意味着两个 HashSet 是否相等取决于各自包含的元素是否完全相同。

完整继承链类图

classDiagram
    class Iterable {
        <<interface>>
        +iterator(): Iterator
    }
    class Collection {
        <<interface>>
        +add(e: E): boolean
        +remove(o: Object): boolean
        +contains(o: Object): boolean
        +size(): int
        +isEmpty(): boolean
        +iterator(): Iterator
    }
    class Set {
        <<interface>>
        +add(e: E): boolean
        +remove(o: Object): boolean
        +contains(o: Object): boolean
        +size(): int
        +isEmpty(): boolean
        +iterator(): Iterator
    }
    class AbstractCollection {
        <<abstract>>
        +isEmpty(): boolean
        +contains(o: Object): boolean
        +add(e: E): boolean
        +remove(o: Object): boolean
        +iterator(): Iterator
    }
    class AbstractSet {
        <<abstract>>
        +equals(o: Object): boolean
        +hashCode(): int
        +removeAll(c: Collection): boolean
    }
    class HashSet {
        -map: HashMap<E, Object>
        -PRESENT: Object
        +HashSet()
        +HashSet(c: Collection)
        +HashSet(initialCapacity: int, loadFactor: float)
        +add(e: E): boolean
        +remove(o: Object): boolean
        +contains(o: Object): boolean
        +size(): int
        +isEmpty(): boolean
        +clear(): void
        +iterator(): Iterator
        +clone(): Object
    }
    class HashMap {
        +put(key: K, value: V): V
        +remove(key: Object): V
        +containsKey(key: Object): boolean
        +size(): int
        +keySet(): Set
    }
    class Serializable {
        <<interface>>
    }
    class Cloneable {
        <<interface>>
    }

    Iterable <|-- Collection
    Collection <|-- Set
    Collection <|.. AbstractCollection
    Set <|-- AbstractSet
    AbstractCollection <|-- AbstractSet
    AbstractSet <|-- HashSet
    Set <|.. HashSet
    Serializable <|.. HashSet
    Cloneable <|.. HashSet
    HashSet *-- HashMap : map
    HashSet ..> HashMap : 完全委托

类图说明
一句话概括:HashSet 在继承链上仅仅实现了 Set 接口,但其核心能力来自内部组合HashMap 实例,这是典型的委托模式
逐层分解

  • 继承层HashSetAbstractSetAbstractCollection,实现了 SetCloneableSerializable
  • 组合层HashSet 拥有一个 HashMap 字段 map,所有方法都通过调用 map 的相应方法完成,HashSet 本身不维护任何元素存储结构。
  • 契约层Set 接口定义了不允许重复元素的约定,HashMap 的 Key 唯一性天然满足该契约。
    数据结构映射HashSet 无独立数据结构,其逻辑元素散列表就是 HashMap 的数组 Node<K,V>[] table,元素被当成 Key 放入 Node
    源码方法对应AbstractSet.equals() 利用迭代器逐项比较,HashSet 直接继承,故两个 set 相等判断完全基于所含元素,与底层 HashMap 桶顺序无关。
    关键结论强调HashSet 不是 HashMap 的子类,而是它的客户;通过组合而非继承,HashSet 完美封装了 HashMap 的 Key 唯一性,同时屏蔽了 Value 的复杂性。

Part 2:存储与构造篇

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

HashSet 源码中最核心的两个字段:

private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();
  • map:负责实际存储的 HashMap 实例。所有元素都作为 map 的 Key 存入,Value 统一使用静态常量 PRESENT
  • PRESENT:一个哑元对象,所有条目共享同一个引用。选择 new Object() 而非 null,是因为如果使用 null,HashMap.put 返回 null 时无法区分是“原本没有该 Key”还是“原先 Key 对应的值即为 null”,使用非 null 哑元对象可保证 add 方法能准确通过 put 返回值 == null 判断是否新增成功。

这种设计表明 HashSet 完全是一个视图层,它对 HashMap 进行了极简封装,使得用户只能操作 Key,而无法直接操作 Value。这也意味着所有对 HashSet 的操作,本质上都是在操作底层 HashMap,因此 HashMap 的容量、负载因子、树化阈值等参数直接决定 HashSet 的行为与性能

HashSet 委托结构类图

classDiagram
    class HashSet {
        -map: HashMap
        -PRESENT: Object
        +add(e: E): boolean
        +remove(o: Object): boolean
        +contains(o: Object): boolean
    }
    class HashMap~K,V~ {
        -table: Node[]
        +put(key: K, value: V): V
        +remove(key: Object): V
        +containsKey(key: Object): boolean
    }
    class Node~K,V~ {
        -hash: int
        -key: K
        -value: V
        -next: Node
    }
    HashSet *-- HashMap : "map (组合)"
    HashMap *-- Node : "table 数组"
    HashSet ..> PRESENT : "所有 Value 指向 PRESENT"
    note for HashSet "map.put(e, PRESENT)"

类图说明
一句话概括:该图展示了 HashSet 内部“组合 HashMap + 哑元常量”的存储模型,强调所有元素被映射为 HashMap 的 Key。
逐层分解

  • 第一层(HashSet):提供面向集合的公开 API,如 addremove,内部持有 map 引用。
  • 第二层(HashMap):真实存储容器,底层为 Node 数组 table,每个 Node 包含哈希值、键、值及下一个节点指针。
  • 第三层(Node):所有存入 HashSet 的元素 e 最终被封入 Nodekey = evalue = PRESENT
    数据结构映射:逻辑上的集合元素被映射到 HashMap 的 Key 空间;PRESENT 作为一个单例被所有 Node 共享,节省内存。
    源码方法对应HashSet.add(e)map.put(e, PRESENT)HashSet.contains(o)map.containsKey(o)
    关键结论强调理解 HashSet 的关键就在于理解“它只使用 HashMap 键空间,值空间被一个永恒不变的哑元对象占据”,这种委托模式使得 HashSet 行为完全等价于 HashMap 键集的容器层面语义。

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

HashSet 提供四个构造器,均围绕底层 HashMap 的实例化展开,源码如下:

  1. 无参构造

    public HashSet() {
        map = new HashMap<>();
    }
    

    底层 HashMap 默认初始容量 16,负载因子 0.75

  2. 基于集合的构造

    public HashSet(Collection<? extends E> c) {
        map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
        addAll(c);
    }
    

    根据 c.size() 预先计算一个足够容纳所有元素且避免立即扩容的容量,最小容量仍为 16,然后批量添加。

  3. 指定容量与负载因子

    public HashSet(int initialCapacity, float loadFactor) {
        map = new HashMap<>(initialCapacity, loadFactor);
    }
    

    允许精确控制底层 HashMap 的初始大小和负载因子,便于性能调优。

  4. 包私有构造(供 LinkedHashSet 使用)

    HashSet(int initialCapacity, float loadFactor, boolean dummy) {
        map = new LinkedHashMap<>(initialCapacity, loadFactor);
    }
    

    dummy 形参仅用于区分签名,实际无意义。该构造器创建的是 LinkedHashMap 实例,因此 LinkedHashSet 继承 HashSet 时只需调用此构造器,即可自然获得插入顺序维护能力。

延迟分配时机
HashMap 的 table 数组首次在 put 操作发生时才进行分配(JDK 8 中 resize() 触发),因此 new HashSet() 仅仅初始化了 map 引用,真正的存储空间在第一次 add 时才分配。

Demo 代码:构造与观察容量(反射)

public class HashSetConstructionDemo {
    public static void main(String[] args) throws Exception {
        HashSet<String> set = new HashSet<>();
        System.out.println("After new HashSet, size=" + set.size()); // 0
        
        // 通过反射窥视底层 HashMap 的 table 长度
        Field mapField = HashSet.class.getDeclaredField("map");
        mapField.setAccessible(true);
        HashMap<?,?> map = (HashMap<?,?>) mapField.get(set);
        Field tableField = HashMap.class.getDeclaredField("table");
        tableField.setAccessible(true);
        Object[] table = (Object[]) tableField.get(map);
        System.out.println("Before add, table=" + table); // null
        
        set.add("Java");
        table = (Object[]) tableField.get(map);
        System.out.println("After add, table length=" + (table == null ? 0 : table.length));
        // 输出类似 16,验证首次 put 触发扩容
    }
}

输出显示添加元素后 table 长度变为 16,印证了延迟分配机制。


Part 3:核心原理篇

模块 5:add 操作——去重的核心(源码剖析)

HashSet.add(E e) 源码只有一行:

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

语义解析

  • 调用 HashMap.put(e, PRESENT),如果 e 先前不作为 Key 存在,则 put 返回 nulladd 返回 true,表示添加成功。
  • 如果 e 已经存在,put 会覆盖 Value 为 PRESENT(实际上还是同一个对象),并返回旧值(即之前的 PRESENT),add 返回 false
  • 因此,去重完全依赖于 HashMap 内部 Key 的 equalshashCode 双重仲裁

让我们追踪一条完整的调用链:

HashSet.add(e)  
→ HashMap.put(e, PRESENT)  
→ hash(key)   // 扰动函数,混合高低位  putVal(hash, key, value, ...)  
→ (n-1) & hash 定位桶下标  
→ 遍历桶内链表/红黑树,逐个判断 “p.hash == hash && (k == key || key.equals(k))”  
→ 如果找到匹配 Node,更新 value,返回旧值;否则插入新 Node,返回 null。

add 调用链流程图

flowchart TD
    Start(["HashSet.add e"]) --> Put["map.put(e, PRESENT)"]
    Put --> Hash["计算 hash: h = key.hashCode h ^= h>>>16"]
    Hash --> Index["计算桶下标: (n-1) & hash"]
    Index --> Check{"桶首节点为空?"}
    Check -->|"是"| Insert["插入新 Node, size++"]
    Check -->|"否"| Loop{"遍历链表/红黑树"}
    Loop --> Match{"hash 相等 且 key==e 或 e.equals(key)?"}
    Match -->|"是"| Update["更新 value 为 PRESENT 返回旧值"]
    Match -->|"否"| Next["继续下一个节点"]
    Next --> Loop
    Insert --> AfterInsert["afterNodeInsertion 预留钩子"]
    AfterInsert --> ReturnNull["返回 null"]
    Update --> ReturnOld["返回旧 PRESENT"]
    ReturnNull --> AddTrue["add 返回 true"]
    ReturnOld --> AddFalse["add 返回 false"]

流程图说明
一句话概括:该图完整描述了从 HashSet.add 出发,经 HashMap.put 的哈希扰动、桶定位、链/树遍历判重,最终根据是否首次插入返回布尔的全部流程。
逐层分解

  • 第一层(HashSet 转发):add 直接委托 map.put(e, PRESENT),未做任何附加逻辑。
  • 第二层(哈希扰动)hash(Object key) 将 Key 哈希值的高 16 位与低 16 位异或,增加随机性,降低低位相同的碰撞几率。
  • 第三层(桶定位):利用 (n-1) & hash 代替取模,要求容量 n 为 2 的幂,快速映射到 0~n-1 的桶索引。
  • 第四层(冲突处理):当桶非空时,顺着链表或红黑树逐一对比,判定条件为 hash 相等 && (引用相等 || equals 相等)。若命中,则替换 value 并返回旧值;否则追加节点。
  • 第五层(返回值映射):HashMap 返回 null 意味着插入新 Key,HashSet.add 返回 true;否则表示 Key 已存在,add 返回 false。
    数据结构映射:HashMap 的 Node<K,V> 链表/红黑树节点实际承载 HashSet 元素;hash 字段来自 Key 的 hashCode() 再经扰动函数处理
    源码方法对应HashMap.putputVal → 内部循环遍历 Node.next,红黑树分支调用 putTreeVal
    关键结论强调HashSet 的去重秘密就是 HashMap 的 Key 唯一性,而这一机制严格依赖 equals 与 hashCode 的正确重写;如果两个对象 equals 为 true 但 hash 值不同,就可能分别落在不同桶,导致重复存储。

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

这两个操作的源码同样极简:

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

public boolean contains(Object o) {
    return map.containsKey(o);
}
  • removeHashMap.remove(o) 当 Key 存在时返回对应的 Value(即 PRESENT),因此判断 == PRESENT 即可确认是否删除成功。注意,如果元素不存在返回 nullnull == PRESENT 为 false,正确表示删除失败。
  • contains:直接委托 containsKey,利用 HashMap 的查找逻辑,时间复杂度 O(1) 平均。

remove 操作调用链路

flowchart LR
    A["HashSet.remove(o)"] --> B["map.remove(o)"]
    B --> C{"hash 扰动 + 定位桶"}
    C --> D["遍历链表/树查找"]
    D --> E{"匹配?"}
    E -->|"是"| F["删除节点 Node 返回其 value: PRESENT"]
    E -->|"否"| G["返回 null"]
    F --> H["== PRESENT -> true"]
    G --> I["== PRESENT -> false"]

流程图说明
一句话概括:remove 流程图展示了 HashSet 如何将元素的删除完全转交给 HashMap,并借助 PRESENT 对象精确转换返回值为布尔语义。
逐层分解

  • 第一步:HashSet.remove 传入元素对象,无类型限制。
  • 第二步:进入 HashMap.remove,经过相同的哈希扰动和桶定位,在链表/红黑树中进行查找匹配(条件同 put 时的查找)。
  • 第三步:若找到匹配节点,removeNode 方法解除其链/树连接,并返回节点的 value,该值恒为 PRESENT
  • 第四步:HashSet 据此与静态常量 PRESENT 比对,确认删除成功。
  • 第五步:若未找到,返回 null,判断为 false。
    数据结构映射:删除动作直接操作 HashMap 的 Node 节点,可能导致桶内链表断开或树转为链表。
    源码方法对应HashMap.removeremoveNode(hash(key), key, null, false, true)
    关键结论强调contains 和 remove 同样完全依赖 Key 的 hashCode/equals 契约;若重写不正确,contains 可能返回 false 即使元素存在,remove 可能无法正确删除。

模块 7:其他重要方法源码简析

  • size() / isEmpty() / clear()

    public int size() { return map.size(); }
    public boolean isEmpty() { return map.isEmpty(); }
    public void clear() { map.clear(); }
    

    全部是单纯转发。

  • iterator()

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

    返回的是 HashMap 内部 KeySet 的迭代器,实际类型为 KeyIterator,它继承自 HashIterator,具有 fail‑fast 检测机制(详见模块 8)。需要注意的是 keySet().iterator() 返回的迭代器并不直接支持 add 操作(虽然 KeySet 不提供 add),但迭代器本身的 remove 是支持且安全的。


Part 4:迭代与序列化篇

模块 8:迭代器与 fail‑fast 机制

HashSet.iterator() 返回的迭代器实际上是 HashMapKeyIterator 实例。该迭代器在创建时捕获底层 HashMap 的 modCount(结构修改计数器)。每次调用 next()remove() 时,都会检查当前 modCount 与预期值是否一致,若不一致立即抛出 ConcurrentModificationException。这种尽力而为的快速失败机制主要用于及早暴露并发修改错误,但不保证能够捕获所有并发修改。

迭代器 next() 与 modCount 交互

sequenceDiagram
    participant Client
    participant HashSet
    participant HashMap
    participant KeyIterator as KeyIterator(fail-fast)
    Note over KeyIterator: expectedModCount = modCount

    Client->>HashSet: iterator()
    HashSet->>HashMap: keySet().iterator()
    HashMap->>KeyIterator: new KeyIterator()
    KeyIterator-->>HashSet: 迭代器实例
    HashSet-->>Client: 迭代器

    Client->>KeyIterator: next()
    KeyIterator->>KeyIterator: checkForComodification()
    alt modCount != expectedModCount
        KeyIterator-->>Client: throw ConcurrentModificationException
    else 一致
        KeyIterator->>HashMap: nextNode() 遍历 table
        HashMap-->>KeyIterator: Node
        KeyIterator-->>Client: element
    end

交互图说明
一句话概括:该时序图展现了 fail‑fast 迭代器在每次获取元素前都会比对 modCount,一旦发现异步结构修改就抛出 CME。
逐层分解

  • 第一步(获取迭代器):HashSet 调用 HashMap.keySet().iterator(),HashIterator 构造时记录 expectedModCount = modCount
  • 第二步(next 调用):执行 checkForComodification(),若 modCount != expectedModCount 抛出 CME,否则从当前桶位置继续遍历链表/红黑树获取下一元素。
  • 第三步(安全删除):使用迭代器自身的 remove() 方法会在删除后同步更新 expectedModCount,因此不会抛出 CME;但若在迭代期间使用集合本身的 remove 方法,则 modCount 增加导致下次 next 失败。
    数据结构映射:迭代器内部维护指向当前 Node 的引用和下一个节点的引用,直接依赖 HashMap 的数组+链表结构进行遍历。
    源码方法对应HashIterator.nextNode() 在 HashMap 非静态内部类中实现,会跳过空桶,按照 table 数组索引递增方向移动。
    关键结论强调在 HashSet 遍历期间,任何导致底层 HashMap 结构变更(增/删)的操作都会触发 CME,除非使用迭代器自身的 remove 或使用 removeIf 等安全方法。

模块 9:序列化与克隆机制

HashSet 实现了 Serializable,但内部 map 字段被声明为 transient,表明它并不希望采用默认的序列化方式。原因在于哈希表本身与 JVM 的哈希种子、内存布局强相关,默认序列化不仅浪费空间,还可能导在跨 JVM 时重建的哈希分布不同。因此,HashSet 自定义了 writeObjectreadObject 方法:

private void writeObject(java.io.ObjectOutputStream s) throws IOException {
    s.defaultWriteObject();
    // 写出 HashMap 的容量与负载因子
    s.writeInt(map.capacity());
    s.writeFloat(map.loadFactor());
    // 写出元素个数
    s.writeInt(map.size());
    // 逐个写出元素
    for (E e : map.keySet())
        s.writeObject(e);
}

private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException {
    s.defaultReadObject();
    int capacity = s.readInt();
    float loadFactor = s.readFloat();
    int size = s.readInt();
    // 根据容量和负载因子重建 HashMap,并逐个放入元素
    map = new HashMap<>(capacity, loadFactor);
    for (int i=0; i<size; i++) {
        E e = (E) s.readObject();
        map.put(e, PRESENT);
    }
}

这种按元素流式序列化的策略不仅恢复了集合内容,还允许反序列化时以最适合当前 JVM 的哈希表布局重新构建,保证了反序列化后 HashSet 的哈希性能。

克隆机制
HashSet.clone() 调用 super.clone()(浅拷贝),然后对 map 字段调用 HashMap.clone()

public Object clone() {
    try {
        HashSet<E> newSet = (HashSet<E>) super.clone();
        newSet.map = (HashMap<E, Object>) map.clone();
        return newSet;
    } catch (CloneNotSupportedException e) {
        throw new InternalError(e);
    }
}

这是浅克隆,HashSet 本身被复制,但内部的元素对象并没有被深拷贝,新旧集合共享相同的元素引用。因此,如果修改元素的可变状态,可能影响到双方。


Part 5:对比与陷阱篇

模块 10:HashSet vs TreeSet vs LinkedHashSet——三大实现类对比

特性HashSetTreeSetLinkedHashSet
底层数据结构HashMapTreeMap(红黑树)LinkedHashMap
元素顺序无序(桶序)自然排序或 Comparator 排序保持插入顺序
时间复杂度add/remove/contains 平均 O(1)O(log n)O(1)(稍高常数)
null 支持允许一个 null不允许(需比较)允许一个 null
内存开销中等较大(维护红黑树)较大(额外链表)
适用场景无排序需求,追求极致性能需要排序的场景,如范围查找需要记住插入顺序且无需排序

Set 选型决策树

flowchart TD
    Start(["选择 Set 实现"]) --> Order{"需要特定的遍历顺序?"}
    Order -->|"需要自然/比较排序"| TreeSet["TreeSet 红黑树 O(log n)"]
    Order -->|"需要插入顺序"| LinkedHashSet["LinkedHashSet 双向链表 O(1)"]
    Order -->|"不关心顺序"| Perf{"对性能极度敏感?"}
    Perf -->|"是 数据量大"| HashSet["HashSet O(1) 快速操作"]
    Perf -->|"否 需要导航"| TreeSet
    HashSet --> Null{"允许 null?"}
    Null -->|"允许"| HS["HashSet 允许 null"]
    Null -->|"不允许"| HSCheck["代码校验 null"]
    LinkedHashSet --> NullL{"允许 null?"}
    NullL -->|"允许"| LHS["LinkedHashSet 允许 null"]
    NullL -->|"不允许"| LHSCheck["代码校验 null"]
    TreeSet --> NPE["TreeSet 禁止 null Comparable/Comparator 抛出 NPE"]

决策树说明
一句话概括:该图提炼了选择 Set 实现的三步决策:是否需顺序 → 何种顺序 → 性能与 null 约束,最终定位最佳实现。
逐层分解

  • 第一层:顺序需求。TreeSet 适用于自然/定制排序;LinkedHashSet 适用于仅维持插入顺序;HashSet 则完全无序。
  • 第二层:性能考量。HashSet 的 O(1) 极快,但若需要范围查找(如找出大于某值的所有元素),TreeSet 的 subSet 等方法是 HashSet 不具备的,此时即便慢些也值得。
  • 第三层:null 容忍度。TreeSet 因依赖比较器,插入 null 几乎必抛 NullPointerException;HashSet 和 LinkedHashSet 都允许一个 null,若业务禁止 null,必须自行前置校验。
    数据结构映射:TreeSet 内部树节点 Entry<K,V> 有 left/right/parent 指针;LinkedHashSet 节点在 HashMap 节点基础上增加 before/after 双向链表指针。
    源码方法对应TreeSet.addm.put(e, PRESENT),其中 mTreeMapLinkedHashSet 构造器通过模块 4 的包私有构造器注入 LinkedHashMap
    关键结论强调无特殊顺序需求时,HashSet 始终是最高效的选择;一旦业务逻辑涉及排序或插入顺序,应果断转向 TreeSet 或 LinkedHashSet。

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

陷阱 1:自定义对象未重写 hashCode/equals 导致去重失效

错误示例

class Person {
    String name;
    int age;
    Person(String name, int age) { this.name = name; this.age = age; }
}
// 未覆写 hashCode/equals
HashSet<Person> set = new HashSet<>();
set.add(new Person("Alice", 30));
set.add(new Person("Alice", 30));
System.out.println(set.size()); // 2,期望 1

根本原因:Object 的 equals 比较引用地址,hashCode 返回不同整数,两个业务相同对象被放入不同桶。

正确示例

class Person {
    String name;
    int age;
    // constructor...
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person p = (Person) o;
        return age == p.age && Objects.equals(name, p.name);
    }
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

此时 set.size() 输出 1。

陷阱 2:遍历中删除抛出 ConcurrentModificationException

错误示例(增强 for 循环本质是迭代器)

for (String s : set) {
    if (s.startsWith("A")) {
        set.remove(s); // 抛出 CME
    }
}

正确做法

  • 使用 Iterator.remove()
    Iterator<String> it = set.iterator();
    while (it.hasNext()) {
        String s = it.next();
        if (s.startsWith("A")) it.remove();
    }
    
  • 或 Java 8+ removeIfset.removeIf(s -> s.startsWith("A"));

陷阱 3:多线程下数据丢失或无限循环

HashSet 非线程安全,并发 add 可能导致 HashMap 内部链表成环或元素丢失。

解决方案

  • Set<String> syncSet = Collections.synchronizedSet(new HashSet<>());
  • 或使用并发集合:ConcurrentHashMap.newKeySet() 返回线程安全的 Set 视图。

陷阱 4:修改元素导致 hashCode 变化,元素“丢失”

典型场景

class MutablePerson { String name; /* 可变,参与了 hashCode */ }
HashSet<MutablePerson> set = new HashSet<>();
MutablePerson p = new MutablePerson("Alice");
set.add(p);
p.name = "Bob"; // 改变属性导致 hashCode 变化
System.out.println(set.contains(p)); // 很可能 false
set.remove(p); // 可能失败,导致内存泄漏

最佳实践
存入 HashSet 的对象最好是不可变对象(如 String、包装类),或至少保证参与 hashCode/equals 计算的字段不可变。如果确实需要修改,应先移除,修改后重新加入。


Part 6:总结与面试篇

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

时间复杂度表(假设良好哈希分布)

  • add:平均 O(1),最坏 O(log n)(桶内为树)或 O(n)(链表)
  • remove / contains:同上
  • size:O(1)
  • 迭代:与容量 + 元素数成正比,O(capacity + size)

负载因子调优建议

  • 默认 0.75 是时间与空间折衷。若内存紧张可增大(如 1.0),但会增加冲突概率;若追求极致查询性能可适当减小(如 0.5),但会消耗更多内存。
  • 预估元素数量初始化容量,可避免频繁的扩容 rehash 开销:new HashSet<>(expectedSize / 0.75 + 1)

null 元素注意事项

  • HashSet 允许 null,但许多操作(如 contains(null))能正确返回 true。但大量使用 null 通常暗示设计不佳,应当用更有意义的默认值或 Optional。

模块 13:面试高频专题(独立详细)

1. HashSet 的底层数据结构是什么?如何保证元素不重复?

标准回答:底层完全依赖 HashMap。元素被当作 HashMap 的 Key 存入,Value 固定为静态常量 PRESENT。HashMap 内部使用数组+链表+红黑树存储,通过 Key 的 hashCode()equals() 两个方法联合保证唯一性:当 put 时发现哈希值相同且 equals 为真,则认为 Key 已存在,只更新 Value 但不新增实体。

追问模拟:如果两个对象 equals 相等但 hashCode 不同会怎样?
回答:这两个对象会落入不同的哈希桶,HashSet 将它们视为不同元素,出现“业务重复但集合中存在两份”的错误,违背 Set 契约。

加分回答:可提及 JDK 8 中哈希冲突严重时链表会树化为红黑树,避免 O(n) 查询,同时指出 hashCode 的设计必须满足“相等对象必同哈希”的原则,并且要尽可能分散。

2. HashSet 的 add 方法返回值的含义?如何判断插入成功?

标准回答add(E e) 返回 boolean 值,true 表示此元素之前不在集合中,添加成功;false 表示集合中已存在与 e 相等(equals)的元素,添加失败。底层通过 map.put(e, PRESENT) == null 判断,如果 put 返回 null 表明之前不存在此 Key。

追问模拟:如果 add 传入 null 会怎样?
回答:HashSet 允许单个 null,首次 add(null) 返回 true,再次返回 false。HashMap 的 put 同样支持一个 null 键。

3. 为什么存入 HashSet 的对象要重写 hashCode 和 equals?如果不重写会怎样?

标准回答:HashSet 依赖 hashCode 决定元素落在哪个桶,依赖 equals 在桶内判定是否相等。如果不重写,会继承 Object 的默认实现——equals 比较引用地址,hashCode 通常返回内存地址相关整数。这导致内容相同的不同对象被视为不等,无法正确去重,出现重复元素。

加分回答:不仅要去重,还需要遵循 equalshashCode 的契约:若两个对象 equals 相等,hashCode 必须相等;反之则不然。此外,建议使用 Objects.hash 生成哈希,并保证参与计算的字段不可变以避免“丢失”元素。

4. HashSet 与 HashMap 的关系?PRESENT 对象的作用?

标准回答:HashSet 内部聚合一个 HashMap 实例 map,所有方法均由 HashMap 的对应方法实现。PRESENT 是一个 static final Object 实例,作为共享的虚拟 Value 填充到每个条目中。它最主要的作用有两点:① 避免使用 null 使 put 返回值无法区分“旧值为 null”还是“Key 不存在”;② 所有条目共用同一个引用,节省内存。

追问模拟:能否用 null 代替 PRESENT?
回答:技术上可以,但会导致 add 始终返回 true,失去判断新增的能力,因为 HashMap 对不存在键的 put 也返回 null,无法区分。

5. HashSet 如何遍历?迭代器原理是什么?

标准回答:通过 iterator() 方法返回一个迭代器,底层是 HashMap.keySet().iterator(),实际类型为 KeyIterator。该迭代器按哈希桶索引和链表/树顺序遍历所有非空桶里的每个节点。迭代器是 fail‑fast 的,创建时记录 modCount,遍历过程中若发现结构性修改(增加/删除元素,非迭代器自身操作),立即抛出 ConcurrentModificationException

加分回答:可使用 for‑each 语法糖,编译后仍为迭代器遍历。当需要边遍历边删除时,应使用 Iterator.remove() 或 Java 8 的 removeIf 方法。

6. HashSet 与 TreeSet 的区别?如何选择?

标准回答:① 底层实现:HashSet 基于 HashMap(数组+链表+红黑树),TreeSet 基于 TreeMap(红黑树)。② 顺序:HashSet 无序,TreeSet 按自然顺序或比较器排序。③ 性能:HashSet 增删查 O(1),TreeSet O(log n)。④ null 支持:HashSet 允许一个 null,TreeSet 通常不允许(比较器可能需要比较 null)。⑤ 功能:TreeSet 提供额外导航方法如 first()last()subSet()

选择依据:需要排序或范围查找时用 TreeSet;仅需去重且追求性能时用 HashSet。

7. HashSet 线程安全吗?如何在并发中使用安全的 Set?

标准回答:HashSet 完全非线程安全。并发添加可能引起 HashMap 链表成环、数据丢失等问题。常见安全化手段:① Collections.synchronizedSet(new HashSet<>()) 返回一个所有方法加锁的包装器,但迭代时仍需手动同步;② 使用 ConcurrentHashMap.newKeySet() 获得并发 Set,它利用分段锁和高并发算法,适合高并发场景;若元素数稳定、读多写少,也可考虑 CopyOnWriteArraySet(底层 CopyOnWriteArrayList)。

加分回答:提及 ConcurrentHashMap.newKeySet() 内部实际是 ConcurrentHashMap.KeySetView,支持原子添加,并可直接获得并发的 Map 视图。

8. HashSet 的默认容量和负载因子是多少?如何影响性能?

标准回答:默认初始容量 16,负载因子 0.75。当元素数超过容量 × 负载因子(即 12)时,HashMap 会进行 resize 扩容为原来的两倍,并重新哈希所有元素。负载因子越大,空间利用率越高,但冲突概率增加,查询效率下降;反之,负载因子越小,查询越快,但内存浪费越多。可以在构造时指定,根据预估元素量调优避免频繁扩容。

9. 如果修改了 HashSet 中元素的 hashCode,会发生什么?

标准回答:元素的哈希值改变后,它在底层 HashMap 中的桶位置可能不再与新哈希值匹配。此时 containsremove 会在新桶位置查找,几乎找不到该元素,导致“元素丢失”、内存泄漏。迭代器遍历仍可能访问到旧桶中的元素(因为它未移动),但后果不可预测。这正是推荐使用不可变对象作为集合元素的原因。

加分回答:如果必须修改,正确的做法是先 remove,修改后再 add;或者在设计上避免将可变字段放入 hashCode 计算。

10. 为什么推荐使用不可变对象作为 HashSet 的元素?

标准回答:不可变对象创建后状态恒定,哈希值固定。这保证了在集合生命周期内,哈希值不变,桶位置稳定,containsremove 等操作始终能正确定位。此外,不可变对象天然线程安全,能被安全地共享在多线程环境中。JDK 中的 StringInteger 等包装类就是最佳范例。如果确实需要可变对象,务必确保参与 hashCode 计算的字段在存入集合后不会发生改变。

加分回答:可举例 Guava 的 ImmutableSet 或 Java 9+ Set.of 创建的不可变集合,它们进一步从集合层面防止修改,提供更强的安全保障。


总结

全文从 HashSet 的委托本质出发,纵贯源码、架构、陷阱与面试,揭示了 Set 去重的核心奥秘。当你在代码中写下 Set<String> set = new HashSet<>() 时,脑中映出的不应仅仅是那个菱形符号,而应是背后 HashMap 的数组、链表和红黑树正在为你的元素唯一性保驾护航。*