在 Java 中,Set
接口的核心特性是不允许存储重复元素,这一特性的实现依赖于各具体实现类的底层数据结构和算法。以下从元素添加、删除、查找的实现细节,以及性能优化和设计模式等角度进行更深入的剖析。
一、HashSet 源码深度解析
1. 元素添加机制(add (E e))
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
HashMap 的 put 逻辑:
- 计算哈希值:通过
key.hashCode()
计算哈希值,再通过(n-1) & hash
确定桶位置(n 为数组长度)。 - 处理哈希冲突:
- 若桶为空,直接插入新节点。
- 若桶不为空,遍历链表 / 红黑树:
- 若发现
key.equals(otherKey)
为 true,则替换旧值(但 PRESENT 是常量,无实际影响)。 - 否则插入新节点(链表尾插法或树节点插入)。
- 若发现
- 扩容机制:当元素数量超过
容量 × 负载因子
(默认16 × 0.75 = 12
)时,触发扩容(resize)为原容量的 2 倍。
2. 元素删除机制(remove (Object o))
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
HashMap 的 remove 逻辑:
- 计算哈希值并定位桶位置。
- 遍历链表 / 红黑树,找到
key.equals(o)
的节点。 - 删除节点(链表移除或树节点调整),并调整红黑树平衡性(若需要)。
3. 性能优化点
- 哈希扰动函数:HashMap 通过
(h = key.hashCode()) ^ (h >>> 16)
让高位哈希参与运算,减少哈希冲突。 - 链表转红黑树:当链表长度超过 8 且数组长度超过 64 时,链表转换为红黑树(O (n) → O (log n))。
二、LinkedHashSet 源码深度解析
1. 双向链表的维护
LinkedHashSet 继承自 HashSet,但使用 LinkedHashMap 存储元素。LinkedHashMap 内部维护了一个双向链表,用于记录元素的插入顺序(或访问顺序)。
// LinkedHashMap 节点结构
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);
}
}
2. 插入顺序 vs 访问顺序
- 插入顺序(默认):元素按照添加顺序排列。
- 访问顺序(accessOrder = true):每次访问元素(如 get、put)会将该元素移至链表尾部,可用于实现 LRU 缓存。
三、TreeSet 源码深度解析
1. 红黑树的元素组织
TreeSet 基于 TreeMap 实现,TreeMap 是一棵红黑树(自平衡二叉搜索树),每个节点存储键值对(值为 PRESENT)。
红黑树特性:
- 每个节点要么是红色,要么是黑色。
- 根节点和叶子节点(NIL)是黑色。
- 红色节点的子节点必须是黑色。
- 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。
2. 元素比较与排序
- 自然排序:元素必须实现
Comparable
接口,通过compareTo()
方法比较。 - 定制排序:创建 TreeSet 时传入
Comparator
,通过compare()
方法比较。 - 唯一性判断:若
compare(a, b) == 0
,则认为两元素相等(即使equals()
为 false)。
3. 插入与删除操作
- 插入元素:
- 从根节点开始比较,找到合适的插入位置。
- 插入新节点(默认红色),并通过旋转和变色维持红黑树平衡。
- 删除元素:
- 找到待删除节点,若有两个子节点,则用后继节点替换。
- 删除节点后,通过旋转和变色维持红黑树平衡。
四、EnumSet 源码深度解析
1. 位向量的高效实现
EnumSet 使用 long
数组作为位向量,每个 bit 表示一个枚举常量的存在与否。例如:
// 假设有枚举 Color { RED, GREEN, BLUE }
// 对应的 ordinal 值为 0, 1, 2
// 若 EnumSet 包含 RED 和 BLUE,则位向量为 0b101 (二进制) = 5 (十进制)
2. 位运算的高效操作
- 添加元素:
elements |= (1L << e.ordinal())
- 删除元素:
elements &= ~(1L << e.ordinal())
- 判断元素存在:
(elements & (1L << e.ordinal())) != 0
- 遍历元素:通过
Long.numberOfTrailingZeros(elements)
快速定位下一个元素。
五、并发场景下的 Set 实现
1. ConcurrentHashMap 派生的 Set
Set<E> set = ConcurrentHashMap.newKeySet(); // JDK 8+
// 等价于
Set<E> set = Collections.newSetFromMap(new ConcurrentHashMap<E, Boolean>());
- 线程安全:基于 CAS(Compare-And-Swap)操作实现无锁并发。
- 弱一致性迭代器:迭代时可能反映其他线程的修改,但不会抛出
ConcurrentModificationException
。
2. CopyOnWriteArraySet
- 写时复制:写操作(add、remove)会复制整个数组,保证线程安全。
- 适用场景:读多写少的场景(如配置项缓存)。
- 缺点:写操作开销大,内存占用高,弱一致性迭代器。
六、设计模式与最佳实践
1. 适配器模式(Adapter Pattern)
Set 的实现类通过包装 Map 实现,体现了适配器模式:
- HashSet → HashMap
- LinkedHashSet → LinkedHashMap
- TreeSet → TreeMap
- EnumSet → 位向量操作
2. 性能优化建议
- 初始容量设置:对于已知元素数量的场景,预先设置合适的初始容量,减少扩容次数。
- EnumSet 优先:处理枚举类型时,优先使用 EnumSet 而非 HashSet。
- 自定义哈希策略:为复杂对象重写
hashCode()
和equals()
时,遵循 “相等对象必须有相同哈希值” 的原则。
七、对比总结
特性 | HashSet | LinkedHashSet | TreeSet | EnumSet |
---|---|---|---|---|
数据结构 | 哈希表 | 哈希表 + 双向链表 | 红黑树 | 位向量 |
顺序保证 | 无 | 插入 / 访问顺序 | 自然 / 定制排序 | 枚举定义顺序 |
元素要求 | 实现 equals () | 同上 | 实现 Comparable | 枚举类型 |
添加 / 删除时间 | O(1) | O(1) | O(log n) | O(1) |
空间效率 | 中 | 中(多维护链表) | 高(树结构) | 极高(位操作) |
线程安全 | 否 | 否 | 否 | 否 |
替代方案 | ConcurrentHashMap.newKeySet() | 同上 | 无 | 无 |
八、面试高频问题
-
HashSet 如何保证元素唯一性?
通过hashCode()
和equals()
方法。若两元素 hashCode 相同且 equals 为 true,则视为重复元素。 -
TreeSet 和 HashSet 的区别?
TreeSet 基于红黑树,元素有序且需实现 Comparable 或传入 Comparator;HashSet 基于哈希表,元素无序。 -
EnumSet 为什么高效?
内部使用位向量存储,每个枚举常量对应一个 bit,位运算效率极高(接近原生类型操作)。 -
LinkedHashSet 如何维护插入顺序?
底层使用 LinkedHashMap,通过双向链表记录元素插入顺序,遍历时按链表顺序返回。 -
HashSet 的初始容量和负载因子对性能的影响?
初始容量过小会频繁扩容(性能开销大);负载因子过大易导致哈希冲突,降低查询效率。