HashSet底层基于HashMap实现,元素作为key存储,value统一使用静态Object实例PRESENT;其性能、扩容、线程安全性均继承自HashMap,必须重写hashCode()和equals()以保证去重正确性。

HashSet底层就是HashMap,但value用的是同一个Object实例
HashSet不存重复元素,靠的是内部封装的HashMap——它把元素当key存,value统一用一个叫PRESENT的静态Object占位。所以你调add()时,实际在调map.put(e, PRESENT)。
这意味着:HashSet的性能、扩容逻辑、线程安全性,全都继承自HashMap。别以为“只是个集合”就和Map无关,改HashSet容量本质就是在调HashMap的initialCapacity和loadFactor。
-
HashSet构造时传的initialCapacity,会直接传给内部HashMap;传loadFactor也是同理 - 如果往
HashSet里放大量自定义对象,必须重写hashCode()和equals()——否则HashMap找不到key,就等于“重复元素没被去重” -
HashSet不是线程安全的,多线程写入可能破坏内部HashMap结构,抛ConcurrentModificationException或静默出错
add()返回false不一定是重复,也可能是null值被拒绝
HashSet允许存null,但只允许一个。当你连续两次add(null),第二次返回false,看起来像重复,其实是HashMap对null key的特殊处理:它把null固定放在table[0]的链表/红黑树上,且只存一次。
这个行为容易误判成“业务逻辑冲突”,尤其在集合用于状态标记(比如记录已处理ID)时,null混在里面会让排查变困难。
立即学习“Java免费学习笔记(深入)”;
- 如果业务中
null有明确语义(如“未初始化”),建议提前过滤,别依赖HashSet来“容错” - 调试时看到
add()返回false,先检查是不是刚加过null,而不是急着查equals()实现 -
contains(null)是合法操作,返回true或false取决于是否真存过null,不是空指针异常
遍历顺序完全不可预测,别依赖for-each输出顺序
HashSet没有顺序保证,哪怕两次插入相同元素,for-each或iterator()返回的顺序也可能不同。这不是bug,是HashMap桶数组+哈希扰动+链表/红黑树切换共同导致的自然结果。
有人在测试环境看到“好像有序”,是因为小数据量下哈希后恰好落在连续桶里,或者JDK版本、JVM参数影响了哈希算法细节——上线后立刻打脸。
- 需要顺序,请用
LinkedHashSet(按插入顺序)或TreeSet(按自然序/比较器) - 单元测试里断言集合内容时,别用
assertEquals(list, new ArrayList(set)),而要用assertTrue(set.containsAll(expected) && expected.containsAll(set)) - 日志打印
HashSet内容时,如果为可读性临时转ArrayList再sort(),记得注明这只是为了展示,不影响业务逻辑
内存占用比ArrayList高不少,小集合别硬套
一个空HashSet默认初始化16个桶,每个桶是个Node数组引用,加上HashMap自身字段(size、modCount等),基础开销约80字节;而ArrayList空实例才12字节左右。存10个Integer,HashSet可能占300+字节,ArrayList不到200字节。
这不是理论数字——在高频创建短生命周期集合的场景(比如方法内临时去重),差异会放大成GC压力。
- 元素少于5个、且不频繁增删时,用
Arrays.asList()+ 手动contains()查重,往往比新建HashSet更轻量 - 如果确定元素范围小且固定(比如状态码0-9),用
boolean[]或BitSet替代,内存和速度都碾压 -
HashSet的remove()平均O(1),但最坏O(n)(哈希全碰撞),而ArrayList.remove(Object)稳定O(n);别只看大O,要看你的数据分布
真正麻烦的从来不是“怎么用HashSet”,而是没想清楚“为什么非得用HashSet”。哈希计算、桶扩容、对象包装——每一步都在悄悄吃资源。用之前,先问自己:这个去重,真的值得扛这一套机制?










