一、代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.example;

import lombok.Getter;
import lombok.Setter;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

public class HashMapExample {

public static void main(String[] args) {
Key key1 = new Key();
key1.setKey("key1");
key1.setDesc("key1 desc");
Map<Key, String> map1 = new HashMap<>();
map1.put(key1, "value1");

// 获取 map1 中的值,然后再修改 key1
System.out.println(map1.get(key1)); // 输出 value1

key1.setKey("new key1");
System.out.println(map1.get(key1)); // 输出 null

Map<Key, String> map2 = new HashMap<>(map1);
System.out.println(map2.get(key1)); // 输出 value1
}

@Getter
@Setter
public static class Key{
private String key;
private String desc;

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Key key1 = (Key) o;
return Objects.equals(key, key1.getKey()) && Objects.equals(desc, key1.getDesc());
}

@Override
public int hashCode() {
return String.format("%s:%s", key, desc).hashCode();
}
}
}

二、解释

这是一个非常经典且重要的 Java 问题,它深刻地揭示了 **HashMap** 的工作原理对象哈希一致性原则 之间的关系。我们来逐行分析代码,并结合 HashMap 的底层原理进行解释。


🌟 核心结论提前:

不要在 **HashMap** **key** 被放入 **HashMap** 后修改其 **hashCode()** **equals()** 相关字段,否则会导致无法通过 **get()** 正确查找该 **key**


🔍 代码逐段分析

1. 定义 Key

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Getter
@Setter
public static class Key {
private String key;
private String desc;

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Key key1 = (Key) o;
return Objects.equals(key, key1.getKey()) && Objects.equals(desc, key1.getDesc());
}

@Override
public int hashCode() {
return String.format("%s:%s", key, desc).hashCode();
}
}
  • Key 类重写了 equals()hashCode()
  • 哈希值依赖于 keydesc 字段。
  • 所以:只要 **key** **desc** 改变,**hashCode()** 就会改变。

2. 创建 key1 并放入 map1

1
2
3
4
5
6
Key key1 = new Key();
key1.setKey("key1");
key1.setDesc("key1 desc");

Map<Key, String> map1 = new HashMap<>();
map1.put(key1, "value1");

此时:

  • key1.key = "key1", key1.desc = "key1 desc"
  • key1.hashCode() 计算为 "key1:key1 desc".hashCode() → 假设为 H1

HashMap 存储时:

  1. 计算 key1.hashCode()H1
  2. 根据 H1 % capacity 找到桶(bucket)位置
  3. key1 -> "value1" 存入该桶中

✅ 此时一切正常。


3. 第一次 get():能拿到值

1
System.out.println(map1.get(key1)); // 输出 value1
  • 此时 key1 的字段未变,hashCode() 仍是 H1

  • HashMap 查找步骤:

    1. 计算 key1.hashCode()H1
    2. 找到对应桶
    3. 遍历桶内 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) 执行过程:

  1. 计算 key1.hashCode()H2
  2. H2 % capacity 对应的桶中查找
  3. key1 实际上是存储在 H1 % capacity 的桶里的!
  4. 所以 H2 对应的桶中没有这个 key1
  5. 返回 null

🔍 关键点

  • HashMap 是根据 hashCode() 找桶的。
  • 现在 key1hashCode() 变了,但对象本身没从旧桶中移除。
  • 所以 get() 找错了地方,找不到!

这就是“哈希不一致”导致的查找失败。


6. 创建 map2:使用 new HashMap<>(map1)

1
2
Map<Key, String> map2 = new HashMap<>(map1);
System.out.println(map2.get(key1)); // 输出 value1

你可能会惊讶:为什么这里又能找到?

我们来看 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 查找流程:

  1. 调用 key.hashCode() → 得到哈希值
  2. 通过哈希值确定桶(bucket)位置
  3. 遍历桶中的链表/红黑树,用 key.equals() 找到匹配的 entry

⚠️ 所以:**hashCode()** 决定“去哪找”,**equals()** 决定“是不是它”


❌ 本例的问题:

  • key1 被放入 map1 后,修改了影响 hashCode() 的字段
  • 导致 hashCode() 改变,但对象仍留在原来的桶中
  • 下次 get() 时,HashMap 去新 hashCode() 对应的桶找 → 找不到

✅ 正确做法(黄金法则):

作为 **HashMap** **key** 的对象,一旦放入 **Map**,其 **hashCode()** 必须保持不变!

如何保证?

  • key 类设计为 不可变类(immutable)
  • 或者确保影响 hashCode()equals() 的字段在放入 Map 后不再修改
1
2
3
4
5
6
7
8
9
10
11
12
public static class Key {
private final String key; // final
private final String desc; // final

public Key(String key, String desc) {
this.key = key;
this.desc = desc;
}

// 只有 getter,没有 setter!
// ...
}

🧪 补充实验建议

你可以打印 hashCode() 来验证:

1
2
3
System.out.println("Before: " + key1.hashCode());
key1.setKey("new key1");
System.out.println("After: " + key1.hashCode());

你会发现两个 hashCode() 不同。


✅ 最终结论

操作 结果 原因
map1.get(key1) before modify value1 hashCode() 未变,能正确找到桶
map1.get(key1) after modify null hashCode() 改变,去错桶找
map2.get(key1) value1 map2 是在修改后创建的,key1 存在 H2 桶中

📌 最佳实践建议:

  1. 用作 **HashMap** **key** 的类应尽量设计为不可变类
  2. 避免在 **key** 放入 **Map** 后修改其 **equals()** / **hashCode()** 相关字段
  3. 重写 **hashCode()** **equals()** 时要保证一致性、稳定性

这个例子完美展示了“为什么 String 是最好的 HashMap key”——因为它是不可变的,hashCode() 一旦计算就不会变!


三、更多

如果增加以下代码,会输出什么?

1
2
3
4
5
6
for (int i = 0; i < 100; i++) {
Key key = new Key();
key.setKey(String.valueOf(i));
map1.put(key, String.valueOf(i));
}
System.out.println(map1.get(key1));

✅ 答案:

仍然输出 **null**

我们来深入分析为什么即使插入 100 个元素,**get(key1)** 依然返回 **null** —— 这背后的原因,正是 Java HashMap 实现中的一个关键细节**HashMap** 在扩容时并不会重新调用 **key.hashCode()**,而是使用 **hash** 字段缓存的值!


🔥 核心结论提前:

**HashMap** 内部缓存了 **key.hashCode()** 的结果(称为 **hash**),扩容时使用的是这个缓存值,而不是重新计算 **key.hashCode()**

所以:即使 **key** 的字段被修改导致 **hashCode()** 改变,**HashMap** 仍然用旧的 **hash** 值来重新分配位置 —— **key1** 仍然“错”在旧桶里。


🧠 深入源码:HashMaphash 缓存机制

我们来看 HashMap 的核心设计。

1. HashMap 存储的是 Node<K,V>

HashMap中的 Node

  • hash 字段是 **final** 的!
  • 它在 put 时计算一次,之后永远不会改变

2. putVal 方法中的关键代码:

1
2
3
4
5
6
7
8
9
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; int n, i, binCount;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) // ⬅️ 使用传入的 hash
tab[i] = newNode(hash, key, value, null);
// ...
}
  • hash 是在 put 时计算的:hash = key.hashCode() ^ (key.hashCode() >>> 16)
  • 这个 hash 被存入 Nodefinal int hash 字段
  • 后续所有操作(包括 getresize)都使用这个缓存的 **hash**

🔄 扩容时发生了什么?

resize() 时,HashMap 会遍历所有 Node,并根据 **Node.hash** 重新计算它在新数组中的位置:

1
2
3
// 在 resize() 中
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e; // ⬅️ 使用 e.hash,不是 e.key.hashCode()

❗ 所以:即使 **key.hashCode()** 已经改变,**HashMap** 仍然使用最初 **put** 时计算的 **hash** 值来决定它在新数组中的位置!


🎯 回到示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Key key1 = new Key();
key1.setKey("key1");
key1.setDesc("key1 desc");
map1.put(key1, "value1"); // ⬅️ 此时 hashCode() = H1,Node.hash = H1

key1.setKey("new key1"); // ⬅️ hashCode() 变为 H2,但 Node.hash 仍是 H1!

for (int i = 0; i < 100; i++) {
Key key = new Key();
key.setKey(String.valueOf(i));
map1.put(key, String.valueOf(i));
}
// 触发 resize(),但 rehash 时使用的是缓存的 hash = H1
// 所以 key1 仍然被放在 H1 % newCapacity 的桶中

此时:

  • map1.get(key1)
    • 计算 key1.hashCode()H2
    • H2 % capacity 的桶中找
    • key1 实际上是基于 H1 分配的,所以在 H1 % capacity 的桶里
    • 找不到 → 返回 null

🖼️ 更新后的示意图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
初始 put:
key1.hashCode() = H1 = 100
Node.hash = 100
放入桶: 100 % 16 = 4

修改 key1:
key1.hashCode() 变为 H2 = 200
但 Node.hash 仍是 100!

扩容时:
遍历 Node,使用 Node.hash = 100
新桶: 100 % 32 = 4, 100 % 64 = 36, 100 % 128 = 100
→ key1 被放入桶 100

get(key1):
key1.hashCode() = 200
去 200 % 128 = 72 桶找 → 空!
返回 null

✅ 最终结论

情况 map1.get(key1) 原因
修改后立即 get null hashCode() 改变,去错桶
插入 100 个元素后 get null HashMap 扩容时使用缓存的 hash(H1),但 get 使用当前 hashCode()(H2),仍然不匹配

📌 重要教训

  1. **HashMap** **hash** 是缓存的,不会在扩容时重新计算
  2. 一旦 **key** **hashCode()** 相关字段被修改,该 **key** 就“永远”无法通过 **get()** 正确访问了
  3. 不要依赖“扩容会修复”这种行为 —— 它不会!