
做技术分享这些年「Java 中的 HashSet 和 HashMap 有什么区别」几乎是我在每一场 Java 面试里都会听到的问题。我自己当面试官时爱问去外面被面也常被问到。大多数候选人能脱口而出「一个存键值对一个存唯一元素」但只要你追问一句「HashSet 底层到底是怎么实现的为什么它能保证不重复」很多人就卡住了。卡壳的人往往不是不努力而是把这两个类孤立地背了没有意识到它们骨子里本就是一家人。这篇文章不打算让你背答案而是把 HashSet 和 HashMap 从底层结构、存储逻辑、API 操作、面试考点、实践选型五个维度拆开讲透。看完你不仅能说清「有什么区别」还能说清「为什么会有这些区别」顺便把 hashCode、equals、扩容、红黑树这些高频考点一起串起来。适合正在准备 Java 面试的同学也适合工作一两年的开发者查缺补漏。1. 底层结构先搞清楚这两个类的地基1.1 HashMap 的存储骨架数组 链表 红黑树先说 HashMap。JDK 8 之后HashMap 的底层结构是 Node 数组 链表 红黑树的组合。Node 数组一般叫 table每个数组位置叫一个桶bucket桶里装的是 Node 节点。一个 Node 节点包含四个字段hash、key、value、next。这里要特别注意热搜词里有个问题是「hashmap bucket 桶存的是什么」答案就是 Node 节点——更准确地说桶里可能是一个链表头节点也可能是一棵红黑树的根节点。put 一个键值对的流程是这样的先根据 key 的 hashCode 算出所在桶的下标然后从这个桶的链表头开始逐个找。如果找到相同 key 就覆盖 value找不到就在链表尾部插入新节点。JDK 8 用的是尾插法新节点挂在链表末尾这一点在讲并发问题时会再次提到。如果桶里已经是一棵红黑树就按树的方式插入。生活化一点理解把数组想象成一栋楼的信箱格格子编号就是桶下标。每个格子原本只能放一封信但 hash 冲突导致多封信要放进同一个格子时就退而求其次用一根绳子把信串起来塞进同一个格子里。取信的时候先找到格子再顺着绳子一封一封比对。这根绳子就是链表。1.2 链表到红黑树的升级条件当链表长度达到 8并且 table 容量达到 64 之后链表会转成红黑树。这两个条件为什么是「并且」而不是「或」因为如果 table 本身太小桶里挤是正常的这时候直接扩容让元素散得更开比树化更划算。数组扩容只需要重新算下标把节点挂到新数组上代价相对可控而树化本身要付出额外的时间和空间成本TreeNode 大约比普通 Node 多占一倍内存。那阈值为什么是 8源码注释里解释过在负载因子 0.75、hash 分布足够随机的理想情况下桶中链表长度达到 8 的概率大概是千万分之六。换句话说正常场景下你几乎不会遇到树化真遇到了八成是 hash 函数出了问题或者数据被恶意构造。面试被问到这一点时能把原理说出来而不是只报两个数字会给人完全不同的印象。1.3 HashSet 的「借壳」真相肚子里装的就是 HashMap现在看 HashSet。直接打开 JDK 源码最显眼的是这两个字段private transient HashMapE, Object map; private static final Object PRESENT new Object();HashSet 的所有能力几乎是委托给 HashMap 完成的。add 一个元素内部调用 map.put(e, PRESENT)remove 一个元素内部调用 map.remove(e)。HashMap 的 key 是唯一的而 HashSet 又恰好需要元素唯一所以元素直接被当作 key 存进去了value 则统一填一个占位用的 PRESENT 对象。把这个关系记牢后面很多问题都能迎刃而解为什么 HashSet 不允许有重复元素因为 HashMap 不允许重复 key。为什么 HashSet 允许 null因为 HashMap 允许一个 key 为 null。为什么 HashSet 无序因为 HashMap 的遍历顺序就是桶的下标顺序。所谓「区别」底层其实是「同一套东西的两种投影」。2. 存储逻辑与判重机制同一套灵魂两种使用方式2.1 存储内容KV 对 vs 单一元素这是最表层也是最常被背出来的区别。HashMap 存储的是完整的键值对get 的时候根据 key 取出 valueHashSet 只存储单一元素add 的时候只关心这个元素在不在集合里。但为什么 HashSet 只存元素却还能正常工作因为它把元素放到 key 的位置上value 永远是一个共享的 PRESENT 占位对象。这带来一个有意思的细节HashSet 底层那个 HashMap 的 value 字段自始至终都指向同一个 Object 引用所以对 HashSet 来说 value 是冗余的纯粹为了复用 HashMap 机制而存在。用大白话讲你可以把 HashSet 理解成一个 HashMap 的「半成品」HashMap 能存 KVHashSet 把 V 固定成一个哑值只把 K 暴露给使用者。理解了这一点你在看源码的时候就会有一种「原来如此」的顺畅感而不是一行行硬啃。2.2 判重逻辑hashCode 定位 equals 确认无论是 put 还是 add判重逻辑都是同一套套路先调对象的 hashCode()经过扰动函数算出桶下标。如果这个桶是空的直接放入判定为「无重复」。如果桶里已经有节点就逐个调 equals() 与已有 key 比较。这里引出一个必须刻在脑子里的约定两个对象如果 equals 相等那它们的 hashCode 必须相同hashCode 相同equals 却不一定相等。前者保证能找到同一个桶后者允许冲突存在。如果你重写了 equals 却没重写 hashCode让两个相等的对象算出不同的桶下标那它们根本不会被比较到集合里就会出现「灵魂上重复」但物理上并存的元素去重直接变成笑话。关于扰动函数JDK 8 里 HashMap 的 hash 方法是这样的static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }下标计算是(n - 1) hashn 是数组长度当 n 是 2 的幂时这个位运算等价于 hash 对 n 取模。问题在于如果 hash 值只有低位在变化高位从来不参与冲突会非常集中。把高 16 位和低 16 位异或一下高位的信息也能影响最终下标让分布更均匀。这个细节经常被当作加分题来问答得上来很加分。2.3 对 null 的态度HashMap 允许一个 key 为 null并且可以有任意多个 value 为 null。key 为 null 时hash 值会特殊处理上面 hash 方法第一行就是key null ? 0 : ...也就是说 null key 固定被放进 0 号桶所以 HashMap 最多只可能有一个 null key。HashSet 同样允许一个 null 元素因为它是把元素当作 key 存的那 null 元素也会被放到 0 号桶且只允许一个。这一点在源码里体现得很直接也是面试官喜欢顺手问的细节。记的时候不用单独背你只要清楚 HashSet 底层是 HashMapnull 的规则自然就出来了。3. API 操作与性能特征从使用角度入手对比3.1 API 方法速查表很多同学会把 HashMap 的 put 和 HashSet 的 add 记混其实只要记住「主语」不同就不会错HashMap 操作的是键值对HashSet 操作的是元素。操作HashMapHashSet新增put(key, value)add(e)删除remove(key)remove(e)查询get(key)contains(e)是否包含containsKey(key) / containsValue(value)contains(e)大小size()size()遍历entrySet() / keySet() / values() / forEach()iterator() / forEach()清空clear()clear()注意一个容易混淆的点HashMap 判断是否包含某个 key 用 containsKey判断是否包含某个 value 用 containsValueHashSet 只有 contains它内部调用的其实是 map.containsKey(e)。毕竟元素在 HashSet 里是 key不是 value。3.2 遍历差异与迭代器细节HashMap 有三种视角可以遍历key 集合、value 集合、KV 条目集合。通常推荐遍历 entrySet因为一次能拿到 KV 两个值不需要在遍历过程中再调一次 get少一次哈希计算。在 KV 都需要的业务场景下这个性能差异会被放大得非常明显。HashSet 没有 value 的概念只能遍历元素本身方式就是增强 for、Iterator、forEach 那几种。这里有个值得注意的坑如果你用 keySet() 或 entrySet() 遍历 HashMap在遍历过程中直接调 map.remove(key) 删除元素会触发 fail-fast 机制抛 ConcurrentModificationException。在 HashSet 里用 Iterator 遍历时想删除元素必须调用 iterator.remove()这是唯一安全的遍历中删除方式。我在一次写数据同步任务时就用 for-each 遍历 HashSet 然后调 set.remove结果运行到一半直接抛异常当时排查了半天才意识到是这个原因。后来养成了习惯凡是遍历中要删东西的一律用 Iterator或者干脆先收集要删的元素遍历结束后统一删。3.3 容量、负载因子与扩容两者底层是一家所以初始容量和负载因子的规则完全一致。默认初始容量是 16负载因子是 0.75。当元素个数超过「容量 × 负载因子」这个阈值时触发扩容容量翻倍所有元素重新散列。0.75 这个值不是拍脑袋定的是时间与空间的折中。负载因子调高比如 1.0内存利用率高但冲突概率增大查询变慢调低比如 0.5查询更快但浪费空间扩容也更频繁。源码注释里专门讨论过0.75 是经过统计权衡后的经验值。日常开发里如果预先知道大概会存多少数据最好直接指定初始容量。比如预期数据量是 100 万可以这样算1000000 / 0.75 1结果约 1333334用这个值作为初始容量。为什么要加 1因为除法结果可能落在两个整数之间直接向下取整可能导致容量不够提前触发扩容加 1 留个余量。扩容反复 rehash 的代价非常大提前规划容量是性价比极高的优化。4. 面试高频考点与实战避坑这些坑我替你踩过4.1 自定义对象作 key不重写 hashCode 和 equals 等于白写这是最常见的追问「如果用自定义对象做 HashSet 的键会有什么问题」答案是不重写 hashCode 和 equals 的话集合判重用的是 Object 默认实现——即对象地址。两个属性完全一样的对象只要不是同一个实例equals 就是 falsehashCode 也可能不同于是两个「内容相同」的对象都能往集合里塞。我带新人的时候就见过这种 bug用一个业务对象去重忘了重写这两个方法结果线上集合里塞满了「看起来一样」的数据。举个例子public class User { private String name; // 只重写了 equals没重写 hashCode Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof User)) return false; return Objects.equals(name, ((User) o).name); } } SetUser set new HashSet(); set.add(new User(a)); set.add(new User(a)); System.out.println(set.size()); // 很可能是 2解决办法很简单equals 和 hashCode 一起重写且 equals 中用到的字段hashCode 也要用到保持一致。IDE 的自动生成功能基本够用关键是别只重写其中一个。4.2 可变对象放进集合后别改会影响 hash 的字段这个坑我踩过很重的一次。当时把一个对象放进 HashSet 做临时去重后来业务上又更新了这个对象的一个字段而那个字段刚好参与 hashCode 计算。结果对象在集合里的桶位置直接失效contains 返回 false集合里还能再次加入「本应重复」的对象。排查了很久最后才怀疑到对象被修改上。所以原则是放进 HashSet、或者作为 HashMap 的 key 之后对象就不要再修改影响 hashCode 的字段了。如果确实需要改先把对象从集合里删除改完再重新加进去。很多框架喜欢用不可变对象作为 key一个重要原因就是它们不会被意外改坏。4.3 HashMap 并发不安全从死循环到数据覆盖被问「HashMap 是不是线程安全的」时标准回答是「不是」。但更值钱的回答是讲清为什么。JDK 7 时代头插法加扩容会让链表在并发场景下形成环get 一个不存在的 key 时可能死循环CPU 直接被打满。到了 JDK 8 改成尾插法环的问题基本解决了但并发 put 依然可能丢数据两个线程同时触发扩容时可能互相覆盖对方刚写进去的节点size 字段也不是原子的统计值会乱。HashSet 底层是 HashMap所以它同样线程不安全。并发场景需要加锁用 Collections.synchronizedSet 包一层或者直接用 ConcurrentHashMap / 并发集合。你只要先明白「HashSet 的人以 HashMap 为底层」这类问题的答案自己就出来了。4.4 别只背区别把 LinkedHashSet、TreeSet 一起串起来面到集合这里面试官往往会追问有序性问题。HashMap 和 HashSet 都是无序的因为遍历顺序完全取决于桶下标而桶下标由 hash 决定。如果需要按插入顺序遍历用 LinkedHashMap 和 LinkedHashSet它们在 HashMap 结构之上多维护了一条双向链表代价是每次插入要多维护几个指针内存占用也略高。如果需要按 key 排序用 TreeMap 和 TreeSet底层是红黑树Map 按 key 的自然顺序或自定义 Comparator 排序Set 按元素大小排序。回答「HashMap 和 HashSet 有什么区别」时最好主动带上这些兄弟类把自己的知识图谱亮出来这个问题就从简单记忆题变成了加分题。5. 实际开发中的选型建议什么时候用哪个5.1 从需求反推去重、缓存、计数与顺序我习惯从一个需求反推选择。如果需求是「只要判断某个东西在不在集合里」比如过滤重复的用户 ID、记录黑名单就用 HashSet存储占用小逻辑清晰。如果需求是「一个 key 对应一个 value需要根据 key 取数据」比如缓存用户资料、统计单词出现次数就用 HashMapkey 是查询条件value 是结果。举个例子统计一篇文章里每个单词出现的次数用 HashMapString, Integer 就非常自然遍历单词get 一下没有就 put 1有就 put count 1。而如果只是给一批用户 ID 去重你根本不需要存 valueHashSet 一行代码搞定。选型不是越复杂越好而是越贴合需求越好。如果要求按插入顺序或处理顺序做业务考虑 LinkedHashSet 或 LinkedHashMap。如果要求排序输出或范围查询比如「按分数区间取出所有学生」用 TreeMap 或 TreeSet 更省事。每类集合都有自己的适用场景背熟它们之间的继承关系和底层差异选型时才有底气。5.2 如果数据规模很大预估初始容量和避免大对象数据量大时提前预估容量的效果非常明显。默认 16 的容量在数据量达到 100 万时扩容次数大约是 log2(100 万 / 16)约 16 次每次扩容都要重新散列全部节点成本惊人。提前用预期数据量除以负载因子再加 1 来指定初始容量可以把这种开销降到最低。HashSet 构造时同样可以传容量它会把参数转发给内部的 HashMap。另外别把超大对象或者重 equals 的对象放作 key。hash 计算和 equals 比较的成本会随对象复杂度上升如果比较逻辑里还有遍历那 put 和 get 的耗时就会变得非常难看。能用基本类型包装类或 String 作 key就用这些轻量类型这是我在做高并发缓存时得出的实际经验。我个人在实际面试和带人过程中的体会是这道题最忌讳只背两层皮的区别。你把「HashSet 底层是 HashMap」这层关系点破再把 hashCode 和 equals 的约定讲清楚顺带说一下扩容和并发问题面试官基本就不会再把这道题当作简单记忆题来考。如果对方想继续深挖往往也是顺着你的话头去问红黑树、负载因子或者 LinkedHashMap这些前面都覆盖到了属于可以继续延伸的方向。最后再分享一个小技巧动手写代码时我习惯把 HashMap 和 HashSet 理解成「同一个钱包的正反面」——想存一个 key 一个 value用 HashMap只想确认一堆东西里有你没你用 HashSet。两者都不保证顺序需要顺序就去 Linked 系列找需要排序就去 Tree 系列找。Java 集合框架本身设计得并不复杂复杂的是把每个类的底层关系串起来理解串起来之后很多面试题和你自己的 bug 都会变得特别好排查。