
Java 中的 HashSet 和 HashMap 到底差在哪很多朋友学到 Java 集合这块都会被 HashSet 和 HashMap 这两个名字绕晕——名字像、用法像甚至你打印出来看里面的数据都长得差不多。尤其是去面试的时候面试官一张嘴就是“说说 HashSet 和 HashMap 的区别”你要是只答一个“线程不安全”或者“一个允许重复一个不允许”基本就凉了。我先说个最关键的认知HashSet 底层其实就是一个 HashMap它自己并没有独立的存储结构。这句话你记住了后面所有区别都能顺出来。那它俩到底怎么分工、底层怎么协作、日常写代码什么时候用哪个、面试怎么答才能让面试官点头这篇我一次性给你捋清楚顺便把我实际踩过的坑也放进来。先说适用范围。这篇文章适合谁正在学 Java 集合的初学者、准备校招社招面试的人、写业务代码但想搞明白集合底层逻辑的工程师。不涉及分布式、不涉及高并发场景就踏踏实实把这两个类的原理和区别说透。1. 整体设计与核心差异1.1 从继承体系看两者关系先画个轮廓。HashMap 和 HashSet 都位于java.util包下但两者的继承路径不太一样HashMap继承AbstractMapK,V实现MapK,V接口。HashSet继承AbstractSetE实现SetE接口。从接口语义上看Map 是“键值对映射”的容器Set 是“不重复元素”的集合。这是最根本的差异HashMap 存的是键值对HashSet 存的是单个对象。但诡异的是你去翻 HashSet 的源码会发现它内部维护了一个HashMapE,Object类型的成员变量所有的 add、remove、contains 操作全是在操作这个 HashMap。// HashSet 源码简化版 public class HashSetE extends AbstractSetE implements SetE, Cloneable, java.io.Serializable { private transient HashMapE,Object map; private static final Object PRESENT new Object(); public HashSet() { map new HashMap(); } public boolean add(E e) { return map.put(e, PRESENT) null; } }你看到了HashSet 添加元素时把元素本身当作 HashMap 的 keyvalue 统一放一个空的 Object 占位对象就是那个PRESENT。因为 HashMap 的 key 是不允许重复的所以 HashSet 的“不重复”特性本质上是借了 HashMap key 的唯一性来实现的。我在给公司新同事讲这个的时候常用一个类比HashMap 是带标签的储物柜每个格子里存东西标签上写名字HashSet 是一排带锁的收纳箱每个箱子里面放一件物品放不进去第二件相同的。实际上 HashSet 就是拿 HashMap 的 key 那排标签来当储物箱用的value 的位置全放同一个哑元。1.2 允许重复与允许空值的对比HashMap 的 key 和 value 都允许为 null但 key 为 null 时只能存一个因为 key 要保证唯一。HashSet 也允许存一个 null 元素因为它的底层 key 就是 HashSet 的元素本身。这里有个很多人忽略的细节HashMap 允许 value 为 null这给 get 操作埋了一个坑。你调用map.get(key)返回 null 的时候没法区分是这个 key 不存在还是这个 key 对应的 value 本来就是 null。MapString, String map new HashMap(); map.put(a, null); System.out.println(map.get(a)); // null System.out.println(map.get(b)); // null但其实 b 不存在如果你要判断 key 是否存在于 HashMap 中一定要用containsKey不要靠 get 的返回值。这个坑我在实际生产环境里踩过当时一个接口返回 null 导致下游 NPE排查了半天才发现是map.get(key) null被当成了“不存在”来处理实际上 value 就是 null。HashSet 就没有这个问题因为它的 value 永远是 PRESENT 占位对象add方法通过map.put(e, PRESENT) null来判断如果返回 null说明之前没有这个 key添加成功如果返回 PRESENT 对象说明 key 已存在添加失败。2. 底层实现原理与存储结构2.1 HashMap 的 bucket、hash 函数与链表/红黑树面试里高频追问的“HashMap 底层实现原理”核心就是三点数组 链表 红黑树、hash 扰动、扩容机制。HashMap 底层是一个NodeK,V[] table数组数组的每个位置叫一个 bucket桶。当你 put 一个键值对时流程是这样的对 key 调用hashCode()得到一个 int 值。对这个 hash 值做一次扰动处理h ^ (h 16)目的是让高 16 位的特征也参与到低 16 位中降低碰撞概率。用(table.length - 1) hash计算桶下标。如果该桶为空直接放入新 Node。如果该桶不为空遍历链表找相同 key找到了就覆盖 value没找到就尾插新 Node。当链表长度超过阈值 8且数组容量达到 64 时链表转红黑树。bucket 里那个 Node 存的是什么是 key、value、hash 值和 next 指针。注意只在 key 的 hash 一样但 key 不相等时才会出现“一个桶里多个 Node”的链表结构。如果两个 key 的 hash 一样且 equals 相等那它们会被视为同一个 keyput 操作是覆盖而不是追加。红黑树是为了解决极端 hash 碰撞下的性能退化问题。链表查找是 O(n)红黑树是 O(log n)。但树化的条件很严格链表的 Node 数量达到 8TREEIFY_THRESHOLD并且整个 table 数组容量不小于 64MIN_TREEIFY_CAPACITY。两个条件都满足才转缺一个就先去扩容。我写个简单示例帮你理解 bucket 里存的数据形态// Node 节点的简化结构 static class NodeK,V { final int hash; final K key; V value; NodeK,V next; }你可以把 bucket 理解成停车场的一个车位。正常情况一辆车停一个位但如果有两辆车 hash 值撞了就顺着 next 指针在这个车位后面拉一根绳子串一串。车位还是那个车位但后面挂着链表。2.2 HashSet 如何复用 HashMap 的存储能力HashSet 的存储能力完全来自内部的 HashMap。你往 HashSet 里 add 元素时本质是往那个 map 里 put 元素value 固定是 PRESENT。于是元素的唯一性由 HashMap 的 key 保证。元素的查找效率由 HashMap 的 hash 数组定位保证。元素的顺序同样是无序的因为 HashMap 的遍历顺序取决于数组下标和链表/树结构不保证插入顺序。这里有个值得玩味的点HashSet 的构造函数可以传入初始容量和负载因子因为构造时它直接 new 了一个对应参数的 HashMap。所以你在优化 HashSet 初始容量时本质上就是在优化内部 HashMap 的 table 大小。// HashSet 指定容量构造 public HashSet(int initialCapacity, float loadFactor) { map new HashMap(initialCapacity, loadFactor); }你可能会问既然 HashSet 内部就是一个 HashMap那为什么不直接用 HashMap 代替 Set因为接口语义不同。Set 表达的是“集合”概念Map 表达的是“映射”概念。你如果写一个方法签名public void doSomething(SetString keys)调用方很明确地知道你要的是一组不重复的元素不关心键值关系。用 HashSet 能提升代码的表达力让阅读代码的人一眼认出设计意图。2.3 从源码角度对比 add 与 put我把两个方法放一起比对一下// HashMap.put 的核心思路 public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } // HashSet.add 的核心思路 public boolean add(E e) { return map.put(e, PRESENT) null; }HashMap 的 put 返回的是被覆盖的旧 value如果 key 不存在则返回 null。HashSet 的 add 返回的是布尔值true 表示集合中原本没有这个元素添加成功false 表示已有相同元素本次添加无效。注意HashMap 的 put 返回 null 有两个语义key 不存在所以没有旧值或者旧值恰好为 null。HashSet 没有这层歧义因为它的 value 永远是 PRESENTput 的返回值要么是 nullkey 不存在要么是 PRESENTkey 已存在。3. 实操要点方法对比与编码陷阱3.1 常用方法对照表我在带新人时常用一张表来帮他们快速记忆两个类的常用操作差异这里也分享给你操作HashMapHashSet添加put(key, value)返回被覆盖的旧值add(element)返回是否添加成功删除remove(key)按 key 删除remove(element)按元素删除查询get(key)查 valuecontains(element)查是否存在判断存在containsKey(key)/containsValue(value)contains(element)遍历entrySet()/keySet()/values()直接遍历元素或使用迭代器大小size()键值对数量size()元素数量清空clear()clear()实际开发里如果想遍历 HashMap 的键值对我推荐用entrySet()效率高于单独遍历 keySet 再逐个 getMapString, Integer map new HashMap(); for (Map.EntryString, Integer entry : map.entrySet()) { String key entry.getKey(); Integer value entry.getValue(); // 业务逻辑 }HashSet 的遍历就简单多了它本身实现了 IterableSetString set new HashSet(); for (String s : set) { // 直接处理元素 }3.2 hashCode 与 equals 的约定最容易被忽略的坑无论是 HashMap 当 key 的元素还是 HashSet 里的元素都严格遵守一个约定equals 相等时 hashCode 必须相等hashCode 相等时 equals 不一定相等。如果你把这个约定打破会出现什么后果最典型的是add 进 HashSet 一个对象然后你去修改这个对象的属性导致它的 hash 值发生了变化但对象已经存放在旧的 bucket 位置。后续你调用 contains 时新的 hash 值定位到另一个 bucket永远找不到它产生内存泄漏式的 bug。下面是我实际遇到过的场景简化成代码供参考class Person { String name; int age; // 假设这里重写了 equals 和 hashCode基于 name 和 age } SetPerson people new HashSet(); Person p new Person(张三, 25); people.add(p); p.age 26; // 修改后 hash 变化了 System.out.println(people.contains(p)); // 极可能返回 false这个问题的根源在于 HashSet 的存储位置由对象的 hash 值决定对象一旦放入集合hash 值就不应该变。所以有两个硬性建议存入 HashSet 或作为 HashMap key 的对象尽量不要是可变的。如果非要用可变对象那把对象加进集合后不要修改会影响 hashCode 的字段。另外重写 equals 时用Objects.equals做字段比较重写 hashCode 时用Objects.hash能省掉一堆手写失误Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; Person person (Person) o; return age person.age Objects.equals(name, person.name); } Override public int hashCode() { return Objects.hash(name, age); }3.3 初始容量和负载因子的经验取值HashMap 和 HashSet 的默认初始容量都是 16默认负载因子都是 0.75。负载因子的含义当元素个数超过容量 * 0.75时触发扩容容量翻倍。0.75 是时间和空间的一个折中官方经过大量测试得出的经验值一般情况下不要动它。但如果你能预估数据量提前指定初始容量能显著减少扩容次数。扩容是什么代价它要重新计算所有已有元素的 bucket 下标全部迁移一遍开销很大。比如你明确知道要存 1000 个键值对就别用默认 16直接new HashMap(1024)或new HashMap(1000)起步。这里有个细节如果你传的是 1000HashMap 会把容量调整为大于等于 1000 的 2 的幂次方也就是 1024。这是为了(length - 1) hash这个位运算能均匀分布length 必须是 2 的幂。提示不要为了省空间把负载因子调成 1.0除非你非常清楚数据分布且对性能不敏感。否则在扩容临界点附近hash 碰撞概率会明显升高链表变长查询性能下降。HashSet 的初始容量优化同理因为它内部就是 HashMap。4. 遍历顺序、性能与并发场景对比4.1 无序不代表随机但你就是不能依赖顺序HashMap 和 HashSet 的遍历顺序都不保证与插入顺序一致。准确地说它们的顺序由 hash、数组容量、碰撞情况共同决定同样的数据在不同的容量下遍历顺序都可能不同。有人会问那 HashMap 的 key 会不会是按 hash 排好序的不会。数组下标是(capacity - 1) hash这个值不是 sorted 的。链表和红黑树各自再维护一个顺序整体输出自然无规律。如果你需要保持插入顺序有两个选择LinkedHashMap按插入顺序迭代内部维护双向链表。TreeMap按键的自然顺序或自定义 Comparator 排序。对应到 Set 侧就是LinkedHashSet和TreeSet。这俩也是面试常客理解起来不难——LinkedHashSet 底层是 LinkedHashMapTreeSet 底层是 TreeMap或者准确地说 TreeMap 的 NavigableMap。4.2 性能对比和什么场景选哪个HashMap 和 HashSet 的操作时间复杂度几乎一样get / put / add / contains / remove平均都是 O(1)。极端情况下大量 hash 碰撞且触发树化退化为 O(log n)。扩容时单次操作可能变慢因为要 rehash 所有数据。那实际选型怎么判断你要存“键 关联数据”比如用户ID对应订单列表必须用 HashMap。你只需要判断“某个元素在不在这批数据里”或者需要对一批对象去重用 HashSet。如果你既要按 key 找对象又要保证 key 不重复HashMap 天然满足不需要额外维护 Set。补充一个小场景业务里需要统计一段文本里出现过的单词种类数最清爽的写法就是遍历单词往 HashSet 里 add最后输出 size。如果你用 HashMap 也能做value 随便放个 true但语义上绕了一圈代码阅读起来费劲。4.3 线程安全对比都在同一个起跑线上HashMap 和 HashSet 都是线程不安全的。多线程并发写轻则数据错乱重则导致 CPU 100%JDK7 的并发扩容可能造成环形链表。虽然 JDK8 修复了环形链表问题但并发安全依然不保证。如果多线程场景必须用 Map优先考虑ConcurrentHashMap分段锁/CAS 实现推荐。Collections.synchronizedMap(map)粗暴加锁性能一般。Set 侧没有并发专用类通常做法是用ConcurrentHashMap.newKeySet()返回一个ConcurrentHashMap.KeySetView本质是 ConcurrentHashMap 的 key 视图线程安全且性能好。SetString concurrentSet ConcurrentHashMap.newKeySet(); concurrentSet.add(a);这个细节日常可能用不到但面试时能答出来会显得你集合这块的系统性不错。5. 面试高频追问与常见问题排查5.1 面试官爱追问的五个细节面试八股里HashMap 和 HashSet 是高频中的高频。我梳理几个常被追问的细节顺序大致按追问深度递增第一问HashMap 的 hash 函数为什么要做扰动如果直接用hashCode()和capacity - 1做与运算当容量比较小时比如 16只有 hashCode 的低 4 位起作用高位信息完全丢失碰撞概率大。扰动函数h ^ (h 16)让高 16 位特征混入低 16 位相当于把高位的随机性“折叠”到低位分布更均匀。第二问为什么树化阈值是 8而不是 5 或 10源码注释里给了一个泊松分布的数据在随机 hash 下链表长度达到 8 的概率约为千万分之六。也就是说正常情况下链表到 8 的长度极其罕见一旦出现说明 hash 分布出了严重问题这时候引入红黑树来兜底性能。阈值 8 本质是对正常情况和极端情况的权衡产物。第三问HashMap 的容量为什么必须是 2 的幂因为取模操作hash % capacity在 capacity 为 2 的幂时可以等价替换为位运算(capacity - 1) hash位运算比取模快得多。另一个原因扩容翻倍后元素的新位置要么在原位置要么在原位置 旧容量只需要看新增的那一位是 0 还是 1rehash 成本极低。第四问HashSet 怎么保证元素不重复不是 HashSet 自己保证的是它内部的 HashMap 以元素为 key 存数据HashMap 的 key 由 hashCode 和 equals 双重判断唯一性。先通过 hashCode 定位 bucket再通过 equals 比较同 bucket 里的元素是否相等。所以 hashCode 和 equals 必须同时正确缺一个都会出问题。第五问HashMap 什么时候触发扩容元素数量大于threshold阈值时触发threshold 容量 × 负载因子。默认容量 16负载因子 0.75threshold 为 12。一个还没存入任何数据的新 HashMap第一次 put 时会先初始化 table 数组在第一次 put 时分配lazy 模式然后计算桶位存放数据。5.2 真实开发中的典型问题排查实录下面记录几个我在实际开发和 Code Review 中遇到的典型问题你可别踩同样的坑。问题一自定义对象放入 HashSet 去重失效。业务里用 HashSet 存了两个字段相同的 User 对象结果两个都进去了。原因User 类只重写了 equals没重写 hashCode。两个对象 equals 相等但 hash 不同被放到了不同 bucketHashSet 根本没有机会调用 equals 比较。解决equals 和 hashCode 必须一起重写用 Objects.equals 和 Objects.hash 最省事。问题二HashMap 的 key 用可变对象导致找不回数据。有同事把 StringBuilder 当 HashMap 的 key存入后又 append 内容之后 get 不到了。原因StringBuilder 的 hashCode 是每次重新计算的key 的内容一变hash 就变新的查找路径的节点不是原来的节点。解决方案key 用不可变对象比如 String、Integer、Long或者你自己写的不可变类。问题三移除元素时抛 ConcurrentModificationException。在遍历 HashSet 时直接调用set.remove(element)会抛并发修改异常因为迭代器检测到集合结构被修改。SetString set new HashSet(); set.add(a); set.add(b); // 错误写法 for (String s : set) { if (s.equals(a)) { set.remove(s); // throw ConcurrentModificationException } } // 正确写法用迭代器 IteratorString it set.iterator(); while (it.hasNext()) { String s it.next(); if (s.equals(a)) { it.remove(); } }HashMap 遍历时也有同样问题map.remove(key)也会抛异常必须用entrySet().iterator()的 remove。问题四HashMap 扩容检测的排查技巧。如果线上发现某接口偶发 STW 式的停顿有一条排查思路是结合 GC 日志和调用链确认是否发生了 HashMap 大规模扩容。如果你能预估数据规模在初始化时给足容量这类问题可以提前规避。生产上我曾经把一个默认容量的 HashMap 改成new HashMap(4096)后接口 P99 上涨明显改善。5.3 用一句话总结存储关系帮你记牢关于 HashSet 和 HashMap 的关系如果你只能记住一句话我建议是HashSet 是 HashMap 的马甲把元素塞进 key用一个哑元占位 value。这一句话能解释 90% 的行为差异。如果你去面试按这个顺序答基本能覆盖 80 分的水平先讲 Map 和 Set 的语义差异再讲 HashSet 内部持有 HashMapadd 时map.put(e, PRESENT)元素作为 key 所以不重复value 统一占位最后补充 null 值、遍历顺序、线程安全以及 hashCode/equals 的约定。最后再给你一个实用小技巧如果你不确定一个类在 HashSet 里能不能正常工作写几行测试代码把数放进去再取出来重复走两遍 equals 和 hashCode 的路径比翻文档快得多。我在本地做原型时经常这么干省了不少 Debug 时间。