HashMap 是 Java 中使用频率最高的集合类之一,其 get 方法是我们日常开发中最常用的操作。这篇文章将详细介绍 HashMap 的 get 方法的完整执行过程。
HashMap 的基本结构
在分析 get 方法前,先简单回顾 HashMap 的基本结构:

HashMap 内部使用一个 Node<K,V>[]数组(在 Java 8 之前称为 Entry<K,V>[])存储数据,每个位置称为一个桶(bucket)。当发生哈希冲突时,同一个桶内的元素以链表形式存储,当链表长度超过阈值(默认为 8)且数组长度大于等于 64 时,会转换为红黑树结构。
get 方法源码分析
先看 JDK 8 中 HashMap 的 get 方法源码:
java
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 检查table是否已初始化、长度是否大于0,以及计算的索引位置是否有节点
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // 检查第一个节点
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode) // 如果是红黑树
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 红黑树的查找逻辑相对复杂,此处不展开
do { // 遍历链表
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
公开的get
方法是一个简洁的入口,而复杂的逻辑则被封装在final
(不可覆盖)的包级私有方法getNode
中。这是代码拆分与封装的一个好例子,它将"做什么"(公共 API)与"怎么做"(内部实现)清晰地分离开来。
get 方法的完整执行流程
下面详细分析 HashMap 的 get 方法执行过程:
1. 计算 Key 的哈希值
首先调用hash(key)
方法计算 key 的哈希值:
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这个方法将 key 的 hashCode 值的高 16 位与低 16 位进行异或运算,这样可以减少哈希冲突。JDK 7 的哈希计算过程更为复杂,而 JDK 8 简化为高 16 位异或低 16 位的方式。
特别注意:当 key 为 null 时,其哈希值固定为 0,因此 null 键总是被放在数组的第一个桶(table[0])中。

2. 定位桶位置
用计算出的哈希值与数组长度-1 进行与运算,确定元素在数组中的索引位置:
java
index = (n - 1) & hash
这里的 n 是数组长度,一定是 2 的幂,所以 n-1 的二进制表示全是 1,与 hash 进行与运算相当于取 hash 的低位,确保索引在数组范围内。这个操作等效于hash % n
(当 n 是 2 的幂时),但位运算更高效。
3. 查找元素
定位到桶位置后,getNode
方法会按照以下顺序进行查找,这是一个精心设计的、性能优先的流程:
-
检查是否可查找(Fast Fail):
- 首先检查 table 是否已初始化(
(tab = table) != null
) - 检查数组长度是否大于 0(
(n = tab.length) > 0
) - 检查计算出的桶位置是否有节点(
(first = tab[(n - 1) & hash]) != null
) - 如果以上任一条件不满足,直接返回 null,避免不必要的查找
- 首先检查 table 是否已初始化(
-
检查首节点(Fast Path):
- 直接检查桶中的第一个节点
first
- 如果它的哈希值和 key 都与要查找的 key 匹配,直接返回该节点
- 这是最高效的情况,只需一次比较
- 直接检查桶中的第一个节点
-
检查后续节点:
- 如果首节点不匹配且存在后续节点(
(e = first.next) != null
):- 红黑树结构 :如果
first
是TreeNode
类型,调用getTreeNode
方法在红黑树中查找(O(log k)时间) - 链表结构:否则,遍历链表比较每个节点,直到找到匹配的节点或遍历结束
- 红黑树结构 :如果
- 如果首节点不匹配且存在后续节点(
-
未找到:如果所有步骤都未能找到匹配的节点,返回 null
4. 返回结果
- 如果找到匹配的节点,返回其 value 值
- 如果未找到,返回 null
实际案例
让我们通过一个实例来分析 get 方法的执行过程:
java
import java.util.HashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class HashMapGetDemo {
private static final Logger logger = LoggerFactory.getLogger(HashMapGetDemo.class);
// 定义一个故意产生哈希冲突的键类
static class CollisionKey {
private final String name;
public CollisionKey(String name) {
this.name = name;
}
@Override
public int hashCode() {
return 1; // 故意返回常量,确保所有实例都产生相同的哈希值
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
return name.equals(((CollisionKey) obj).name);
}
@Override
public String toString() {
return name;
}
}
public static void main(String[] args) {
// 创建HashMap
HashMap<String, Integer> map = new HashMap<>();
// 添加元素
map.put("Java", 95);
map.put("Python", 92);
map.put("C++", 89);
// 获取元素
Integer javaScore = map.get("Java");
logger.info("Java的分数:{}", javaScore);
// 获取不存在的元素
Integer goScore = map.get("Go");
logger.info("Go的分数:{}", goScore);
// 演示null键
map.put(null, 100);
Integer nullScore = map.get(null);
logger.info("null的分数:{}", nullScore);
// 演示安全处理与日志记录
String nonExistentKey = "Ruby";
Integer rubyScore = map.get(nonExistentKey);
if (rubyScore != null) {
logger.info("{}的分数是: {}", nonExistentKey, rubyScore);
} else {
// 使用warn级别记录未找到的情况,这在排查问题时很有用
logger.warn("未找到键 '{}' 对应的分数。", nonExistentKey);
}
// 演示因未检查null而可能引发的异常
try {
int score = map.get("Go"); // map.get("Go")返回null
logger.info("Go的分数(拆箱后): {}", score);
} catch (NullPointerException e) {
// 正确做法:将异常对象作为最后一个参数传递,由日志框架负责打印堆栈信息
logger.error("尝试对一个null值进行自动拆箱时发生异常!key: 'Go'", e);
}
// 演示哈希冲突
logger.info("\n--- 演示哈希冲突场景 ---");
HashMap<CollisionKey, String> collisionMap = new HashMap<>();
collisionMap.put(new CollisionKey("key1"), "value1");
collisionMap.put(new CollisionKey("key2"), "value2"); // 与key1冲突
collisionMap.put(new CollisionKey("key3"), "value3"); // 与key1, key2冲突
// 此时get("key2")会触发链表遍历
String value = collisionMap.get(new CollisionKey("key2"));
logger.info("获取冲突键'key2'的值: {}", value);
// 此处会先定位到桶,比较key1(失败),再比较key2(成功),返回value2
}
}
当执行map.get("Java")
时,流程如下:
- 计算"Java"的哈希值
- 确定桶位置
- 在该位置查找键为"Java"的节点
- 找到后返回值 95
对于map.get("Go")
,因为 Map 中不存在"Go"这个键,所以返回 null。
对于map.get(null)
,HashMap 会直接在 table[0]位置查找 key 为 null 的节点。
在哈希冲突的演示中,所有的CollisionKey
实例都会映射到同一个桶中。当执行collisionMap.get(new CollisionKey("key2"))
时,HashMap 会先定位到桶,然后遍历链表:
- 比较第一个节点
key1
(不匹配) - 比较第二个节点
key2
(匹配) - 返回对应的值
value2
这直观地展示了 HashMap 在处理哈希冲突时的链表遍历过程。
get 方法的性能分析
HashMap 的 get 方法性能主要受以下因素影响:
- 哈希函数质量:好的哈希函数能减少冲突,使元素分布更均匀
- 初始容量设置 :合理的初始容量可以减少扩容次数
- 推荐公式:
initialCapacity = (int) (expectedSize / loadFactor) + 1
- 例如,预计存放 100 个元素,初始容量应设为
(int) (100 / 0.75) + 1 = 134
- 推荐公式:
- 负载因子:影响 HashMap 的扩容时机,默认 0.75
- 哈希冲突程度:冲突多会导致链表/红黑树查找,降低性能
在理想情况下(哈希冲突少),HashMap 的put
和get
操作的平均时间复杂度 或摊还时间复杂度(Amortized Time Complexity) 为 O(1)。单个put
操作偶尔可能会因为触发扩容(resize
)而导致 O(n)的耗时,但在大量操作下,这些高成本操作的开销被分摊了,使得平均成本保持为常数。
在最坏情况下,复杂度为 O(k)(链表结构)或 O(log k)(红黑树结构),其中 k 是桶中节点的数量。极端情况下 k 可能接近 n(总元素数),因此最坏情况可表述为 O(n)或 O(log n)。
哈希冲突攻击与防范
哈希冲突攻击(HashDoS)
哈希冲突攻击是一种针对基于哈希表的数据结构的拒绝服务攻击。攻击者可以构造大量具有相同哈希值的 key,强制 HashMap 在单个桶中形成超长链表,将 get 操作的时间复杂度从 O(1)降为 O(n),导致应用性能严重下降。
例如,攻击者可以精心构造一批 URL 或 JSON 键,使它们的哈希值相同,当这些数据被用作 HashMap 的 key 时,会导致严重的性能问题。
Java 8 的防御机制
Java 8 中引入的红黑树优化正是为了应对这种攻击。当一个桶内的链表长度超过 8 且数组长度大于等于 64 时,链表会转换为红黑树,将最坏情况下的查找时间从 O(n)降为 O(log n),有效缓解了哈希冲突攻击的影响。
此外,为了进一步防御此类攻击,在安全敏感的应用中,可以考虑:
- 使用带有随机种子的安全哈希函数
- 使用 JDK 提供的
ConcurrentHashMap
,它对哈希冲突有更好的处理机制 - 限制单个请求中可接收的键值对数量
线程安全性考虑
需要特别强调的是,HashMap 是非线程安全的 。如果在多线程环境下共享一个 HashMap 实例,且至少有一个线程会修改它(如put
, remove
),那么必须在外部进行同步控制。
java
// 错误的多线程用法
Map<String, String> map = new HashMap<>();
// 多个线程并发地对map进行读写操作,可能导致数据错乱或程序崩溃
// 正确的同步方式
Map<String, String> syncedMap = Collections.synchronizedMap(new HashMap<>());
// 或者,在并发场景下,更推荐使用`ConcurrentHashMap`
Map<String, String> concurrentMap = new ConcurrentHashMap<>();
ConcurrentHashMap
是专为高并发场景设计的,它通过分段锁(JDK 7)或 CAS+synchronized
(JDK 8+)等技术,提供了比Collections.synchronizedMap
更高的并发性能。当涉及到多线程环境时,应优先考虑使用ConcurrentHashMap
。
get(): 其他 Map 方法的基础
深入理解get
和其核心辅助方法getNode
的原理,有助于我们掌握其他更现代的 Map 接口方法,因为它们内部往往会复用这套查找逻辑。例如:
getOrDefault(key, defaultValue)
: 内部先尝试getNode
,如果返回null
,则返回你指定的defaultValue
,避免了手动进行null
检查。containsKey(key)
: 它的实现就是简单地调用getNode(hash(key), key) != null
。computeIfAbsent(key, mappingFunction)
: 这是实现高效缓存("get-or-create"模式)的利器。它也会先用getNode
查找,只有当 key 不存在时,才会执行mappingFunction
来创建值、放入 Map 并返回。
了解get
的机制,可以帮助你更高效、更优雅地使用这些高级 API。
常见问题及解决方案
问题 1:自定义对象作为 key 时 get 返回 null
原因:自定义类没有正确重写 hashCode 和 equals 方法。
解决方案:
java
public class Person {
private String name;
private int age;
// 构造方法和getter/setter省略
@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);
}
}
问题 2:get 方法性能下降
原因:哈希冲突过多,或者 HashMap 过大导致频繁扩容。
解决方案:
- 使用更好的哈希函数
- 合理设置初始容量(使用前文提供的公式)
- 考虑使用其他集合类,如 LinkedHashMap 或 ConcurrentHashMap
总结
步骤 | 内容 | 关键点 |
---|---|---|
1 | 计算哈希值 | 通过 key.hashCode()异或高 16 位得到最终哈希值;null 键哈希值为 0 |
2 | 确定桶位置 | 通过(n-1) & hash 计算索引,等效于 hash % n 但更高效 |
3 | 查找元素 | 懒加载检查 → 首节点检查 → 红黑树/链表查找 |
4 | 返回结果 | 找到则返回 value,否则返回 null |
5 | 树化条件 | 链表长度>8 且 数组长度>=64,是防御哈希冲突攻击的关键机制 |
6 | 线程安全 | HashMap 非线程安全,多线程环境需使用 ConcurrentHashMap |