首页 / Java 学习笔记 / 08

JAVA · Vol.I · DAY 08 · 集合框架源码

HashSet 与 TreeSet 底层原理:Map 的「马甲」

中级高频#集合#源码
第 1 站

从一道高频面试题开始

面试中被问到 Set 集合,很多人的回答只停留在"不允许重复"。但面试官想听的是——你知道 HashSet 底层用的是什么吗?

面试官问:"HashSet 和 TreeSet 底层分别是什么数据结构?两者的本质区别是什么?"

如果你只能回答"一个用 HashMap,一个用 TreeMap",那你只说对了结论,还没展示出理解深度。面试官真正想听到的是:委托模式——Set 的所有操作如何转发给 Map,以及这种设计背后的工程思想。

这道题还有一条经典的追问链,每一环都在考察你是否真的读过源码:

  • 追问 1:HashSet 的 value 是什么?(PRESENT 占位对象)
  • 追问 2:为什么 add() 返回 boolean 而不是 void?(靠 put() 的返回值判重)
  • 追问 3:去重到底依赖什么?hashCode() + equals() 契约)
  • 追问 4:为什么 TreeSet 不能放 null,HashSet 却可以?(比较 vs 哈希)
  • 追问 5:LinkedHashSet 怎么保持顺序的?(LinkedHashMap 的双向链表)

本篇文章的核心观点只有一句话:Set 就是 Map 的马甲。理解了这个委托模式,一个知识点就能覆盖 HashSet、TreeSet、LinkedHashSet 三个类。我们从源码出发,逐层拆解:先看套壳结构,再深入 HashMap 的哈希、冲突、扩容,最后落到红黑树与生产实践。

为什么面试官爱从 Set 问起?

因为 Set 是通往 Map 深水区的桥。从 HashSet 一句"底层是 HashMap",可以顺势引出哈希函数、负载因子、树化阈值、扩容拆分、hashCode/equals 契约——一道题考完整个 Java 集合核心。答好了,就是体系化能力的展示。

第 2 站

Set 接口与委托模式

Set 接口继承自 Collection,定义了"不允许重复元素"的契约。但 Set 本身只是一个接口规范,它的三个主要实现类——HashSet、LinkedHashSet、TreeSet——全部通过委托一个 Map 来完成工作

以 HashSet 为例,打开源码你会看到两个关键成员:

HashSet.java · JDK 核心字段
// 底层存储:所有元素都作为 key 存入这个 HashMap
private transient HashMap<E, Object> map;

// 占位 value —— 所有 entry 共享同一个对象引用
private static final Object PRESENT = new Object();

元素作为 HashMap 的 key 存储,value 统一指向一个静态常量 PRESENT。这就是委托模式的精髓:Set 不自己实现存储逻辑,而是把一切交给 Map

委托模式:Set 是 Map 的马甲 HashSet<E> add(e) / remove(o) / contains(o) 委托 HashMap<E, Object> put(e, PRESENT) / remove(o) / containsKey(o) 桶 0 桶 1 桶 2 桶 3 "apple" → PRESENT "banana" → PRESENT Set 只管"有没有",Map 负责"怎么存" —— 一个知识点覆盖两个类
图 1委托模式:HashSet 将所有操作转发给内部的 HashMap,元素作为 key,PRESENT 作为占位 value
为什么 value 要用固定的 PRESENT 对象?

HashSet 只关心"元素是否存在",不需要存 value。如果 value 设为 null,add()map.put(e, PRESENT) == null 的判断就会失效——无法区分"key 不存在"和"value 本身就是 null"。用静态 final 对象引用既省内存(所有 entry 共享),又保证逻辑正确。

这套"外壳 + 内核"的设计在工程上叫组合优于继承:存储引擎(HashMap/TreeMap/LinkedHashMap)只写一遍,却能同时服务 Map 和 Set 两个 API 面;Set 侧不重复实现哈希表或红黑树,代码量、bug 面、维护成本全部减半。

第 3 站

HashSet 源码解读:四行代码看透本质

HashSet 的源码极简:绝大多数方法体只有一行,核心逻辑全部在 HashMap 里。你只需要看四个方法,就能完整理解这个类:

HashSet.java · 核心方法实现
// add:元素作为 key,PRESENT 作为 value
public boolean add(E e) {
    return map.put(e, PRESENT) == null;
}

// remove:直接调用 HashMap 的 remove
public boolean remove(Object o) {
    return map.remove(o) == PRESENT;
}

// contains:直接调用 HashMap 的 containsKey
public boolean contains(Object o) {
    return map.containsKey(o);
}

// size:直接返回 HashMap 的 size
public int size() {
    return map.size();
}
核心等式:HashSet.add(e) = HashMap.put(e, PRESENT)
去重原理:put 返回 null 说明 key 不存在(新增成功),返回旧 value 说明 key 已存在(add 返回 false)

再看构造器家族——每一行都在告诉我们"底层就是 HashMap":

HashSet.java · 构造器与迭代器
public HashSet() { map = new HashMap<>(); }          // 默认 16 容量、0.75 负载因子
public HashSet(int initialCapacity) { map = new HashMap<>(initialCapacity); }

// 用集合初始化:按 0.75 负载因子反推容量,避免 addAll 过程中扩容
public HashSet(Collection<? extends E> c) {
    map = new HashMap<>(Math.max((int) (c.size() / .75f) + 1, 16));
    addAll(c);
}

// 迭代 = 遍历 HashMap 的 keySet 视图
public Iterator<E> iterator() { return map.keySet().iterator(); }

关键洞察:你对 HashMap 了解的一切,直接适用于 HashSet。

  • HashMap 的哈希冲突解决(链表 + 红黑树)→ HashSet 的去重性能保证
  • HashMap 的扩容机制(2 倍扩容,load factor 0.75)→ HashSet 的扩容行为
  • HashMap 要求 key 重写 equals() + hashCode() → HashSet 要求元素重写这两个方法
  • HashMap 允许 1 个 null key → HashSet 允许 1 个 null 元素
面试加分回答模板:
"HashSet 底层委托 HashMap,元素作为 key 存入,value 使用静态常量 PRESENT 占位。因此 HashMap 的哈希表结构、扩容策略、冲突处理机制,全部直接决定了 HashSet 的行为。"

一句话把 Set 和 Map 串联起来,展示的不是死记硬背,而是体系化理解
第 4 站

为什么 add 返回 boolean:put 返回值的三段式

新手常问:add 为什么不设计成 void?答案藏在 HashMap.put() 的返回值语义里。

面试追问:"HashSet.add(e) 返回 false 的时候,底层到底发生了什么?集合被修改了吗?modCount 会变吗?"

put 返回值的三种情况

HashMap.java · put 返回值契约
// 返回:与 key 关联的【旧 value】;若此前没有该 key 的映射,返回 null
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
场景 put 返回 HashSet.add 返回
key 不存在,新插入节点 null true(新增成功)
key 已存在,覆盖旧 value(仍是 PRESENT) 旧 value = PRESENT false(重复,未新增)

关键点:因为 value 恒为非 null 的 PRESENT,所以"put 返回 null"与"key 是新的"之间是一一对应的——这就是第 2 站那个 PRESENT 设计的闭环。如果 value 允许为 null,两种含义就混在一起无法区分了。

add 返回 false 时,底层发生了什么?

putVal() 命中已存在的 key 时走的是"覆盖分支":只把 e.value 换成新 value(这里换的还是 PRESENT,等于没换),然后 return oldValue不会 ++modCount,不会 ++size。所以:add 一个重复元素不是结构性修改。推论:遍历 HashSet 的过程中重复 add 已存在的元素,不会抛 ConcurrentModificationException——这是很多人不知道的细节。

返回 boolean 的工程价值

这个设计来自 Collection.add() 的接口契约:返回"集合是否因本次调用发生了改变"。List 里 add 几乎永远返回 true,而 Set 必须能表达"重复元素被拒绝"。调用方由此可以实现很多惯用法:

MetricService.java · 统计"首次出现"
// 统计独立访客:add 返回 true 才代表一个新用户
Set<Long> seen = new HashSet<>();
for (Long uid : dailyLog) {
    if (seen.add(uid)) {   // 只有真正的新用户才计数
        uniqueCount++;
    }
}

边界情况:add(null) 是合法的——HashMap 允许一个 null key(hash(null) == 0,落在桶 0),所以 HashSet 最多容纳一个 null;再 add 一次 null 返回 false。

核心要点
  • add(e) 返回 map.put(e, PRESENT) == null,靠 put 的返回值判重。
  • 返回 false 时集合内容、size、modCount 均不变,只是 value 被"覆盖"成同样的 PRESENT。
  • boolean 返回值是 Collection 的修改语义:调用方需要知道集合是否真的变了。
第 5 站

去重契约:hashCode() 与 equals() 的完整故事

HashSet 判断"重复"的本质是:先按 hashCode 找桶,再在桶内用 equals 精确定位。两个方法缺一不可。

面试追问:"如果一个类只重写了 equals()、没有重写 hashCode(),放进 HashSet 会发生什么?"

为什么必须成对出现

Object 的默认实现是"身份比较":equals 比引用,hashCode 是 JVM 生成的身份哈希(通常与内存地址相关)。而 Java 契约要求:

equals() 相等 ⟹ hashCode() 必须相等(逆命题不成立:hashCode 相同不代表 equals 相等)

这个契约不是摆设,它直接决定 HashMap 的查找正确性。看 getNode() 的两段式定位:

HashMap.java · 查找一个 key 的两步
// 第 1 步:hash(key) 定位桶 —— 只用 hashCode
if ((tab = table) != null && (n = tab.length) > 0 &&
    (first = tab[(n - 1) & hash]) != null) {
    // 第 2 步:桶内用 == 或 equals 逐个比较 —— 只用 equals
    if (first.hash == hash && ((k = first.key) == key ||
        (key != null && key.equals(k))))
        return first;
    // ... 链表 / 红黑树中继续 equals 比较
}

只重写一半的两种事故现场

User.java · 只重写 equals、忘掉 hashCode —— 去重直接失效
public class User {
    private final Long id;
    private final String name;

    // equals 正确:按业务字段比较
    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User)) return false;
        User u = (User) o;
        return Objects.equals(id, u.id) && Objects.equals(name, u.name);
    }
    // hashCode 没重写 —— 用的还是 Object 的身份哈希!

    public static void main(String[] args) {
        User a = new User(1L, "Alice");
        User b = new User(1L, "Alice");
        Set<User> set = new HashSet<>();
        set.add(a);
        set.add(b);                 // 你以为返回 false?实际返回 true!
        System.out.println(set.size()); // 2 —— 两个"相等"对象并存,去重失效
    }
}

原因:a.equals(b) == true,但两者的身份 hashCode 不同,被散列到不同的桶equals() 压根没机会被调用,Set 里自然出现两个"相等"元素。反向事故(hashCode 相同、equals 不同)则只影响性能:都挤进同一桶让链表变长,但桶内 equals 会精确区分,正确性不受影响。

生产中的两种写法

现代写法直接用 Objects.hash(...)Objects.equals(...)(或用 IDE 生成),参与计算的字段只取 equals 用到的字段,保证两者严格对齐。参与 hashCode 的字段最好 final——这引向第 12 站最大的坑:可变对象做 key。

核心要点
  • 定位两段式:hashCode 定桶,equals 定元素,二者缺一不可。
  • equals 相等 ⟹ hashCode 相等;反过来不成立(hashCode 碰撞是正常现象)。
  • 只重写 equals:去重失效(不同桶);只重写 hashCode:正确性无碍、性能受损。
第 6 站

性能的地基:hash 扰动、寻址与 8 / 64 / 6 三个数字

HashSet 号称 O(1),这个常数项的好坏完全取决于 HashMap 的哈希与冲突策略。先看两个关键函数:

HashMap.java · 扰动函数与关键常量
// 扰动:让高 16 位也参与低 16 位运算,稀释低位的碰撞
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

// 寻址:i = (n - 1) & hash —— n 是 2 的幂,等价 hash % n 且更快

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;    // 16
static final float DEFAULT_LOAD_FACTOR = 0.75f;         // 负载因子
static final int TREEIFY_THRESHOLD = 8;                  // 链长 ≥ 8 → 尝试树化
static final int UNTREEIFY_THRESHOLD = 6;                // 树节点 ≤ 6 → 退化为链表
static final int MIN_TREEIFY_CAPACITY = 64;              // 树化前数组容量的下限

为什么用 (n - 1) & hash因为容量强制为 2 的幂(构造时用 tableSizeFor() 向上取整),此时 hash % n(n - 1) & hash 数学等价,而位与运算比取模快一个数量级。为什么还要扰动?寻址只用 hash 的低 log2(n) 位,若 hashCode 的低位分布差(比如某些对象低位恒为 0),冲突会集中;把高 16 位异或进来稀释低位,就是 h ^ (h >>> 16) 的用意。

冲突解决:链表 → 红黑树的两级演化

从 hashCode 到桶:扰动 → 取模 → 冲突(链表 → 红黑树) key.hashCode() → h hash():h ^ (h >>> 16) 扰动 (n - 1) & hash → 桶索引 定位 桶数组(容量 n = 16) 0 1 2 3 4 5 6 7 A B C 链长 ≥ 8 时触发 treeifyBin 红黑树 TreeNode,查找 O(log n) 树化条件:单桶链长 ≥ 8 且容量 ≥ 64,否则先扩容(resize);扩容分裂后树节点 ≤ 6 退化为链表
图 2哈希寻址与冲突演化:链表兜底,链长超过阈值后升级为红黑树
面试追问:"树化阈值为什么是 8?退化阈值为什么是 6 而不是 7?"
8 和 6 背后的两个工程理由

选 8:HashMap 的 javadoc 给出过泊松分布估算——在理想随机哈希、负载因子 0.75 下,一个桶里链长恰好为 8 的概率约 0.00000006(不足一千万分之一)。所以 8 意味着"哈希质量已严重退化",值得付出 TreeNode 的内存代价(TreeNode 约为普通 Node 的两倍)去换 O(log n)。选 6 而不是 7:避免元素在 7/8 之间增减时,链表与红黑树反复横跳(结构抖振),留 1 的缓冲带。且 treeifyBin() 有个前提:容量不足 64 时先扩容而不是先树化——小数组上树化收益低,扩容稀释冲突更划算。

核心要点
  • 扰动函数 h ^ (h >>> 16) 让高位参与寻址,容量 2 的幂让 (n-1) & hash 等价取模。
  • 三个数字:TREEIFY_THRESHOLD = 8MIN_TREEIFY_CAPACITY = 64UNTREEIFY_THRESHOLD = 6
  • 树化是兜底不是常态:设计目标是"理想哈希下永远用不上红黑树"。
第 7 站

扩容机制:2 倍扩容与高低位拆分

size > threshold(threshold = 容量 × 负载因子,默认 16 × 0.75 = 12)时触发 resize()容量翻倍newCap = oldCap << 1),上限 MAXIMUM_CAPACITY = 1 << 30。扩容要搬运所有节点,是 O(n) 的全量操作,所以预估容量很重要。

JDK 8 的核心优化:免 rehash 的高低位拆分

JDK 7 扩容要重新计算每个元素的 hash 并取模;JDK 8 发现了一个数学规律:容量翻倍后,元素的索引要么不变,要么 = 原索引 + oldCap,判断依据是 e.hash & oldCap 这一位是 0 还是 1。

JDK 8 扩容:不重新哈希,元素要么留原位,要么 + oldCap 扩容前:capacity = 8(oldCap = 8) 桶 3 k1 (h=3) k2 (h=11) k3 (h=19) (e.hash & oldCap) == 0 ? 原位 : 原位 + oldCap 3&8=0 → 留桶3 · 11&8=8 → 桶3+8=11 · 19&8=0 → 留桶3 扩容后:capacity = 16 桶 3(原位) 桶 11(+8) k1 (h=3) k3 (h=19) lo 链:(e.hash & oldCap) == 0 k2 (h=11) hi 链:原索引 + oldCap
图 3扩容时用 e.hash & oldCap 把一条链表拆成 lo / hi 两条,hi 链整体搬到原索引 + oldCap
从 JDK 7 死循环到 JDK 8 尾插:线程安全的历史课

JDK 7 的 HashMap 扩容用头插法(新节点插到链表头),并发扩容时两条线程同时搬运,可能把链表搬成环形,之后任何 get() 都会死循环,CPU 直接打满——这是当年著名的生产事故。JDK 8 改为尾插法并配合高低位拆分,不再有环,但并发下仍会丢数据、覆盖写,线程安全依然要交给 ConcurrentHashMap(其 sizeCtl 控制初始化/扩容,CounterCell 分担计数,保证 size() 近似准确)。

扩容与复杂度

  • 触发时机:size > threshold,其中 threshold = capacity × 0.75(默认 16 × 0.75 = 12,即第 13 个元素触发首次扩容)。
  • 均摊成本:虽然单次扩容是 O(n),但扩容频率随容量增长指数下降,均摊到每次 put 仍是 O(1)——这就是"均摊分析"。
  • 生产启示:能预估规模时,直接用 new HashSet<>((int) (n / 0.75f) + 1) 初始化,避免中途多次全量搬运。
核心要点
  • 扩容 = 容量翻倍(上限 1 << 30),threshold = 容量 × 0.75。
  • JDK 8 免 rehash:e.hash & oldCap 判定元素留原位还是 +oldCap,且尾插保序。
  • 扩容是 O(n),但均摊 O(1);预估容量是生产级性能的第一道防线。
第 8 站

TreeSet 与 TreeMap:红黑树上的有序集合

TreeSet 同样采用委托模式,但底层 Map 换成了 NavigableMap(通常是 TreeMap)。TreeMap 基于红黑树实现,保证每次插入、删除、查找的时间复杂度为 O(log n)

TreeSet.java · JDK 核心字段与方法
public class TreeSet<E> extends AbstractSet<E>
    implements NavigableSet<E>, Cloneable, Serializable {

    // 底层存储:NavigableMap(通常是 TreeMap)
    private transient NavigableMap<E, Object> m;

    // 占位 value(同 HashSet 的 PRESENT)
    private static final Object PRESENT = new Object();

    // 默认构造:自然排序 → 元素必须实现 Comparable
    public TreeSet() { this(new TreeMap<>()); }

    // 自定义比较器
    public TreeSet(Comparator<? super E> comp) {
        this(new TreeMap<>(comp));
    }

    // 核心方法同样委托
    public boolean add(E e)    { return m.put(e, PRESENT) == null; }
    public boolean remove(Object o) { return m.remove(o) == PRESENT; }

    // TreeSet 独有的有序操作(TreeSet 有而 HashSet 没有)
    public E first()   { return m.firstKey(); }
    public E last()    { return m.lastKey(); }
    public E lower(E e)    { return m.lowerKey(e); }    // < e 的最大元素
    public E higher(E e)   { return m.higherKey(e); }   // > e 的最小元素
    public E ceiling(E e)  { return m.ceilingKey(e); }  // >= e 的最小元素
    public E floor(E e)    { return m.floorKey(e); }    // <= e 的最大元素
}

红黑树:自平衡的二叉搜索树

如果只是普通二叉搜索树,插入顺序可能让它退化成一条链表(O(n))。红黑树用颜色标记 + 旋转/变色维持平衡,保证树高始终约 log₂n。五条性质:

红黑树:自平衡的二叉搜索树(TreeMap 内核) 50 30 70 20 40 60 80 黑色节点(含根与 NIL 叶子) 红色节点(红节点子必为黑,无红红相邻) 性质:① 节点非红即黑 ② 根为黑 ③ 叶子 NIL 为黑 ④ 红节点孩子必为黑 ⑤ 任一节点到叶子路径黑数相同 中序遍历(左-根-右)=升序:20 30 40 50 60 70 80
图 4红黑树五条性质保证树高约 log₂n;TreeMap 插入/删除后由 fixAfterInsertion() / fixAfterDeletion() 做变色与旋转自平衡

排序从哪来:Comparable 与 Comparator

TreeMap.java · 比较入口
// 有外部 Comparator 用 Comparator,否则要求 key 实现 Comparable(自然排序)
final int compare(Object k1, Object k2) {
    return comparator == null
        ? ((Comparable<? super K>) k1).compareTo((K) k2)
        : comparator.compare((K) k1, (K) k2);
}

// put 中:cmp == 0 视为同一个 key —— 只覆盖 value,不新增节点
else return t.setValue(value);
面试追问:"TreeSet 判断元素是否重复,用的是 equals() 还是 compareTo()?如果一个类的 compareTo 和 equals 不一致会怎样?"
TreeSet 的去重标准是 compare 结果,不是 equals

TreeSet 的重复判定只看 compareTo() == 0(或 Comparator 返回 0):返回 0 就认为"同一个元素",add() 返回 false,equals() 根本不参与。因此若类没实现 Comparable,add 时抛 ClassCastException;若 compareTo 与 equals 不一致,会出现"equals 不同的两个对象被当成一个"或反之——这是生产中的经典坑,第 12 站会展开。这也是为什么 TreeSet 的元素必须实现 Comparable 或传入 Comparator

为什么 TreeSet 不允许插入 null?

TreeSet 需要对元素进行比较排序。插入 null 时,TreeMap.put() 会先执行 compare(key, key) 做类型检查:自然排序下 ((Comparable) null).compareTo(...) 直接抛 NullPointerException。而 HashSet 只依赖 hashCode()equals(),null 的 hashCode 被特殊处理为 0,所以允许一个 null。注意边界:若自定义 Comparator 显式容忍 null,TreeMap 理论上也能收 null——但这是反直觉的用法,面试按"默认 NPE"回答即可。

第 9 站

TreeSet 的导航方法与"活视图"

TreeSet 独有的价值在于有序查询。六个导航方法要分清"严格"与"含等"、以及"找不到返回什么":

方法 语义 找不到时
first() / last() 最小 / 最大元素 空集合抛 NoSuchElementException
lower(e) 严格小于 e 的最大元素 返回 null(不抛异常)
floor(e) 小于等于 e 的最大元素(含 e)
ceiling(e) 大于等于 e 的最小元素(含 e)
higher(e) 严格大于 e 的最小元素
pollFirst() / pollLast() 取出并删除最小 / 最大元素 返回 null(空集合)

subSet / headSet / tailSet:返回的是"活视图"

TreeSet.java · 区间视图的实现
// 子区间视图:包一层 NavigableSubMap,操作实时映射回原 TreeMap
public NavigableSet<E> subSet(E fromElement, boolean fromInclusive,
                               E toElement, boolean toInclusive) {
    return new TreeSet<>(m.subMap(fromElement, fromInclusive, toElement, toInclusive));
}

public NavigableSet<E> headSet(E toElement, boolean inclusive) {
    return new TreeSet<>(m.headMap(toElement, inclusive));
}
视图是"活的":类比数据库视图

subSet() 返回的不是副本,而是原树的窗口——就像数据库视图,查的是同一张表。对视图的读写都会实时作用到原 TreeSet:原集合新增元素,视图立刻"看得见"(若落在区间内);向视图 add 区间外的元素则抛 IllegalArgumentException。如果不需要联动,再 new TreeSet<>(view) 拷贝一份。

还有 descendingSet() / descendingIterator() 提供逆序遍历视图。这些导航操作全部走红黑树路径,复杂度都是 O(log n)。典型场景:日程表中查"下一个空闲时间段"、价格区间查询、按时间线做范围聚合。

核心要点
  • lower/floor/ceiling/higher 四兄弟的区别只在"严格/含等";空结果返回 null,而 first/last 抛异常。
  • subSet/headSet/tailSet 是活视图:共享底层数据,区间外 add 抛 IllegalArgumentException。
  • TreeSet 判重以 compare == 0 为准,与 equals 无关。
第 10 站

LinkedHashSet:LinkedHashMap 保序的秘密

LinkedHashSet 的代码量比 HashSet 还少——它继承 HashSet,只是把底层 Map 换成 LinkedHashMap。看那个 package-private 的构造器:

LinkedHashSet.java / HashSet.java · 偷天换日
// LinkedHashSet 的构造器,全部转发给 HashSet 的"特殊"构造器
public class LinkedHashSet<E> extends HashSet<E> {
    public LinkedHashSet() { super(16, .75f, true); }
    public LinkedHashSet(Collection<? extends E> c) {
        super(Math.max(2 * c.size(), 11), .75f, true);
        addAll(c);
    }
}

// HashSet 里的"隐藏构造器":第三个参数 dummy 只是占位,真正作用是换 Map 实现
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
    map = new LinkedHashMap<>(initialCapacity, loadFactor);
}

LinkedHashMap = HashMap + 双向链表

LinkedHashMap 在 HashMap 的每个节点上多挂了两个指针 before / after,串成一条贯穿所有桶的全局双向链表。桶内的冲突链表(next 指针)负责"定位",全局链表负责"顺序",两套结构互不干扰。

LinkedHashSet = 哈希定位 + 双向链表保序(插入顺序) 桶数组 桶 0 桶 1 桶 2 桶 3 cherry apple banana durian next 冲突链 before / after 双向链表(遍历只走这条链,输出插入顺序) after after after head(最老)→ apple tail(最新)← durian 虚线 = 桶内 next 冲突链(负责查找);实线 = 全局 before/after 顺序链(负责遍历)
图 5LinkedHashMap 在哈希桶之外另挂一条全局双向链表:查找走哈希,遍历走链表,顺序与插入一致
面试追问:"向 LinkedHashSet 重复 add 一个已存在的元素,元素的顺序会变吗?"
插入序 vs 访问序:accessOrder 开关

LinkedHashMap 有个字段 accessOrder,默认 false 表示插入序:重复 put 已存在的 key 只覆盖 value,不移动节点位置(所以 LinkedHashSet 重复 add 不改变顺序)。若设为 true 则每次访问都把节点挪到链表尾部——这正是 LinkedHashMap.removeEldestEntry() 实现 LRU 缓存(如 MyBatis 的 LruCache、Spring 早期的缓存)的底层机制。LinkedHashSet 只暴露插入序版本,因为 Set 语义下"访问序"没有意义。

核心要点
  • LinkedHashSet 继承 HashSet,通过隐藏构造器把底层换成 LinkedHashMap,代码零重复。
  • 顺序由 before/after 全局双向链表保证,查找仍走哈希桶,复杂度与 HashSet 同阶 O(1) 平均。
  • 代价:每个节点多两个引用(before/after),内存略高于 HashSet;重复 add 不改变顺序。
第 11 站

一张表覆盖三个 Set + 复杂度对比

掌握了委托模式后,三个 Set 实现类的区别就转化为三个 Map 的区别:

特性 HashSet LinkedHashSet TreeSet
底层 Map HashMap LinkedHashMap TreeMap
元素顺序 无序 插入顺序 自然排序 / Comparator
add / remove / contains O(1) 平均 O(1) 平均 O(log n)
是否允许 null 允许 1 个 允许 1 个 不允许(NPE)
Comparable 要求 不需要 不需要 必须实现
内存开销 较小 中等(多链表) 较大(每节点约 48B)
独有方法 first/last/lower/higher/ceiling/floor/subSet

复杂度对比(均摊 / 平均意义)

操作 HashSet LinkedHashSet TreeSet
add O(1) 平均(最坏 O(log n),全树化后) O(1) 平均 O(log n)
remove O(1) 平均 O(1) 平均 O(log n)
contains O(1) 平均 O(1) 平均 O(log n)
遍历全部 O(容量)(按桶扫描,含空桶) O(n)(沿 after 链,无空桶开销) O(n)(中序遍历)
取最小 / 最大 不支持 不支持 O(log n)(first/last)
区间查询 不支持 不支持 O(log n + k)(subSet 等)
排序能力 仅保持插入序 实时有序

值得注意的细节:HashSet 遍历是 O(容量) 而不是 O(size)——它要扫过整个桶数组(包括空桶),这也是为什么"容量开得过大"会拖慢迭代。而 LinkedHashSet 沿 after 链走,天然 O(size);TreeSet 中序走树,也是 O(size)。

Set 家族继承树与委托关系 Collection<E> extends Set<E> NavigableSet<E> HashSet LinkedHashSet TreeSet → HashMap → LinkedHashMap 委托 → TreeMap = 继承 = 委托给 Map
图 6Set 家族继承树:每个 Set 实现类都委托给对应的 Map 实现
一句话选型

数据量大、只要去重 → HashSet;去重要排序或区间查询 → TreeSet;去重且遍历要稳定有序 → LinkedHashSet。本质上就是问自己:要不要顺序?顺序是"比较序"还是"插入序"?答案决定底层 Map。

第 12 站

实战场景与生产最佳实践

场景一:去重 —— HashSet

UserService.java · 用户 ID 去重
// 收集所有不重复的用户 ID(O(1) 插入 + 自动去重)
Set<Long> uniqueUserIds = new HashSet<>(
    (int) (orderList.size() / 0.75f) + 1);   // 预估容量,避免扩容
for (Order order : orderList) {
    uniqueUserIds.add(order.getUserId());
}

场景二:有序去重 + 区间查询 —— TreeSet

Leaderboard.java · 排行榜
// 按分数降序;分数相同按名字升序 —— 注意 TreeSet 以 compare 结果判重,
// 若只按分数比较,同分玩家会被当成"重复元素"吞掉!
TreeSet<Player> leaderboard = new TreeSet<>(
    Comparator.comparingInt(Player::getScore).reversed()
             .thenComparing(Player::getName)   // 决胜字段,保证唯一性
);
leaderboard.add(new Player("Alice", 2800));
leaderboard.add(new Player("Bob", 3200));
leaderboard.add(new Player("Carol", 2800)); // 与 Alice 同分,靠名字区分

Player top = leaderboard.first();                  // Bob(最高分)
Player second = leaderboard.lower(top);            // 第二名
// 区间查询:分数 ≥ 3000 的玩家(probe 对象模式,只用于比较)
NavigableSet<Player> highScores =
    leaderboard.tailSet(new Player("", 3000), true);

场景三:保持插入顺序 —— LinkedHashSet

TagService.java · 有序标签
// 文章标签:去重且保持添加顺序,遍历输出稳定
Set<String> tags = new LinkedHashSet<>();
tags.add("Java");
tags.add("集合框架");
tags.add("面试");
tags.add("Java");  // 重复,忽略且不改变顺序
// 遍历顺序:Java → 集合框架 → 面试

生产坑清单:每一个都见过线上事故

面试追问:"把一个可变对象放进 HashSet 之后,又修改了它的字段,会发生什么?"
坑 1:可变对象做 key = 幽灵元素 + 内存泄漏

如果元素的 hashCode() 依赖可变字段,放进去之后再改字段,它的哈希值变了,但它在桶里的位置没变。contains() / remove() 会按新哈希去另一个桶找——找不到!这个元素既无法查询也无法删除,成了集合里的"幽灵",常驻内存。所以:做 Set 元素 / Map key 的类,参与 hashCode 的字段应尽量 final 不可变(String、Integer、Long 天生安全)。

  • 坑 2:equals / hashCode 契约被破坏——只重写一个、或用了非 final 字段、或子类继承时破坏对称性,直接导致去重失效或查询错乱。用 Objects.hash() / Objects.equals() 或 IDE 生成,别手写。
  • 坑 3:容量预估不当——new HashSet<>() 默认 16,塞进 10 万条要扩容多次(每次 O(n) 全量搬运)。已知规模 N 用 new HashSet<>((int) (N / 0.75f) + 1)。反过来也别盲目开超大容量:HashSet 遍历是 O(容量),空桶也要扫。
  • 坑 4:多线程直接用 HashSet——HashMap 非线程安全,并发 put 可能丢数据、覆盖写(JDK 8 后不再死循环,但依然错乱)。并发去重用 ConcurrentHashMap.newKeySet()(内部 sizeCtl 控制初始化与扩容、CounterCell 分担计数);读多写少用 CopyOnWriteArraySet;简单场景 Collections.synchronizedSet()
  • 坑 5:fail-fast 迭代器——遍历时通过 iterator.remove() 之外的途径修改集合(结构性修改,modCount 变化),抛 ConcurrentModificationException。注意:add 一个已存在的元素不改变 modCount,不会触发(见第 4 站)。
  • 坑 6:TreeSet 的排序器与 equals 不一致——TreeSet 判重只看 compare 结果。Comparator 只比较分数时,同分玩家会被吞;自定义类若 compareTo 与 equals 语义不一致,可能出现"equals 不相等却被去重"或"compareTo 认为相同但 equals 不同",破坏 Set 语义,也坑了依赖 hashCode/equals 的其他容器。另注意 TreeSet 默认不接受 null。
  • 坑 7:序列化与深浅拷贝——HashSet/TreeSet 都实现 Serializable,但其中的元素必须可序列化;跨进程传递 Set 时元素类要保持版本一致,否则反序列化报错。
选型口诀
  • 只要去重 → HashSet(最快,O(1),记得预估容量)
  • 去重 + 排序 / 区间查询 → TreeSet(O(log n),元素要 Comparable 或传 Comparator)
  • 去重 + 保持插入顺序 → LinkedHashSet(O(1),遍历稳定有序,LRU 缓存同源机制)
  • 并发去重ConcurrentHashMap.newKeySet(),别裸用 HashSet
总结

这一篇你掌握了什么

核心知识点回顾

  • 一个模式:Set 是 Map 的马甲——HashSet 委托 HashMap、TreeSet 委托 TreeMap、LinkedHashSet 委托 LinkedHashMap,元素作 key,PRESENT 静态对象作占位 value。
  • add 返回 boolean:map.put(e, PRESENT) == null 判重;put 返回 null 说明新插入(true),返回旧 value 说明已存在(false),且重复 add 不改 size / modCount。
  • 去重契约:hashCode 定桶 + equals 定元素,两段式定位;equals 相等 ⟹ hashCode 相等,二者必须成对重写。
  • 三个数字:树化阈值 8、退化阈值 6、最小树化容量 64;负载因子 0.75、初始容量 16、扩容翻倍(上限 1 << 30)。
  • 扩容优化:JDK 8 用 e.hash & oldCap 高低位拆分,免 rehash、尾插保序;均摊 O(1)。
  • 红黑树:TreeMap 内核,五条性质保证树高约 log₂n,中序遍历即升序;判重以 compare == 0 为准而非 equals;默认不允许 null。
  • 导航方法:first/last(空集抛异常)、lower/floor/ceiling/higher(无结果返回 null)、subSet/headSet/tailSet 是活视图。
  • 保序原理:LinkedHashMap = HashMap + before/after 全局双向链表,查找走哈希、遍历走链表,accessOrder=false 时重复 add 不改变顺序。
  • 复杂度:HashSet/LinkedHashSet O(1) 平均,TreeSet O(log n);HashSet 遍历是 O(容量),LinkedHashSet/TreeSet 遍历是 O(size)。
  • 生产红线:可变对象不做 key、容量按 N/0.75 预估、并发用 ConcurrentHashMap.newKeySet()、Comparator 与 equals 保持一致。

面试时的满分话术:"HashSet、TreeSet、LinkedHashSet 都是 Map 的马甲——元素作 key、PRESENT 占位 value,差异只在底层 Map:哈希表 O(1)、红黑树 O(log n) 有序、双向链表保插入序。去重靠 hashCode+equals 契约,add 用 put 返回值判重。"一句话说完,深度自然就出来了。

Comments · 评论