一、代码
1 | package com.example; |
二、解释
这是一个非常经典且重要的 Java 问题,它深刻地揭示了 **HashMap** 的工作原理 与 对象哈希一致性原则 之间的关系。我们来逐行分析代码,并结合 HashMap 的底层原理进行解释。
🌟 核心结论提前:
不要在
**HashMap**的**key**被放入**HashMap**后修改其**hashCode()**和**equals()**相关字段,否则会导致无法通过**get()**正确查找该**key**!
🔍 代码逐段分析
1. 定义 Key 类
1 |
|
Key类重写了equals()和hashCode()。- 哈希值依赖于
key和desc字段。 - 所以:只要
**key**或**desc**改变,**hashCode()**就会改变。
2. 创建 key1 并放入 map1
1 | Key key1 = new Key(); |
此时:
key1.key = "key1",key1.desc = "key1 desc"key1.hashCode()计算为"key1:key1 desc".hashCode()→ 假设为H1
HashMap 存储时:
- 计算
key1.hashCode()→H1 - 根据
H1 % capacity找到桶(bucket)位置 - 把
key1 -> "value1"存入该桶中
✅ 此时一切正常。
3. 第一次 get():能拿到值
1 | System.out.println(map1.get(key1)); // 输出 value1 |
此时
key1的字段未变,hashCode()仍是H1HashMap查找步骤:- 计算
key1.hashCode()→H1 - 找到对应桶
- 遍历桶内 Entry,用
equals()比较 key → 成功匹配自己
- 计算
所以能查到
"value1"
✅ 正常。
4. 修改 key1 的字段
1 | key1.setKey("new key1"); |
现在:
key1.key = "new key1",desc = "key1 desc"key1.hashCode()变为"new key1:key1 desc".hashCode()→ 假设为H2(≠ H1)- 但
key1还在map1中,且仍然存储在 基于旧哈希值**H1**的桶中
⚠️ 问题来了!
5. 第二次 get():返回 null
1 | System.out.println(map1.get(key1)); // 输出 null |
HashMap.get(key1) 执行过程:
- 计算
key1.hashCode()→H2 - 去
H2 % capacity对应的桶中查找 - 但
key1实际上是存储在H1 % capacity的桶里的! - 所以
H2对应的桶中没有这个key1 - 返回
null
🔍 关键点:
HashMap是根据hashCode()找桶的。- 现在
key1的hashCode()变了,但对象本身没从旧桶中移除。 - 所以
get()找错了地方,找不到!
❌ 这就是“哈希不一致”导致的查找失败。
6. 创建 map2:使用 new HashMap<>(map1)
1 | Map<Key, String> map2 = new HashMap<>(map1); |
你可能会惊讶:为什么这里又能找到?
我们来看 new HashMap<>(map1) 的行为:
- 它会遍历
map1中所有的Entry<Key, String> - 对每个 entry,执行
map2.put(entry.getKey(), entry.getValue()) - 注意:此时
map1中的key1已经被修改,其hashCode()是H2 - 所以
map2插入时:- 计算
key1.hashCode()→H2 - 把
key1 -> "value1"放入H2 % capacity的桶中
- 计算
✅ 因此,在 map2 中:
key1的存储位置是基于当前hashCode()(H2)计算的- 所以
map2.get(key1)时:- 计算
hashCode()→H2 - 去
H2对应桶中找 - 找到了!返回
"value1"
- 计算
🧠 总结:HashMap 原理 + 关键教训
✅ HashMap 查找流程:
- 调用
key.hashCode()→ 得到哈希值 - 通过哈希值确定桶(bucket)位置
- 遍历桶中的链表/红黑树,用
key.equals()找到匹配的 entry
⚠️ 所以:
**hashCode()**决定“去哪找”,**equals()**决定“是不是它”
❌ 本例的问题:
key1被放入map1后,修改了影响hashCode()的字段- 导致
hashCode()改变,但对象仍留在原来的桶中 - 下次
get()时,HashMap去新hashCode()对应的桶找 → 找不到
✅ 正确做法(黄金法则):
作为
**HashMap**的**key**的对象,一旦放入**Map**,其**hashCode()**必须保持不变!
如何保证?
- 将
key类设计为 不可变类(immutable) - 或者确保影响
hashCode()和equals()的字段在放入Map后不再修改
1 | public static class Key { |
🧪 补充实验建议
你可以打印 hashCode() 来验证:
1 | System.out.println("Before: " + key1.hashCode()); |
你会发现两个 hashCode() 不同。
✅ 最终结论
| 操作 | 结果 | 原因 |
|---|---|---|
map1.get(key1) before modify |
value1 |
hashCode() 未变,能正确找到桶 |
map1.get(key1) after modify |
null |
hashCode() 改变,去错桶找 |
map2.get(key1) |
value1 |
map2 是在修改后创建的,key1 存在 H2 桶中 |
📌 最佳实践建议:
- 用作
**HashMap**的**key**的类应尽量设计为不可变类 - 避免在
**key**放入**Map**后修改其**equals()**/**hashCode()**相关字段 - 重写
**hashCode()**和**equals()**时要保证一致性、稳定性
这个例子完美展示了“为什么
String是最好的HashMapkey”——因为它是不可变的,hashCode()一旦计算就不会变!
三、更多
如果增加以下代码,会输出什么?
1 | for (int i = 0; i < 100; i++) { |
✅ 答案:
仍然输出
**null**
我们来深入分析为什么即使插入 100 个元素,**get(key1)** 依然返回 **null** —— 这背后的原因,正是 Java HashMap 实现中的一个关键细节:**HashMap** 在扩容时并不会重新调用 **key.hashCode()**,而是使用 **hash** 字段缓存的值!
🔥 核心结论提前:
**HashMap**内部缓存了**key.hashCode()**的结果(称为**hash**),扩容时使用的是这个缓存值,而不是重新计算**key.hashCode()**!所以:即使
**key**的字段被修改导致**hashCode()**改变,**HashMap**仍然用旧的**hash**值来重新分配位置 ——**key1**仍然“错”在旧桶里。
🧠 深入源码:HashMap 的 hash 缓存机制
我们来看 HashMap 的核心设计。
1. HashMap 存储的是 Node<K,V>:
hash字段是**final**的!- 它在
put时计算一次,之后永远不会改变
2. putVal 方法中的关键代码:
1 | final V putVal(int hash, K key, V value, boolean onlyIfAbsent, |
hash是在put时计算的:hash = key.hashCode() ^ (key.hashCode() >>> 16)- 这个
hash被存入Node的final int hash字段 - 后续所有操作(包括
get、resize)都使用这个缓存的**hash**值
🔄 扩容时发生了什么?
当 resize() 时,HashMap 会遍历所有 Node,并根据 **Node.hash** 重新计算它在新数组中的位置:
1 | // 在 resize() 中 |
❗ 所以:即使
**key.hashCode()**已经改变,**HashMap**仍然使用最初**put**时计算的**hash**值来决定它在新数组中的位置!
🎯 回到示例代码
1 | Key key1 = new Key(); |
此时:
map1.get(key1):- 计算
key1.hashCode()→H2 - 去
H2 % capacity的桶中找 - 但
key1实际上是基于H1分配的,所以在H1 % capacity的桶里 - 找不到 → 返回
null
- 计算
🖼️ 更新后的示意图
1 | 初始 put: |
✅ 最终结论
| 情况 | map1.get(key1) |
原因 |
|---|---|---|
| 修改后立即 get | null |
hashCode() 改变,去错桶 |
| 插入 100 个元素后 get | null ✅ |
HashMap 扩容时使用缓存的 hash(H1),但 get 使用当前 hashCode()(H2),仍然不匹配 |
📌 重要教训
**HashMap**的**hash**是缓存的,不会在扩容时重新计算- 一旦
**key**的**hashCode()**相关字段被修改,该**key**就“永远”无法通过**get()**正确访问了 - 不要依赖“扩容会修复”这种行为 —— 它不会!