hashset.add()本质是hashmap.put(key, present),去重由hashmap决定;add(null)合法因hashmap允许null键;自定义类须正确重写equals()和hashcode()且保持一致,否则去重失效。

HashSet.add() 本质是 HashMap.put(key, PRESENT)
HashSet 的 add() 方法不自己管理元素去重,而是把元素当 key 存进内部的 HashMap,value 固定用一个静态 PRESENT 对象(new Object())。所以“去重”逻辑完全由 HashMap.put() 决定:如果 key 已存在,就覆盖旧 value 并返回原 value;否则插入并返回 null。HashSet 正是靠这个返回值是否为 null 来判断是否新增成功。
常见错误现象:add() 返回 false 却以为是异常——其实只是元素已存在,这是正常行为,不是 bug。
-
HashSet的线程不安全,多线程调用add()可能导致数据丢失或死循环(尤其在扩容时) - 自定义类必须正确重写
equals()和hashCode(),否则即使逻辑相等的两个对象也会被当成不同元素存入 -
PRESENT是个哑值,不参与业务逻辑,也不建议反射修改它
为什么 add(null) 能成功,但 HashMap.put(null, v) 也能存?
HashMap 允许 null 作为 key(放在桶数组索引 0 的位置),所以 HashSet.add(null) 实际调用的是 map.put(null, PRESENT),合法且只允许一个 null。这和 ArrayList 或 LinkedList 不同,后两者对 null 完全无感,而 HashSet 把 null 当作一个特殊但合法的 hashable 值。
使用场景:需要表示“未设置”或“空状态”又想保持集合语义时,null 是可接受的成员。
- 注意:若后续用
stream().filter(Objects::nonNull)过滤,null会被剔除,别误以为它“不存在” - Guava 的
ImmutableSet明确禁止null,JDK 的HashSet则允许——这点在迁移或封装时容易踩坑 -
ConcurrentHashMap不允许nullkey/value,所以ConcurrentHashSet(基于它构建)也不能存null
add() 触发扩容时,HashMap 的 rehash 如何影响 HashSet 行为?
当 HashSet 元素数超过 capacity × loadFactor(默认 0.75),底层 HashMap 会扩容并 rehash 所有 key。这个过程是全量复制,期间 add() 可能阻塞,且所有迭代器失效(ConcurrentModificationException)。
性能影响明显:10 万元素插入末期,单次 add() 耗时可能从纳秒级跳到毫秒级。
- 初始容量设太小(如默认 16)会导致频繁扩容,建议预估大小后用
new HashSet(expectedSize) - rehash 不改变元素逻辑顺序,但物理存储位置全变,所以
iterator()遍历顺序不可靠(不要依赖插入顺序) - 如果用
LinkedHashSet,它内部用LinkedHashMap,rehash 仍保持插入序,但代价更高
为什么重写 hashCode() 必须和 equals() 保持一致?
HashSet.add() 先算 hashCode() 定位桶,再用 equals() 比较同桶内所有元素。如果两个对象 equals() == true 但 hashCode() 不同,它们会被分到不同桶里,add() 就无法识别重复,导致逻辑错误。
典型错误代码:hashCode() 只基于 id 字段,equals() 却比较 name + age —— 插入两个 name/age 相同但 id 不同的对象,结果都被存进去了。
- IDE 自动生成的
hashCode()/equals()通常可靠;手写时务必保证:相等对象的哈希值一定相等 - 字段选错也危险:比如用可变字段(如
String content)算hashCode(),之后改了内容,对象就再也找不到了(因为桶位置变了) - 空字段要统一处理:
Objects.hash(field1, field2)自动把null当 0,比手写更稳妥
最常被忽略的是:重写了 hashCode() 却忘了同步更新 equals(),或者用了 Lombok 但没加 @EqualsAndHashCode 注解——这时候 add() 看似正常,实际去重失效,问题往往延后暴露。










