HashMap 与 HashSet
在 Java 集合框架中,HashMap 和 HashSet 是日常开发中出镜率极高的两个类。它们之所以受欢迎,核心在于高效的动态查找能力—— 相比传统的遍历(O (N))或二分查找(依赖有序序列),这两个集合能以接近 O (1) 的时间复杂度完成插入、删除和查找操作。。
一、哈希表
要理解 HashMap 和 HashSet,必须先搞懂它们的底层实现 ——哈希表(Hash Table)。哈希表的本质是一种 “键 - 地址映射” 结构,通过一个「哈希函数」将关键码(Key)直接映射到存储地址,从而实现 “一次定位” 的高效查找。
1.1 哈希表的核心逻辑
举个简单例子:我们要存储一组数字 {1,4,5,6,9,44},选择哈希函数为 hash(key) = key % 10(除留余数法,最常用的哈希函数之一),哈希表底层数组长度为 10。
- 计算每个 key 的哈希地址:
hash(1)=1、hash(4)=4、hash(44)=4... - 直接将 key 存到对应的数组索引位置:1 存到索引 1,4 存到索引 4,44 也该存到索引 4—— 这就引出了 “哈希冲突”。
1.2 哈希冲突:不可避免,但可优化
什么是哈希冲突?
不同的 Key 通过哈希函数计算出相同的地址(比如 4 和 44 都映射到索引 4),这种现象就是哈希冲突。冲突无法完全避免(因为数组容量有限,Key 无限),但我们可以通过两种方式降低冲突影响:
1. 冲突避免:从源头减少冲突
- 合理设计哈希函数:优先选择「除留余数法」(取一个接近数组长度的质数作为除数),保证 Key 分布均匀。
- 控制负载因子:负载因子(
负载因子 = 元素个数 / 数组长度)是哈希表的 “松紧度” 指标。负载因子越大,数组越满,冲突率越高;反之则空间浪费越多。Java 中 HashMap 的默认负载因子是 0.75—— 这是团队权衡空间与性能的结果:当元素个数超过数组长度 * 0.75时,就会触发「扩容」(数组长度翻倍),从而降低负载因子,减少冲突。
2. 冲突解决:哈希桶(数组 + 链表 / 红黑树)
Java 采用「哈希桶(开散列)」解决冲突:哈希表底层是数组,每个数组元素(称为 “桶”)对应一个链表;当发生冲突时,将冲突的 Key 以链表节点的形式挂在同一个桶下。
如果某个桶的链表过长(JDK 1.8 后阈值为 8),且数组长度 >= 64,链表会自动转为红黑树—— 因为链表查询是 O (N),而红黑树是 O (logN),能大幅提升极端场景下的性能。
public class HashBucket {//哈希桶节点(存储Key-Value)private static class Node {private int key;private int value;Node next; //链表指针(解决冲突)public Node(int key, int value) {this.key = key;this.value = value;}}private Node[] array; //底层数组(哈希桶)private int size; //当前存储的元素个数private static final double LOAD_FACTOR = 0.75; //负载因子阈值(文档1-160)private static final int DEFAULT_CAPACITY = 8; //默认初始容量//构造方法:初始化哈希桶public HashBucket() {array = new Node[DEFAULT_CAPACITY];size = 0;}/*** 插入/更新Key-Value* @param key 待插入key* @param value 待插入value* @return 若key已存在,返回旧value;否则返回-1*/public int put(int key, int value) {//1. 计算哈希地址(除留余数法,文档1-139)int index = key % array.length;//2. 检查桶中是否存在该key(存在则更新value)Node cur = array[index];while (cur != null) {if (cur.key == key) {int oldValue = cur.value;cur.value = value; //更新valuereturn oldValue;}cur = cur.next;}//3. 不存在该key,头插法插入新节点(解决冲突)Node newNode = new Node(key, value);newNode.next = array[index];array[index] = newNode;size++;//4. 检查负载因子,超过阈值则扩容if (loadFactor() >= LOAD_FACTOR) {resize();}return -1;}/*** 根据key获取value* @param key 目标key* @return 找到返回value,否则返回-1*/public int get(int key) {//1. 计算哈希地址int index = key % array.length;//2. 遍历桶中的链表查找keyNode cur = array[index];while (cur != null) {if (cur.key == key) {return cur.value;}cur = cur.next;}return -1; //未找到}/*** 计算当前负载因子*/private double loadFactor() {return size * 1.0 / array.length;}/*** 扩容:数组长度翻倍,重新哈希迁移节点*/private void resize() {//1. 创建新数组(容量翻倍)Node[] newArray = new Node[array.length * 2];//2. 遍历旧数组,迁移每个桶的节点到新数组for (int i = 0; i < array.length; i++) {Node cur = array[i];while (cur != null) {Node next = cur.next; //保存下一个节点(防止链表断裂)//重新计算当前节点在新数组中的索引int newIndex = cur.key % newArray.length;//头插法插入新数组cur.next = newArray[newIndex];newArray[newIndex] = cur;cur = next; //处理下一个节点}}//3. 替换数组引用array = newArray;}//测试方法public static void main(String[] args) {HashBucket hashBucket = new HashBucket();//插入数据(包含冲突场景:4和44哈希地址相同)hashBucket.put(4, 40);hashBucket.put(44, 440);hashBucket.put(5, 50);hashBucket.put(14, 140);//测试获取System.out.println("get(4):" + hashBucket.get(4)); //40System.out.println("get(44):" + hashBucket.get(44)); //440System.out.println("get(14):" + hashBucket.get(14)); //140System.out.println("get(20):" + hashBucket.get(20)); //-1(不存在)//测试更新hashBucket.put(4, 400);System.out.println("更新后get(4):" + hashBucket.get(4)); //400}
}
二、HashMap:键值对的高效存储方案
HashMap 是 Map 接口的实现类,专门用于存储「Key-Value 键值对」,是开发中存储关联数据的首选。
2.1 HashMap 的核心特性
- Key 唯一性:同一个 HashMap 中,Key 不能重复(若插入相同 Key,会覆盖旧 Value 并返回旧值)。
- Value 可重复:不同 Key 可以对应相同 Value。
- null 允许:Key 和 Value 都可以为 null(注意:Key 最多只能有一个 null,Value 可以有多个)。
- 无序性:存储顺序不保证与插入顺序一致(底层哈希表是无序的)。
- 线程不安全:多线程环境下操作可能出现并发问题(需用
ConcurrentHashMap替代)。
2.2 底层实现:数组 + 链表 / 红黑树
HashMap 的底层结构可以概括为 “数组承载桶,桶下挂链表 / 红黑树”:
- 数组:称为「哈希桶数组」,每个元素是链表 / 红黑树的头节点。
- 链表:解决哈希冲突,存储相同哈希地址的 Key-Value 对。
- 红黑树:优化长链表的查询性能(当链表长度 > 8 且数组长度 >= 64 时转换)。
2.3 关键流程:put 与 get 是如何工作的?
1. put 方法:插入键值对
当我们调用 map.put(key, value) 时,底层会经历以下步骤:
- 计算 Key 的哈希值:先调用 Key 的
hashCode()方法得到哈希值,再通过一次 “扰动计算”(减少哈希碰撞)得到最终哈希值。 - 确定数组索引:用最终哈希值对「当前数组长度」取模,得到 Key 对应的桶索引。
- 处理桶内元素:
- 若桶为空:直接新建节点插入桶中。
- 若桶不为空:遍历链表 / 红黑树,检查是否存在相同 Key(通过
equals()比较):- 存在相同 Key:更新 Value,返回旧 Value。
- 不存在相同 Key:新建节点插入链表头部(JDK 1.8 后)或红黑树中。
- 检查扩容:插入后若元素个数超过
数组长度 * 0.75,触发扩容(数组长度翻倍),并重新计算所有节点的索引,迁移数据。
2. get 方法:查询键值对
调用 map.get(key) 时,流程更简单:
- 同 put 步骤 1-2:计算 Key 的哈希值,确定桶索引。
- 遍历桶内的链表 / 红黑树,通过
equals()找到相同 Key 的节点,返回其 Value。 - 若未找到,返回 null。
2.4 常用方法与实战场景
核心方法
| 方法 | 功能描述 |
|---|---|
V put(K key, V value) | 插入 / 更新键值对,返回旧 Value(若 Key 不存在则返回 null) |
V get(Object key) | 根据 Key 获取 Value,不存在则返回 null |
V remove(Object key) | 根据 Key 删除键值对,返回被删除的 Value(不存在则返回 null) |
Set<K> keySet() | 返回所有 Key 的集合(用于遍历 Key) |
Set<Map.Entry<K,V>> entrySet() | 返回所有键值对的集合(推荐用于遍历 Key-Value,效率比 keySet 高) |
boolean containsKey(Object key) | 判断是否包含指定 Key |
实战场景
- 存储用户信息:Key 为用户 ID,Value 为 User 对象。
- 统计单词频次:Key 为单词,Value 为出现次数(插入时判断 Key 是否存在,存在则 Value+1)。
- 缓存临时数据:比如接口请求结果,Key 为请求参数,Value 为响应数据。
2.5 坑点提醒:自定义类作为 Key 的注意事项
如果用自定义类(比如 User)作为 HashMap 的 Key,必须同时覆写 hashCode() 和 equals() 方法,否则会出现逻辑错误。
原因:
hashCode()决定 Key 映射到哪个桶,equals()决定桶内是否为相同 Key。- 若不覆写:默认使用 Object 类的方法 ——
hashCode()返回对象的内存地址,equals()比较内存地址,导致即使内容相同的两个对象也会被视为不同 Key。
正确原则:
- 若
a.equals(b)为 true,则a.hashCode()必须等于b.hashCode()。 - 若
a.hashCode()相等,a.equals(b)不一定为 true(哈希冲突)。
2.6 HashMap使用示例
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;public class HashMapDemo {public static void main(String[] args) {//1. 初始化HashMap(存储<String, Integer>:姓名-年龄)Map<String, Integer> hashMap = new HashMap<>();//2. 插入键值对(文档1-80)hashMap.put("张三", 20);hashMap.put("李四", 22);hashMap.put("王五", 21);hashMap.put("张三", 23); //重复Key,覆盖valueSystem.out.println("HashMap内容:" + hashMap); //{张三=23, 李四=22, 王五=21}//3. 常用方法//获取valueSystem.out.println("张三的年龄:" + hashMap.get("张三")); //23System.out.println("赵六的年龄(默认值):" + hashMap.getOrDefault("赵六", 0)); //0(不存在)//判断是否包含Key/ValueSystem.out.println("是否包含Key李四:" + hashMap.containsKey("李四")); //trueSystem.out.println("是否包含Value21:" + hashMap.containsValue(21)); //true//遍历方式1:遍历所有Key(keySet)System.out.print("遍历Key:");Set<String> keySet = hashMap.keySet();for (String key : keySet) {System.out.print(key + " "); //张三 李四 王五(无序)}System.out.println();//遍历方式2:遍历所有Value(values)System.out.print("遍历Value:");Collection<Integer> values = hashMap.values();for (Integer value : values) {System.out.print(value + " "); // 23 22 21}System.out.println();//遍历方式3:遍历键值对(entrySet,推荐高效)System.out.println("遍历键值对:");Set<Map.Entry<String, Integer>> entrySet = hashMap.entrySet();for (Map.Entry<String, Integer> entry : entrySet) {System.out.println(entry.getKey() + "->" + entry.getValue());}//删除KeyhashMap.remove("王五");System.out.println("删除王五后:" + hashMap); //{张三=23, 李四=22}}
}
三、HashSet:一键去重的 “利器”
HashSet 是 Set 接口的实现类,专门用于存储 “不重复的单个元素”,核心功能是去重。
3.1 HashSet 的核心特性
- 元素唯一性:集合中不会有重复元素(add 重复元素会返回 false)。
- 只存 Key:相比 HashMap,HashSet 只存储单个元素(本质是 HashMap 的 Key)。
- null 允许:最多只能有一个 null 元素。
- 无序性:存储顺序与插入顺序无关。
- 线程不安全:与 HashMap 一致,多线程需谨慎。
3.2 底层秘密:基于 HashMap 实现
很多人不知道,HashSet 其实是 “借壳” HashMap 实现的 —— 它的底层就是一个 HashMap!当我们调用 hashSet.add(element) 时,底层实际执行的是:hashMap.put(element, PRESENT)其中 PRESENT 是一个静态的空 Object 对象(private static final Object PRESENT = new Object()),仅作为占位符使用(因为 HashMap 需要键值对,而 HashSet 只需要 Key)。
这就解释了为什么 HashSet 能去重:因为 HashMap 的 Key 是唯一的,HashSet 的元素本质就是 HashMap 的 Key。
3.3 常用方法与去重原理
核心方法
| 方法 | 功能描述 |
|---|---|
boolean add(E e) | 添加元素,成功返回 true,重复返回 false |
boolean contains(Object o) | 判断集合是否包含元素 o,存在返回 true |
boolean remove(Object o) | 删除元素 o,成功返回 true,不存在返回 false |
int size() | 返回集合中元素的个数 |
void clear() | 清空集合所有元素 |
去重原理
HashSet 的去重逻辑完全依赖 HashMap 的 Key 唯一性:
- 调用元素的
hashCode()计算哈希值,确定 HashMap 的桶索引。 - 遍历桶内元素,调用
equals()比较是否存在相同元素。 - 若存在,add 方法返回 false,不插入;若不存在,插入元素(作为 HashMap 的 Key),返回 true。
因此,自定义类作为 HashSet 元素时,同样需要覆写 hashCode() 和 equals() 方法,否则无法正确去重。
3.4 实战场景
- 列表去重:比如将
List<String> phones转为 HashSet,快速去除重复手机号。 - 判断元素存在:比如检查某个用户 ID 是否在 “黑名单” 集合中。
- 统计唯一值:比如统计日志中出现的唯一 IP 地址。
3.5 自定义类作为 HashMap/HashSet Key(需覆写 hashCode () 和 equals ())
import java.util.HashMap;
import java.util.HashSet;
import java.util.Objects;//自定义类:User(作为Key)
class User {private String id;private String name;public User(String id, String name) {this.id = id;this.name = name;}//自定义Key必须覆写equals()和hashCode()@Overridepublic boolean equals(Object o) {if (this == o) return true;if (o == null || getClass() != o.getClass()) return false;User user = (User) o;return Objects.equals(id, user.id) && Objects.equals(name, user.name);}@Overridepublic int hashCode() {return Objects.hash(id, name);}@Overridepublic String toString() {return "User{id='" + id + "', name='" + name + "'}";}
}//测试自定义Key
public class CustomKeyDemo {public static void main(String[] args) {//1. 自定义Key在HashMap中的使用HashMap<User, String> userMap = new HashMap<>();User user1 = new User("001", "张三");User user2 = new User("001", "张三"); //与user1内容相同,应视为同一KeyuserMap.put(user1, "北京");userMap.put(user2, "上海"); //覆盖user1的valueSystem.out.println("HashMap中User对应的地址:" + userMap.get(user1)); // 上海(证明user1和user2是同一Key)//2. 自定义Key在HashSet中的使用HashSet<User> userSet = new HashSet<>();userSet.add(user1);userSet.add(user2); //重复Key,无法插入System.out.println("HashSet大小:" + userSet.size()); //1(去重成功)System.out.println("HashSet内容:" + userSet); //[User{id='001', name='张三'}]}
}
四、横向对比:理清这些 “Set/Map” 的区别
实际开发中,我们常需要在 HashMap/HashSet 和 TreeMap/TreeSet 之间做选择,这里用表格清晰对比:
4.1 HashMap vs HashSet
| 对比维度 | HashMap | HashSet |
|---|---|---|
| 存储内容 | 键值对(K-V) | 单个元素(K) |
| 底层依赖 | 直接基于哈希表 | 基于 HashMap(Value 占位) |
| 核心功能 | 关联数据存储 | 元素去重 |
| 关键方法 | put、get | add、contains |
| null 允许度 | Key 和 Value 均可为 null | 仅元素(K)可为 null |
4.2 HashMap/HashSet vs TreeMap/TreeSet
| 对比维度 | HashMap/HashSet | TreeMap/TreeSet |
|---|---|---|
| 底层结构 | 数组 + 链表 / 红黑树 | 红黑树 |
| 有序性 | 无序(插入顺序不保证) | 有序(Key 自然排序 / 定制排序) |
| 时间复杂度 | 插入 / 查找 / 删除 O (1) | 插入 / 查找 / 删除 O (logN) |
| Key 要求 | 需覆写 hashCode + equals | 需实现 Comparable 或提供 Comparator |
| null 允许度 | 允许(HashMap Key 可 null) | 不允许(TreeMap Key 不可 null) |
| 适用场景 | 追求高效读写,无需有序 | 需要按 Key 排序的场景 |
总结
- 底层基石是哈希表:HashMap 和 HashSet 都基于哈希表实现,高效性的核心是 “哈希函数映射 + 冲突解决”。
- HashMap 是键值对存储:Key 唯一,Value 可重复,需注意自定义 Key 覆写 hashCode 和 equals。
- HashSet 是去重工具:底层依赖 HashMap,元素即 HashMap 的 Key,去重逻辑与 HashMap Key 唯一性一致。
- 选择依据看需求:需有序用 Tree 系列,需高效用 Hash 系列;存键值对用 Map,存单个元素用 Set。
掌握这些核心逻辑后,再遇到 HashMap 或 HashSet 的问题(比如为什么会有重复元素、为什么查询慢),就能从底层原理出发快速定位原因,真正做到 “知其然,更知其所以然”。
