HashSet
HashSet 源码分析
HashSet 本身几乎没有算法:它把一个 HashMap 包成 Set,元素当 key,value 统一填一个哨兵对象。去重、扩容、哈希扰动、树化全是 HashMap 的事,这个类只负责”把 Map 语义翻译成 Set 语义”。
代码块收起展开
// 基于本地 JDK 源码 (D:/1ForCode/JAVA_Source, java.base, 2024 版), java.util.HashSet
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable
{
// ...
transient HashMap<E,Object> map; // 唯一的实例字段。transient:序列化自己写,不走默认机制
// Dummy value to associate with an Object in the backing Map
static final Object PRESENT = new Object(); // 全类共享一个哨兵,不占额外内存;不能用 null,因为 map.put 返回 null 有"原来没这个 key"的语义
public HashSet() {
map = new HashMap<>(); // 默认容量 16、负载因子 0.75,全部沿用 HashMap 的默认值
}
@SuppressWarnings("this-escape")
public HashSet(Collection<? extends E> c) {
map = HashMap.newHashMap(Math.max(c.size(), 12)); // 按 size/0.75 向上取整算容量,保证 addAll 不触发扩容;下限 12 防止空/小集合建出过小的表
addAll(c);
}
public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<>(initialCapacity, loadFactor); // 参数原样透传,连 IllegalArgumentException 都是 HashMap 抛的
}
public HashSet(int initialCapacity) {
map = new HashMap<>(initialCapacity);
}
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor); // 包私有,专供 LinkedHashSet:dummy 参数只为区分重载签名
}
public Iterator<E> iterator() {
return map.keySet().iterator(); // 迭代即遍历 key 视图,fail-fast 行为也是继承来的
}
public int size() {
return map.size();
}
// ...
public boolean contains(Object o) {
return map.containsKey(o);
}
public boolean add(E e) {
return map.put(e, PRESENT)==null; // put 返回旧 value:null 表示 key 原本不存在 -> 添加成功。"元素已存在则不动"的语义靠这一个返回值判断
}
public boolean remove(Object o) {
return map.remove(o)==PRESENT; // 同理:删掉的 value 是 PRESENT 说明 key 真的在;用 == 比 != null 更精确
}
// ...
public static <T> HashSet<T> newHashSet(int numElements) { // JDK 19+:传"预期元素数"而不是"容量",替用户算好 size/0.75
if (numElements < 0) {
throw new IllegalArgumentException("Negative number of elements: " + numElements);
}
return new HashSet<>(HashMap.calculateHashMapCapacity(numElements));
}
}反序列化是这个类里唯一”自己写了逻辑”的地方,核心是防御恶意字节流:
代码块收起展开
// 基于本地 JDK 源码 (D:/1ForCode/JAVA_Source, java.base, 2024 版), java.util.HashSet
@java.io.Serial
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
// Consume and ignore stream fields (currently zero).
s.readFields();
// Read capacity and verify non-negative.
int capacity = s.readInt();
if (capacity < 0) {
throw new InvalidObjectException("Illegal capacity: " +
capacity);
}
// Read load factor and verify positive and non NaN.
float loadFactor = s.readFloat();
if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
throw new InvalidObjectException("Illegal load factor: " +
loadFactor);
}
// Clamp load factor to range of 0.25...4.0.
loadFactor = Math.clamp(loadFactor, 0.25f, 4.0f); // 流里的负载因子只在合理区间内被信任,防止 0.0001 这种值把表撑爆
// Read size and verify non-negative.
int size = s.readInt();
if (size < 0) {
throw new InvalidObjectException("Illegal size: " + size);
}
// 关键:容量不用流里写的 capacity,而是按 size 重算,保证表至少 25% 满且不超上限
capacity = (int) Math.min(size * Math.min(1 / loadFactor, 4.0f),
HashMap.MAXIMUM_CAPACITY);
// 建表前先向序列化框架申报即将分配的数组大小,超过流的允许上限直接拒绝(防 OOM 攻击)
SharedSecrets.getJavaObjectInputStreamAccess()
.checkArray(s, Map.Entry[].class, HashMap.tableSizeFor(capacity));
// Create backing HashMap
map = (this instanceof LinkedHashSet ?
new LinkedHashMap<>(capacity, loadFactor) :
new HashMap<>(capacity, loadFactor)); // LinkedHashSet 没有自己的 readObject,靠这个 instanceof 复用父类逻辑
// Read in all elements in the proper order.
for (int i=0; i<size; i++) {
@SuppressWarnings("unchecked")
E e = (E) s.readObject();
map.put(e, PRESENT); // 逐个 put 回去,等于重新哈希一遍,不信任流里的桶布局
}
}原理串讲
一次 set.add(e) 的完整链路:HashSet.add 直接调 map.put(e, PRESENT),进入 HashMap 的 hash() 扰动、(n-1) & hash 定桶、链表/红黑树查重那一整套。
如果 key 已存在,HashMap 会替换 value 并返回旧 value(也就是 PRESENT),add 于是返回 false,集合毫无变化;如果 key 不存在,put 返回 null,add 返回 true。
整个 Set 的”去重”语义,就压缩在 ==null 这一个判断上。
代码块收起展开
为什么 value 用一个共享的静态 PRESENT,而不是 null 或者每次 new?用 null 不行,因为 `map.put` 返回 null 时你分不清是"key 不存在"还是"key 存在但 value 是 null",去重判断直接失效;每次 new 则白白多造对象。
static final 的哨兵让所有 HashSet 实例共享同一个引用,`remove` 里 `==PRESENT` 用引用相等判断即可,零额外内存开销。代价是每个 Entry 里存着一个永远用不上的 value 指针,这是”复用 HashMap 不重写哈希表”换来的固定税。
反序列化为什么要重算 capacity 而不用流里写的?因为 writeObject 写出的 capacity 只是”当时的表大小”,而字节流可以被伪造:一个声称 capacity 是几亿、size 却是 3 的流,若照单全收就会分配巨型数组造成拒绝服务。
所以 readObject 只信 size,用 size * (1/loadFactor) 反推一个刚好够用的容量,再经 SharedSecrets.checkArray 向 ObjectInputStream 的过滤器申报 tableSizeFor(capacity) 的真实分配量,让 JEP 290 的反序列化过滤机制有机会在分配前拦截。
元素也是逐个 put 重建,而不是恢复桶结构,顺带解决了”不同 JVM 上 hashCode 不同”导致的布局不可移植问题。
构造器这边唯一值得注意的是 HashSet(Collection c):Math.max(c.size(), 12) 交给 HashMap.newHashMap,后者按 ceil(n / 0.75) 算初始容量,保证随后的 addAll 一次扩容都不触发。
JDK 19 加的静态工厂 newHashSet(int) 把同样的换算暴露给用户,修的是一个经典误区:new HashSet<>(100) 装 100 个元素在到 75 个时就会扩容,因为 100 是桶数不是元素数。
设计取舍
- 组合而不是继承 HashMap:Set 只暴露 8 个左右的委托方法,HashMap 的 Map 专属 API 一个都漏不出去;若用继承,
put(K,V)这类方法会污染 Set 接口。 map字段是包私有而非 private,iterator()等方法留给 LinkedHashSet 直接复用,序列化时又靠instanceof LinkedHashSet一处分支同时服务两个类。writeObject会把 capacity 和 loadFactor 写进流,但readObject基本不信它们:capacity 全部重算,loadFactor 强制夹到 0.25~4.0,体现”序列化字段是输入,不是事实”。- 误区:以为 HashSet 有独立实现。被问到”HashSet 怎么去重”时,答案全在 HashMap 的
putVal里(hash 相等 && (<mark> 或 equals)判同一 key),HashSet 只贡献了 PRESENT 和一个</mark>null。
最后带一句两个兄弟:LinkedHashSet 只是把底层换成 LinkedHashMap 的”有序壳”,靠上面那个 dummy 构造器接入,自己没有一行数据结构代码;TreeSet 则是同样套路的 TreeMap 壳,用红黑树换来有序与 O(log n)。