HashSet

HashSet 源码分析

HashSet 本身几乎没有算法:它把一个 HashMap 包成 Set,元素当 key,value 统一填一个哨兵对象。去重、扩容、哈希扰动、树化全是 HashMap 的事,这个类只负责”把 Map 语义翻译成 Set 语义”。

代码块JAVA · 65 行收起展开
// 基于本地 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));
    }
}

反序列化是这个类里唯一”自己写了逻辑”的地方,核心是防御恶意字节流:

代码块JAVA · 49 行收起展开
// 基于本地 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 这一个判断上。

代码块JAVA · 2 行收起展开
为什么 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)。

延伸阅读