目录
前言
在日常编程中,我们经常需要存储和快速查找成对的“键(Key)”和“值(Value)”。例如,根据用户 ID 查找用户信息,根据单词查找其定义,或者缓存计算结果。虽然数组和链表也能完成类似任务,但当数据量变大时,它们的效率往往会急剧下降(查找需要 O(n) 时间)。这时,哈希表(Hash Table) 就闪亮登场了!本文将带您从基础概念到源码实现,全面掌握Java哈希表技术。
一、什么是哈希表?
哈希表(hashmap)是Map集合中的一员,map集合里的数据是键值对,其格式是{key1=value1,key2=value2,...}。Map集合的所有键是不允许重复的,但值可以重复,键和值是一一对应的,每一个键只能找到自己对应的值。
接下来我们来认识哈希表,想象你开了一家杂货铺,里面有1000种商品。当顾客问起一样商品卖多少钱时,你是在一张长长的清单中挨个查找这个商品的名字和价格,还是立刻报出价格。毫无疑问你希望是后者。
从开发的角度来看,前者的做法中,清单是数组,现在我想知道矿泉水的价格是多少,就得遍历数组,看看哪一项商品名称是它,然后就可以得到价格了。这样的方法比较耗时。
而通过哈希表则可以实现根据商品名称立刻得到它的价格。超市的“商品 → 价格”系统就是哈希表,哈希表是一种通过哈希函数将键映射到存储位置的数据结构。
JDK8之前,哈希表=数组+链表
JDK8开始,哈希表=数组+链表+红黑树
其具体实现细节稍后讲解。
二、HashMap 快速上手
2.1创建 HashMap
import java.util.HashMap;
// 1. 创建空HashMap (默认容量16, 负载因子0.75)
HashMap<String, Integer> map1 = new HashMap<>();
// 2. 指定初始容量 (避免频繁扩容)
HashMap<String, Double> map2 = new HashMap<>(32); // 初始容量32
// 3. 指定初始容量和负载因子
HashMap<Integer, String> map3 = new HashMap<>(50, 0.8f); // 容量50, 负载因子0.8
// 4. 从现有Map创建
Map<String, Integer> existingMap = ...;
HashMap<String, Integer> map4 = new HashMap<>(existingMap);
2.2增加元素 (Put)
HashMap<String, String> countries = new HashMap<>();
// 1. 添加键值对
countries.put("CN", "中国");
countries.put("US", "美国");
countries.put("JP", "日本");
// 2. 添加null键和null值 (允许)
countries.put(null, "未知国家");
countries.put("UK", null);
// 3. putIfAbsent(): 仅当键不存在时才插入
countries.putIfAbsent("FR", "法国"); // 会插入
countries.putIfAbsent("CN", "中华人民共和国"); // 不会覆盖(键已存在)
System.out.println(countries);
// 输出: {null=未知国家, CN=中国, FR=法国, UK=null, US=美国, JP=日本}
2.3查询元素 (Get)
// 1. 根据键获取值
String china = countries.get("CN"); // "中国"
String uk = countries.get("UK"); // null (值本身是null)
String unknown = countries.get("DE"); // null (键不存在)
// 2. getOrDefault(): 键不存在时返回默认值
String germany = countries.getOrDefault("DE", "德国"); // "德国"
// 3. 检查键是否存在
boolean hasJapan = countries.containsKey("JP"); // true
boolean hasCanada = countries.containsKey("CA"); // false
// 4. 检查值是否存在
boolean hasChina = countries.containsValue("中国"); // true
boolean hasNull = countries.containsValue(null); // true (UK的值是null)
2.4修改元素 (Update)
// 1. put() 覆盖更新 (键存在时覆盖值)
countries.put("CN", "中华人民共和国"); // 更新中国的值
// 2. replace(K, V): 仅当键存在时才更新
countries.replace("US", "美利坚合众国"); // 成功更新
countries.replace("CA", "加拿大"); // 无效果 (键不存在)
// 3. replace(K, oldValue, newValue): 键值匹配时才更新
countries.replace("JP", "日本", "日本国"); // 成功 (旧值匹配)
countries.replace("UK", "英格兰", "英国"); // 失败 (旧值不匹配)
System.out.println(countries.get("CN")); // "中华人民共和国"
2.5删除元素 (Remove)
// 1. 根据键删除
String removed = countries.remove("JP"); // 返回"日本国"并删除
// 2. 删除键值对 (键+值同时匹配才删除)
boolean isRemoved = countries.remove("UK", null); // true (UK的值是null)
boolean notRemoved = countries.remove("US", "美国"); // false (值已更新为"美利坚合众国")
// 3. 清空整个Map
countries.clear();
System.out.println(countries.size()); // 0
2.6遍历 HashMap
HashMap<String, Integer> population = new HashMap<>();
population.put("北京", 2154);
population.put("上海", 2487);
population.put("广州", 1868);
// 方法1: 遍历键 (keySet)
for (String city : population.keySet()) {
System.out.println("城市: " + city);
}
// 方法2: 遍历值 (values)
for (int people : population.values()) {
System.out.println("人口: " + people + "万");
}
// 方法3: 遍历键值对 (推荐!entrySet)
for (Map.Entry<String, Integer> entry : population.entrySet()) {
System.out.println(entry.getKey() + "人口: " + entry.getValue() + "万");
}
// 方法4: Java 8+ forEach + Lambda
population.forEach((city, people) ->
System.out.println(city + "->" + people + "万")
);
2.7关键方法总结
操作 | 方法 | 重要说明 |
---|---|---|
创建 | new HashMap<>() | 建议预估大小初始化容量(减少扩容开销) |
增/改 | put(key, value) | 键存在时覆盖旧值 |
查 | get(key) | 键不存在返回 null |
getOrDefault(key, default) | 安全获取值,避免空指针 | |
删 | remove(key) | 返回被删除的值 |
remove(key, value) | 键值匹配才删除 | |
遍历 | entrySet() + 循环 | 效率最高的遍历方式 |
forEach() + Lambda | Java 8+ 简洁写法 | |
判空 | isEmpty() | 检查是否为空Map |
大小 | size() | 获取键值对数量 |
三、核心特性与工作原理
3.1哈希表的初始化与扩容机制
哈希表初始化创建一个默认长度16的数组,默认加载因子为0.75
哈希表容量总是2的幂次方(16, 32, 64...),当指定非2的幂次容量时,HashMap会自动调整为大于该值的最小2的幂,例如指定为10,实际会被调整为16。
// 1. 默认初始化:容量16,加载因子0.75
HashMap<String, Integer> defaultMap = new HashMap<>();
// 2. 指定初始容量
HashMap<String, String> sizedMap = new HashMap<>(32); // 容量32,加载因子0.75
// 3. 指定初始容量和加载因子
HashMap<Integer, String> customMap = new HashMap<>(50, 0.6f);
// 容量50,加载因子0.6,实际容量为64
加载因子是扩容触发阈值的计算因子:阈值 = 容量 × 加载因子,当元素数量 > 容量 × 加载因子时触发扩容,扩容过程如下:
-
创建新数组:容量扩大为原数组的2倍
-
重新计算位置:遍历所有元素重新计算索引
-
迁移元素:将元素转移到新数组
-
更新参数:设置新阈值 = 新容量 × 加载因子
加载因子 | 空间利用率 | 查找性能 | 扩容频率 | 适用场景 |
---|---|---|---|---|
0.5 | 低 | 高 | 高 | 查询密集型应用 |
0.75 | 中 | 中 | 中 | 通用场景(默认) |
0.9 | 高 | 低 | 低 | 内存敏感型应用 |
3.2 哈希函数:键到索引的魔法转换
哈希表的核心在于哈希函数,它负责将任意大小的键(Key)转换为固定范围的数组索引:
index = hash(key) % array_size
在Java的HashMap中,哈希转换过程经过精心设计:
// Java 8+ 的哈希优化实现
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
关键设计点:
-
高位参与运算:通过
^ (h >>> 16)
让高位参与哈希计算,减少冲突 -
空键处理:
null
键始终映射到索引0位置 -
最终索引计算:
(n - 1) & hash
(n为桶数组大小)
3.3哈希冲突解决策略
当不同键产生相同哈希值(即相同数组索引)时,称为哈希冲突。HashMap采用分层策略解决:
第一阶段:数组+链表
1. 创建一个默认长度16的数组,默认加载因子为0.75,数组名table
2. 使用元素的哈希值对数组的长度做运算计算出应存入的位置
3. 判断当前位置是否为null,如果是null直接存入
4. 如果不为null,表示有元素,则调用equals方法比较,相等,则不存;不相等,则存入数组
- JDK 8之前,新元素存入数组,占老元素位置,老元素挂下面
- JDK 8开始之后,新元素直接挂在老元素下面
第二阶段:数组+链表+红黑树
JDK8开始,当链表长度超过8,且数组长度>=64时,自动将链表转成红黑树。