JAVA · Vol.I · DAY 08 · 集合框架源码
HashSet 与 TreeSet 底层原理:Map 的「马甲」
从一道高频面试题开始
面试中被问到 Set 集合,很多人的回答只停留在"不允许重复"。但面试官想听的是——你知道 HashSet 底层用的是什么吗?
如果你只能回答"一个用 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 是通往 Map 深水区的桥。从 HashSet 一句"底层是 HashMap",可以顺势引出哈希函数、负载因子、树化阈值、扩容拆分、hashCode/equals 契约——一道题考完整个 Java 集合核心。答好了,就是体系化能力的展示。
Set 接口与委托模式
Set 接口继承自 Collection,定义了"不允许重复元素"的契约。但 Set 本身只是一个接口规范,它的三个主要实现类——HashSet、LinkedHashSet、TreeSet——全部通过委托一个 Map 来完成工作。
以 HashSet 为例,打开源码你会看到两个关键成员:
// 底层存储:所有元素都作为 key 存入这个 HashMap
private transient HashMap<E, Object> map;
// 占位 value —— 所有 entry 共享同一个对象引用
private static final Object PRESENT = new Object();
元素作为 HashMap 的 key 存储,value 统一指向一个静态常量 PRESENT。这就是委托模式的精髓:Set 不自己实现存储逻辑,而是把一切交给 Map。
HashSet 只关心"元素是否存在",不需要存 value。如果 value 设为 null,add() 中 map.put(e, PRESENT) == null 的判断就会失效——无法区分"key 不存在"和"value 本身就是 null"。用静态 final 对象引用既省内存(所有 entry 共享),又保证逻辑正确。
这套"外壳 + 内核"的设计在工程上叫组合优于继承:存储引擎(HashMap/TreeMap/LinkedHashMap)只写一遍,却能同时服务 Map 和 Set 两个 API 面;Set 侧不重复实现哈希表或红黑树,代码量、bug 面、维护成本全部减半。
HashSet 源码解读:四行代码看透本质
HashSet 的源码极简:绝大多数方法体只有一行,核心逻辑全部在 HashMap 里。你只需要看四个方法,就能完整理解这个类:
// 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();
}
去重原理:put 返回 null 说明 key 不存在(新增成功),返回旧 value 说明 key 已存在(add 返回 false)
再看构造器家族——每一行都在告诉我们"底层就是 HashMap":
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 串联起来,展示的不是死记硬背,而是体系化理解。
为什么 add 返回 boolean:put 返回值的三段式
新手常问:add 为什么不设计成 void?答案藏在 HashMap.put() 的返回值语义里。
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,两种含义就混在一起无法区分了。
putVal() 命中已存在的 key 时走的是"覆盖分支":只把 e.value 换成新 value(这里换的还是 PRESENT,等于没换),然后 return oldValue。不会 ++modCount,不会 ++size。所以:add 一个重复元素不是结构性修改。推论:遍历 HashSet 的过程中重复 add 已存在的元素,不会抛 ConcurrentModificationException——这是很多人不知道的细节。
返回 boolean 的工程价值
这个设计来自 Collection.add() 的接口契约:返回"集合是否因本次调用发生了改变"。List 里 add 几乎永远返回 true,而 Set 必须能表达"重复元素被拒绝"。调用方由此可以实现很多惯用法:
// 统计独立访客: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的修改语义:调用方需要知道集合是否真的变了。
去重契约:hashCode() 与 equals() 的完整故事
HashSet 判断"重复"的本质是:先按 hashCode 找桶,再在桶内用 equals 精确定位。两个方法缺一不可。
为什么必须成对出现
Object 的默认实现是"身份比较":equals 比引用,hashCode 是 JVM 生成的身份哈希(通常与内存地址相关)。而 Java 契约要求:
这个契约不是摆设,它直接决定 HashMap 的查找正确性。看 getNode() 的两段式定位:
// 第 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 比较
}
只重写一半的两种事故现场
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:正确性无碍、性能受损。
性能的地基:hash 扰动、寻址与 8 / 64 / 6 三个数字
HashSet 号称 O(1),这个常数项的好坏完全取决于 HashMap 的哈希与冲突策略。先看两个关键函数:
// 扰动:让高 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) 的用意。
冲突解决:链表 → 红黑树的两级演化
选 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 = 8、MIN_TREEIFY_CAPACITY = 64、UNTREEIFY_THRESHOLD = 6。
- 树化是兜底不是常态:设计目标是"理想哈希下永远用不上红黑树"。
扩容机制: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。
e.hash & oldCap 把一条链表拆成 lo / hi 两条,hi 链整体搬到原索引 + oldCapJDK 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);预估容量是生产级性能的第一道防线。
TreeSet 与 TreeMap:红黑树上的有序集合
TreeSet 同样采用委托模式,但底层 Map 换成了 NavigableMap(通常是 TreeMap)。TreeMap 基于红黑树实现,保证每次插入、删除、查找的时间复杂度为 O(log n)。
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。五条性质:
fixAfterInsertion() / fixAfterDeletion() 做变色与旋转自平衡排序从哪来:Comparable 与 Comparator
// 有外部 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 的重复判定只看 compareTo() == 0(或 Comparator 返回 0):返回 0 就认为"同一个元素",add() 返回 false,equals() 根本不参与。因此若类没实现 Comparable,add 时抛 ClassCastException;若 compareTo 与 equals 不一致,会出现"equals 不同的两个对象被当成一个"或反之——这是生产中的经典坑,第 12 站会展开。这也是为什么 TreeSet 的元素必须实现 Comparable 或传入 Comparator。
TreeSet 需要对元素进行比较排序。插入 null 时,TreeMap.put() 会先执行 compare(key, key) 做类型检查:自然排序下 ((Comparable) null).compareTo(...) 直接抛 NullPointerException。而 HashSet 只依赖 hashCode() 和 equals(),null 的 hashCode 被特殊处理为 0,所以允许一个 null。注意边界:若自定义 Comparator 显式容忍 null,TreeMap 理论上也能收 null——但这是反直觉的用法,面试按"默认 NPE"回答即可。
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:返回的是"活视图"
// 子区间视图:包一层 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 无关。
LinkedHashSet:LinkedHashMap 保序的秘密
LinkedHashSet 的代码量比 HashSet 还少——它继承 HashSet,只是把底层 Map 换成 LinkedHashMap。看那个 package-private 的构造器:
// 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 指针)负责"定位",全局链表负责"顺序",两套结构互不干扰。
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 不改变顺序。
一张表覆盖三个 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)。
数据量大、只要去重 → HashSet;去重要排序或区间查询 → TreeSet;去重且遍历要稳定有序 → LinkedHashSet。本质上就是问自己:要不要顺序?顺序是"比较序"还是"插入序"?答案决定底层 Map。
实战场景与生产最佳实践
场景一:去重 —— HashSet
// 收集所有不重复的用户 ID(O(1) 插入 + 自动去重)
Set<Long> uniqueUserIds = new HashSet<>(
(int) (orderList.size() / 0.75f) + 1); // 预估容量,避免扩容
for (Order order : orderList) {
uniqueUserIds.add(order.getUserId());
}
场景二:有序去重 + 区间查询 —— TreeSet
// 按分数降序;分数相同按名字升序 —— 注意 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
// 文章标签:去重且保持添加顺序,遍历输出稳定
Set<String> tags = new LinkedHashSet<>();
tags.add("Java");
tags.add("集合框架");
tags.add("面试");
tags.add("Java"); // 重复,忽略且不改变顺序
// 遍历顺序:Java → 集合框架 → 面试
生产坑清单:每一个都见过线上事故
如果元素的 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 · 评论