一、Set 核心本质:基于 Map 的 "键封装"
所有 Set 实现类的底层都是复用 Map 实现 (元素存在 Map 的 key 位置,value 用静态常量 PRESENT 占位),这是理解 Set 特性的核心:
java
运行
// HashSet 源码核心片段(JDK 1.8)
public class HashSet<E> extends AbstractSet<E> {
private transient HashMap<E, Object> map;
// 占位用的静态常量,所有元素共享同一个value
private static final Object PRESENT = new Object();
public HashSet() {
map = new HashMap<>();
}
public boolean add(E e) {
return map.put(e, PRESENT) == null; // 本质是Map的put,key重复则返回旧值(添加失败)
}
}
结论:Set 的所有特性(去重、有序性、性能)都继承自底层 Map,理解了 HashMap/TreeMap,就理解了对应的 Set 实现。
二、HashSet 深度解析(补充底层核心)
1. 去重原理的本质(面试高频)
HashSet 的去重依赖 HashMap 的 key 唯一性,核心两步校验:
- 哈希值校验 :调用元素的
hashCode()计算哈希值,确定存储桶位置; - equals 校验 :若桶中已有元素,调用
equals()比较,返回true则视为重复,拒绝添加。
关键易错点本质:
- 仅重写
hashCode():不同元素可能哈希值相同(哈希冲突),equals()会判断为不同,导致重复; - 仅重写
equals():不同元素哈希值不同,不会进入equals()比较,直接视为不同元素; - 必须同时重写两者:保证 "哈希值相同的元素一定 equals,equals 的元素一定哈希值相同"。
2. 性能优化核心:初始容量计算
HashSet 默认负载因子 0.75,扩容触发条件 size > 容量 × 负载因子,提前计算初始容量可避免频繁扩容:
java
运行
// 公式:初始容量 = 预估元素数 / 负载因子 + 1(向上取整)
int expectedSize = 10000;
// 正确:10000/0.75≈13333.33,+1避免边界值触发扩容
HashSet<String> set = new HashSet<>((int) (expectedSize / 0.75) + 1);
3. 并发安全替代方案(实战首选)
HashSet 非线程安全,原文的 Collections.synchronizedSet() 是 "包装式同步"(效率低),现代开发优先使用 ConcurrentHashMap 封装的 Set:
java
运行
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public class ConcurrentSetDemo {
public static void main(String[] args) {
// 方式1:JDK 8+ 推荐(ConcurrentHashMap的newKeySet)
Set<String> concurrentSet = ConcurrentHashMap.newKeySet();
// 方式2:手动封装(兼容旧版本)
ConcurrentMap<String, Object> map = new ConcurrentHashMap<>();
Set<String> set = map.keySet();
// 多线程安全添加
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
concurrentSet.add(Thread.currentThread().getName() + "-" + i);
}
};
new Thread(task).start();
new Thread(task).start();
}
}
三、LinkedHashSet 深度解析(补充有序性本质)
1. 底层结构:HashMap + 双向链表
LinkedHashSet 继承 HashSet,底层是 LinkedHashMap(HashMap + 双向链表):
- HashMap:保证元素唯一、O (1) 性能;
- 双向链表 :维护元素顺序(插入顺序 / 访问顺序),链表节点额外存储
before/after指针。
2. 两种顺序模式的核心场景
表格
| 顺序模式 | 触发条件 | 核心场景 | 典型案例 |
|---|---|---|---|
| 插入顺序 | 默认(accessOrder=false) | 记录操作日志、保存浏览历史(需固定顺序) | 用户操作轨迹、接口调用记录 |
| 访问顺序 | accessOrder=true | LRU 缓存(最近访问的元素排在末尾,淘汰最久未访问) | 本地缓存、热点数据存储 |
LRU 缓存简易实现:
java
运行
import java.util.LinkedHashSet;
// 基于LinkedHashSet实现LRU缓存(固定容量,满了删除最久未访问)
class LRUCache<E> extends LinkedHashSet<E> {
private final int capacity;
public LRUCache(int capacity) {
// 初始容量=容量/0.75+1,负载因子0.75,开启访问顺序
super((int) (capacity / 0.75) + 1, 0.75f, true);
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(java.util.Map.Entry<E, Object> eldest) {
// 容量满时删除最久未访问的元素
return size() > capacity;
}
// 简化:重写add方法,触发访问顺序更新
public boolean add(E e) {
super.add(e);
return true;
}
}
public class LRUCacheDemo {
public static void main(String[] args) {
LRUCache<String> cache = new LRUCache<>(3);
cache.add("A");
cache.add("B");
cache.add("C");
System.out.println(cache); // [A, B, C]
cache.add("A"); // 访问A,移到末尾
System.out.println(cache); // [B, C, A]
cache.add("D"); // 容量满,删除最久未访问的B
System.out.println(cache); // [C, A, D]
}
}
3. 性能对比:LinkedHashSet vs HashSet
表格
| 操作 | HashSet | LinkedHashSet | 差异原因 |
|---|---|---|---|
| 插入 | O(1) | O (1)(略慢) | 需维护链表指针 |
| 查询 | O(1) | O (1)(几乎无差异) | 哈希桶定位不受链表影响 |
| 遍历 | O (n)(无序,哈希桶遍历) | O (n)(更快) | 双向链表顺序遍历,无需遍历空桶 |
四、TreeSet 深度解析(补充排序核心)
1. 去重原理的特殊性(与 HashSet 本质不同)
TreeSet 基于 TreeMap 实现,去重不依赖 hashCode/equals,而是依赖排序规则:
- 自然排序:元素实现
Comparable,compareTo()返回 0 则视为重复; - 定制排序:传入
Comparator,compare()返回 0 则视为重复。
核心坑点 :若自定义对象同时重写了 equals 和排序规则,需保证两者逻辑一致,否则会出现 "equals 相同但排序规则不同" 的矛盾:
java
运行
// 反例:排序规则与equals不一致
class User implements Comparable<User> {
String id;
String name;
public User(String id, String name) {
this.id = id;
this.name = name;
}
// 排序规则:按name排序
@Override
public int compareTo(User o) {
return this.name.compareTo(o.name);
}
// equals规则:按id判断
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return id.equals(user.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}
public class TreeSetConflictDemo {
public static void main(String[] args) {
TreeSet<User> set = new TreeSet<>();
// 两个id相同(equals=true)但name不同的User
set.add(new User("1001", "张三"));
set.add(new User("1001", "李四"));
// 输出size=2(排序规则返回非0,视为不同元素),但equals=true,违反直觉
System.out.println(set.size());
}
}
正确做法:排序规则必须与 equals 逻辑一致(通常基于唯一标识如 id 排序)。
2. 排序规则的核心要求
TreeSet 的红黑树依赖稳定的排序规则,必须满足:
- 自反性 :
compare(a,a) = 0; - 对称性 :
compare(a,b) = -compare(b,a); - 传递性 :
compare(a,b) > 0且compare(b,c) > 0→compare(a,c) > 0。
原文中 "随机返回排序结果" 的反例,本质是违反了传递性,导致红黑树结构混乱,甚至抛出异常。
3. 数值排序的精度坑(补充)
对浮点数排序时,避免直接用减法(可能溢出 / 精度丢失),优先用 Double.compare()/Integer.compare():
java
运行
// 错误:double减法可能丢失精度(如0.0000001和0.0000002相减)
TreeSet<Double> badSet = new TreeSet<>((a, b) -> (int) (a - b));
// 正确:使用Double.compare
TreeSet<Double> goodSet = new TreeSet<>(Double::compare);
五、三大 Set 增强对比表(补充核心维度)
表格
| 特性 | HashSet | LinkedHashSet | TreeSet |
|---|---|---|---|
| 底层实现 | HashMap(数组 + 链表 + 红黑树) | LinkedHashMap(HashMap + 双向链表) | TreeMap(红黑树) |
| 有序性 | 无序(哈希桶分布) | 插入顺序 / 访问顺序 | 自然排序 / 定制排序 |
| 去重依据 | hashCode() + equals() | hashCode() + equals() | compareTo()/compare() |
| 时间复杂度 | 增删查 O (1)(平均) | 增删查 O (1)(平均,略慢) | 增删查 O (log n) |
| null 支持 | 允许一个 null | 允许一个 null | 不支持 null(排序时抛 NPE) |
| 线程安全 | 非线程安全 | 非线程安全 | 非线程安全 |
| 并发替代方案 | ConcurrentHashMap.newKeySet() | 无原生实现(需手动封装 LinkedHashMap) | 无原生实现(需手动封装 TreeMap) |
| 内存占用 | 低(无额外链表) | 中(双向链表额外指针) | 高(红黑树节点含颜色 / 父 / 子指针) |
| 核心适用场景 | 纯去重、高性能查询 | 需保留顺序的去重(日志 / 缓存) | 需排序的去重(排行榜 / 有序筛选) |
六、Set 与 List 核心差异(补充性能维度)
表格
| 维度 | List(以 ArrayList 为例) | Set(以 HashSet 为例) | 核心原因 |
|---|---|---|---|
| 去重 | 需手动实现(如遍历判断) | 自动去重 | 底层 Map 的 key 唯一性 |
| 查找性能 | 按元素查找 O (n) | 按元素查找 O (1) | 哈希表直接定位 vs 数组遍历 |
| 内存占用 | 低(仅存储元素) | 高(封装为 Map 的 key,额外存储 PRESENT) | Set 是 Map 的 "包装器",有额外开销 |
| 顺序控制 | 插入顺序固定,支持索引调整 | 仅 LinkedHashSet 支持顺序 | List 基于数组索引,Set 依赖底层 Map 结构 |
| 常用场景 | 需重复元素、索引访问、频繁修改 | 需去重、无需索引、高性能查找 | - |
七、实战选型决策树(快速选对 Set)
flowchart TD
A[选择Set实现类] --> B{是否需要有序?}
B -->|否| C{是否追求极致性能?}
C -->|是| D[HashSet]
C -->|否| E[HashSet(默认首选)]
B -->|是| F{有序类型?}
F -->|插入/访问顺序| G[LinkedHashSet]
F -->|排序(自然/定制)| H[TreeSet]
A --> I{是否多线程环境?}
I -->|是| J[ConcurrentHashMap.newKeySet()(替代HashSet)]
I -->|否| K[按上述规则选择]
八、开发最佳实践
- 默认首选 HashSet:90% 的去重场景用 HashSet,创建时指定初始容量优化性能;
- 自定义元素必重写 hashCode/equals:尤其是 TreeSet,需保证排序规则与 equals 一致;
- 有序去重选 LinkedHashSet:如记录用户浏览历史、操作日志,避免用 TreeSet(性能低);
- 排序去重选 TreeSet:仅当需要动态排序时使用,静态排序可先存 HashSet 再排序(更高效);
- 多线程禁用原生 Set :优先用
ConcurrentHashMap.newKeySet(),而非Collections.synchronizedSet(); - 大数据量去重:若元素数量超 10 万,优先用 HashSet(O (1) 性能),避免 TreeSet(O (log n))。
总结
- HashSet:基于 HashMap,无序、高性能、自动去重,单线程纯去重场景首选,核心是重写 hashCode/equals 并指定初始容量;
- LinkedHashSet:基于 LinkedHashMap,有序(插入 / 访问)+ 去重,性能略低于 HashSet,适用于需保留顺序的去重场景;
- TreeSet:基于 TreeMap,有序(排序)+ 去重,性能 O (log n),仅适用于需动态排序的场景,核心是保证排序规则的稳定性;
- 核心本质:所有 Set 都是 Map 的 "键封装",理解底层 Map 的特性是掌握 Set 的关键。