我把一个可变对象当 HashMap 的 key 存了进去、后来改了这个对象里参与 hashCode 计算的一个字段,再用它去 map 里 get 竟然返回 null,可我遍历这个 map 明明能看到这条记录还在里面,我对着这个又在又取不出来的幽灵键排查了大半天才想通问题出在哪
这是一次让我把 Java 里"用对象做 HashMap 的 key"这件事,从"随便什么对象都能当 key",重新理解成"当 key 的对象,参与 hashCode 的部分放进去之后就绝不能再改"的事故。我把一个可变对象当 HashMap 的 key 存了进去,后来改了这个对象里参与 hashCode 计算的一个字段,再用它去 map.get 竟然返回 null。可我遍历这个 map,明明能看到这条记录还在里面。我对着这个"又在、又取不出来"的幽灵键排查了大半天,才想通问题出在哪。这篇就把这次"key 还在 map 里、却 get 不出来"的事故,从头到尾复盘一遍。
故障现场:一个明明还在 map 里的 key,却怎么也 get 不到
我有个实体类 Key,重写了 equals 和 hashCode,都基于它的 id 字段。我把一个 Key 对象当键、连着一个值,put 进了一个 HashMap。一切正常。后来业务逻辑里,我修改了这个 Key 对象的 id(它是可变的,有 setter)。再之后,我拿这个对象去 map.get(key),想取回当初存的值——结果返回 null。
我懵了:这个 key 我没从 map 里删过啊。我 map.containsKey(key),也是 false;可我 map.size() 还是 1,遍历 map.entrySet(),那条记录清清楚楚地躺在里面,它的 key 就是我手里这个对象。我一度怀疑是不是 HashMap 坏了,或者多线程改坏了内部结构。直到我把"put 时这个对象的 hashCode"和"现在 get 时它的 hashCode"分别打出来一比,才恍然大悟——两个 hashCode 不一样!因为我改了 id,而 hashCode 是基于 id 算的。HashMap 是靠 key 的 hashCode 先定位到某个"桶"、再在桶里比 equals 找 entry 的:put 的时候,它按旧 hashCode 把 entry 放进了旧桶;我改了 id 后,key 的 hashCode 变了,get 时 HashMap 按新 hashCode 去算桶、跑到了另一个桶里找,那个桶里当然没有——可 entry 其实还安安静静地待在旧桶里。它就成了一个"还在 map 里、却永远 get 不到"的幽灵。
// Key 的 equals/hashCode 基于 id, 而 id 是可变的(有 setter)
class Key {
int id;
Key(int id) { this.id = id; }
public int hashCode() { return Integer.hashCode(id); }
public boolean equals(Object o) {
return o instanceof Key && ((Key)o).id == id;
}
}
Map map = new HashMap<>();
Key k = new Key(1);
map.put(k, "value"); // 按 hashCode(id=1) 放进了【1 号桶】
k.id = 2; // ★ 改了参与 hashCode 的字段!hashCode 变了
System.out.println(map.get(k)); // null ← 取不到!
System.out.println(map.containsKey(k));// false ← "不包含"?!
System.out.println(map.size()); // 1 ← 但它明明还在!
for (var e : map.entrySet())
System.out.println(e.getKey().id); // 2 ← 遍历得到它, 就在里面
// 真相: put 时按 hashCode=1 放进 1 号桶;
// 改 id 后 hashCode 变成 2; get 时按 hashCode=2 去 2 号桶找 → 找不到。
// entry 还在 1 号桶里, 成了 get 不到、却遍历得到的"幽灵键"。
问题被钉死在这个认知错位上:我以为"只要这个 key 对象还在 map 里,我拿着它就一定能 get 到",但 HashMap 找 key 靠的不是"对象本身",而是"对象的 hashCode 算出的桶位置 + equals";一旦我改了参与 hashCode 的字段,这个对象"该在哪个桶"的答案就变了,而它实际所在的桶(put 时按旧 hashCode 定的)并不会跟着移动。于是"存放时的位置"和"查找时算出的位置"对不上了——HashMap 跑到新位置找一个待在旧位置的东西,自然找不到。我把一个"位置由它自己的内容决定"的东西放进了容器,又中途改了它的内容,等于偷偷挪动了它"应该在的位置",却没真的把它挪过去。我以为我攥着钥匙,其实我把钥匙的齿型改了,它再也开不了当初那把锁。
第一件事:想明白 HashMap 是靠 hashCode 定位的,key 的 hash 不能变
把这次事故彻底想清楚,关键是理解HashMap 的存取,是一个"先用 key 的 hashCode 算出桶下标、再在桶内用 equals 比对"的两步过程:put 时按当时的 hashCode 决定 entry 存到哪个桶;get/containsKey 时按当时的 hashCode 决定去哪个桶找。这个机制成立的前提是:一个 key 从放进去到取出来,它的 hashCode(以及 equals 行为)始终不变。一旦中途变了,存和取就会去到不同的桶,key 就"丢"了。
这就是为什么"用作 HashMap key 的对象,必须保证其参与 hashCode/equals 的部分在 map 持有它期间不被修改"——最稳妥的做法是用不可变对象当 key(像 String、Integer 这些天生不可变的,正是 HashMap key 的理想选择)。HashMap 自己没有能力感知 key 内部字段的变化、更不会在你改了字段后自动把 entry 搬到新桶里——它在 put 那一刻就把 entry "钉"在了按当时 hashCode 算出的桶里,此后你改 key 的字段,改的只是那个对象,entry 待的桶纹丝不动。容器是按"放进来那一刻的 hash"给东西安排位置的,它默认这个 hash 是稳定的;你中途改 hash,就违背了这个容器赖以工作的根本约定。
// 正确姿势: 用不可变对象做 key, hashCode 永不变, 存取位置永远一致
// ① 首选: 用 String/Integer 等天生不可变类型做 key
Map m1 = new HashMap<>();
m1.put(1, "value");
// 你永远无法"修改" Integer(1) 的值, 它的 hashCode 恒定 → 绝不会丢
// ② 自定义 key 类: 把参与 hashCode/equals 的字段设为 final, 不给 setter
final class Key {
private final int id; // final, 创建后不可改
Key(int id) { this.id = id; }
public int hashCode() { return Integer.hashCode(id); }
public boolean equals(Object o) {
return o instanceof Key key && key.id == id;
}
}
// 没有 setter, id 改不了 → hashCode 恒定 → 放进 map 后绝不会变成幽灵
// 反面: 若 key 确实可变, 又非要用它, 那就【绝不在它还是某 map 的 key 时改它】
// 要改, 先 remove(oldKey), 改完再 put(newKey, value) —— 显式地搬桶
想通这一层,我才明白自己错在哪:我把一个"位置依赖于自身内容(hashCode)"的对象放进了 HashMap,却又保留了"随时能改它内容"的能力,并真的在它还是 key 的时候改了它。这等于一边告诉容器"请按它现在的样子安排座位",一边又偷偷把它变了样——容器记着的还是旧座位,我却拿着新样子去找,当然对不上。根治之道是从源头消除这种可能:当 key 用的对象,就让它(参与 hash 的部分)不可变;真要可变,就在改它之前先从 map 里取出来、改完再放回去,显式地完成"搬桶"。
第二件事:正解——key 用不可变对象,或改前先 remove、改后再 put
找到根因,正解就清晰了:用作 HashMap key 的对象,要保证其参与 hashCode/equals 的部分在 map 持有期间不变——首选不可变对象(参与字段设 final、不给 setter,或直接用 String/Integer 等),这样从根上杜绝问题;如果 key 确实必须可变,那就遵守"在它还是某个 map 的 key 时绝不修改它",真要改就先 remove(oldKey)、改完再 put(newKey, value),显式地把它从旧桶搬到新桶。
// 错误: key 可变, put 后直接改字段 → 幽灵键, get 不到
Key k = new Key(1);
map.put(k, "value");
k.id = 2; // ✗ hashCode 变了, entry 留在旧桶, 取不出
// 正解1: key 设计成不可变(参与 hash 的字段 final, 无 setter) —— 最彻底
final class Key {
private final int id;
Key(int id) { this.id = id; }
public int hashCode() { return Integer.hashCode(id); }
public boolean equals(Object o) {
return o instanceof Key key && key.id == id;
}
public int id() { return id; } // 只读, 不提供修改入口
}
// 正解2: key 非用可变对象不可时, "改 key" = 先 remove 再 put(显式搬桶)
String v = map.remove(k); // 1) 用旧 hashCode 把 entry 从旧桶取出
k.setId(2); // 2) 现在它不再是任何 map 的 key, 改它安全
map.put(k, v); // 3) 用新 hashCode 放进新桶, 位置重新对齐
// 正解3: 不想改 key 类, 就用值的拷贝/快照做 key, 别用会被改的活对象
map.put(new Key(k.id()), "value"); // 存入一个独立快照, 后续改 k 不影响它
这套做法的精髓,是维护住 HashMap 赖以工作的根本约定:"key 在 map 里期间,它的 hashCode 不变"。用不可变对象做 key,是从源头让"改它"这件事根本不可能发生,一劳永逸;实在要用可变对象,就把"修改 key"这个操作,翻译成 HashMap 能理解的"先按旧 hash 移除、再按新 hash 插入"——也就是亲手帮它完成它自己做不了的"搬桶"。绝不能做的,是让一个对象一边当着 key、一边又被悄悄改掉 hashCode。不是不能用对象做 key,而是要让 key 的"身份依据"(hashCode/equals)在它服役期间保持稳定。
【用对象做 Map/Set 的 key/元素, 我现在认死的几条】
1. HashMap 靠 key 的 hashCode 定位桶, 再用 equals 比对找 entry
2. 前提: key 从 put 到 get, hashCode 和 equals 行为必须始终不变
3. 中途改了参与 hashCode 的字段 → 存取去到不同桶 → 变"幽灵键"
4. 首选不可变对象做 key: 参与字段 final、无 setter, 或用 String/Integer
5. key 必须可变时: 它还在 map 里就绝不改它
6. 真要改: 先 remove(旧), 改完再 put(新), 显式搬桶
7. 同理 HashSet 元素、TreeMap/TreeSet 的比较字段, 入容器后都别改
第三件事:其他"把'位置/身份依赖于内容'的东西放进容器后又改内容"的同类坑
顺着"容器按放入时的内容(hash/顺序)给元素定了位,你又改了这个内容"这条线,我把同类的坑都排查了一遍:
第一个,HashSet 里的元素改了 hashCode。和 HashMap key 同理——HashSet 内部就是 HashMap,放进去的元素改了参与 hashCode 的字段,就 contains 不到、也删不掉了。
第二个,TreeMap/TreeSet 里改了参与比较的字段。它们靠 compareTo/Comparator 维持有序,元素入容器后改了比较依据,树的有序性就被破坏,查找/删除全乱。
第三个,用对象的默认 hashCode(没重写)当 key,期望按内容相等。没重写 hashCode/equals 时按对象身份(地址)比,两个"内容相同"的 key 也取不到对方的值——这是反过来的同一问题。
第四个,把可变对象放进按它排序的列表后改它。Collections.sort 后又改了元素的排序字段,列表的有序性失效,二分查找会出错。
第四件事:可变 key vs 不可变 key——一张对照表
我把"用可变对象做 key"和"用不可变对象做 key"摆在一起对比,核心看"放进去后还安不安全":
| 维度 | 可变对象做 key | 不可变对象做 key |
|---|---|---|
| hashCode 稳定性 | 改字段就变, 不稳定 | 恒定不变 |
| put 后改字段 | 变幽灵键, get 不到 | 不可能发生(无法改) |
| 存取位置一致性 | 改后存放桶与查找桶不一致 | 永远一致 |
| 线程安全(作为 key) | 别人改它你就丢 key | 天生线程安全 |
| 典型代表 | 带 setter 的实体、可变集合 | String、Integer、final 字段类 |
| 建议 | 尽量别用; 非用则改前 remove | HashMap key 的首选 |
看清这张表,选择就有谱了:HashMap 的 key 首选不可变对象(String/Integer 或参与字段 final 的类),从根上保证 hashCode 恒定、存取位置永远一致;可变对象尽量别做 key,非用不可就严守"在 map 里不改、要改先 remove 再 put"。我这次踩坑,就是用了带 setter 的可变对象做 key,还在它服役期间改了参与 hashCode 的字段。不可变 key 不是约束,而是一种省心的保障——你根本没机会犯这个错。
第五件事:我曾经对 HashMap key 想当然的几个误区
这次事故也把我对 HashMap key 的一堆"想当然"照了个底朝天:
| 我以为 | 实际上 |
|---|---|
| 只要 key 对象还在 map 里就能 get 到 | get 靠 hashCode 定位桶, hash 变了就去错桶, 取不到 |
| 改 key 的字段不影响它在 map 里的存取 | 改了参与 hashCode 的字段就成幽灵键 |
| get 不到说明 key 已经不在 map 里了 | 它可能还在(size 不变、遍历得到), 只是定位不到 |
| HashMap 会跟着 key 字段变化更新位置 | entry 在 put 时就钉死在桶里, 改字段不会自动搬桶 |
| 什么对象都能随便拿来当 key | 当 key 的对象, 参与 hash 的部分必须保持不变 |
这些误区的根子是同一个:我没意识到"把对象放进 HashMap"是和容器立下了一个隐含契约——"我保证这个 key 的 hashCode/equals 在你持有它期间不变",而我中途改字段,单方面撕毁了这个契约。HashMap 高效的代价,正是它要信任你不会改 key 的 hash;它把 entry 按你给的 hash 安置好,之后就不再过问 key 内部的变化。把一个"身份会变"的东西,放进一个"按身份定位"的容器,还真去改了它的身份,是这类"找不到、删不掉"事故的共同根源。
第六件事:用对象做 key、排查"键还在却 get 不到"时,我现在的自检习惯
现在每当我用对象做 Map/Set 的 key、或排查"key 明明在容器里却取不出来/删不掉",我都会先按这张图问自己:
这张图的精髓,是"HashMap 靠 key 的 hashCode 定位;key 在 map 里期间 hashCode 必须不变;首选不可变对象做 key"。设计就用不可变对象做 key、参与字段 final 无 setter、排查就比对 put 时与 get 时同一对象的 hashCode 是不是变了。这套习惯,让我从"随手拿对象当 key"变成了"先确认这个 key 的 hash 会不会变"——核心始终是:HashMap 的存取是"先用 key 的 hashCode 算出桶下标、再在桶内用 equals 比对"的两步过程:put 时按当时的 hashCode 把 entry 钉进对应的桶,get/containsKey 时按当时的 hashCode 决定去哪个桶找;这个机制成立的根本前提是一个 key 从放进去到取出来其 hashCode 和 equals 行为始终不变;一旦中途修改了 key 中参与 hashCode 的字段,它"该在哪个桶"的答案就变了,而它实际所在的桶(put 时按旧 hashCode 定的)并不会自动跟着移动,于是存放位置与查找位置错位、get 跑到新桶找一个待在旧桶的 entry 自然返回 null、containsKey 也为 false,但这条 entry 其实还在旧桶里,size 不减、遍历 entrySet 仍能看到它,成了"又在又取不到"的幽灵键;HashMap 自己没有能力感知 key 内部字段的变化、更不会自动搬桶;正解是用作 key 的对象要保证参与 hashCode/equals 的部分在 map 持有期间不变——首选不可变对象(参与字段设 final 不给 setter,或直接用 String/Integer 等天生不可变类型),若 key 确实必须可变就严守"在它还是某 map 的 key 时绝不修改它"、真要改则先 remove(oldKey) 改完再 put(newKey,value) 显式完成搬桶;HashSet 元素、TreeMap/TreeSet 的比较字段同理,入容器后都不能改。
我立下的几条规矩
这场"幽灵键、键还在却 get 不到"的事故,换来了我用对象做 key 时,刻进骨子里的几条铁律:
- HashMap 靠 key 的 hashCode 定位桶、再用 equals 比对;hash 错了就去错桶。
- key 从 put 到 get,hashCode 和 equals 行为必须始终不变。
- 改了 key 里参与 hashCode 的字段 → 存取去到不同桶 → 变幽灵键。
- 首选不可变对象做 key:参与字段 final、无 setter,或用 String/Integer。
- key 必须可变时:它还在 map 里就绝不改它。
- 真要改:先 remove(旧) 改完再 put(新),显式搬桶。
- HashSet 元素、TreeMap/TreeSet 的比较字段,入容器后同样别改。
附:我现在设计 HashMap key 的"不可变 + 改前 remove"骨架
这是我现在设计 HashMap key 固定套的骨架——把这次踩坑的教训(key 不可变、参与字段 final、真要改先 remove 再 put)固化成一套结构,让"幽灵键"那种坑再不会埋进代码:
// 写法一: key 类做成不可变 —— 从根上杜绝 hashCode 变化(首选)
public final class UserKey { // final 类, 不可被继承破坏
private final long tenantId; // 参与 hash 的字段全部 final
private final long userId;
public UserKey(long tenantId, long userId) {
this.tenantId = tenantId;
this.userId = userId;
}
@Override public int hashCode() { return Objects.hash(tenantId, userId); }
@Override public boolean equals(Object o) {
if (!(o instanceof UserKey k)) return false;
return tenantId == k.tenantId && userId == k.userId;
}
// 只读访问器, 没有任何 setter → hashCode 恒定
public long tenantId() { return tenantId; }
public long userId() { return userId; }
}
// Java record 更省事: record UserKey(long tenantId, long userId) {}
// record 自动 final 字段 + 自动 equals/hashCode, 天生适合做 key
// 写法二: 万一 key 必须可变, 封装"改 key"为"先 remove 再 put"
public static void rekey(Map map, K oldKey,
Runnable mutate, K newKeyView) {
V v = map.remove(oldKey); // 1) 按旧 hash 取出, 离开容器
mutate.run(); // 2) 此刻它不是任何 map 的 key, 改它安全
if (v != null) map.put(newKeyView, v); // 3) 按新 hash 放回, 位置对齐
}
// 自检: 放进 map 前后, key 的 hashCode 必须一致(可在测试里断言)
int before = key.hashCode();
map.put(key, value);
businessLogic(key); // 跑一段可能改 key 的逻辑
assert key.hashCode() == before : "key 的 hashCode 被改了, 会变幽灵键!";
这套骨架把我这次的教训钉死在了结构里:能不可变就不可变——key 类设成 final、参与 hash 的字段全 final、只给只读访问器(或直接用 record),让"改它"在编译期就不可能;实在要可变,就把"改 key"封装成 remove→改→put 的显式搬桶,绝不让对象在当 key 期间被改 hash。再配一句"put 前后 hashCode 必须一致"的断言兜底,就彻底告别了"size 是 1 却 get 不到"的幽灵。把"用不可变之物做定位依据、改动前先取出再重新登记"这个道理,沉淀成设计 key 的固定骨架,这是我对这次"幽灵键"最实在的交代——毕竟,交给容器按特征保管的东西,那个特征就该像刻在石头上一样稳定,而不是被我随手一个 setter 改得面目全非。
写在最后
回头看,这场由"可变对象做 HashMap key"引发的"幽灵键"事故,真正教给我的,远不止"把 key 设成不可变"这一个技巧。它让我对"当我们把一个东西'登记/存放'进某个'按它的某种特征来组织、来定位'的系统时,我们其实和这个系统立下了一个隐含的契约:'我用来登记的那个特征,在你保管它期间不会变';系统正是信任这个特征稳定,才据它把东西安置到了一个固定的位置;而如果我们事后偷偷改了这个特征,系统并不会(也无法)知道、更不会自动重新安置——于是'它该在的位置'和'它实际在的位置'就永久错开了",有了一次刻骨的体会。我栽跟头,是因为我把一个"定位依据(hashCode)由其内容决定"的对象登记进了 HashMap,事后却又改了它的内容、从而改了它的定位依据——HashMap 在我 put 的那一刻,按它当时的 hashCode 给它安排了一个桶,这就像按身份证号把档案归进了某个抽屉;之后我改了它参与 hashCode 的字段,等于偷偷改了它的"身份证号";我再拿着新号去查,系统按新号算出一个新抽屉、那里空空如也,而它的档案还安静地躺在按旧号归档的旧抽屉里;系统没错,它忠实地按"放进来时的身份"归了档;错的是我,在它已经被按某个身份归档之后,又改了那个身份。这让我领悟到一个关于"身份稳定性与按身份定位"的深刻认知:任何"按特征定位/索引/归类"的系统(哈希表、有序树、数据库索引、缓存、注册表),都建立在一个根本假设之上:"被登记之物用来定位的那个特征是稳定的";这类系统为了高效,会在登记时就按当时的特征把东西安置到固定位置、并从此信任这个位置有效,而不会持续地去复查每个元素的特征有没有变;所以,凡是要交给这类系统按特征保管的东西,那个"定位特征"就必须在保管期间保持不变——要么让它天生不可变,要么在不得不改时,显式地走"先取出、再改、再重新登记"的流程,亲手维护"实际位置"和"应在位置"的一致;把一个"定位特征会变"的东西交给"按特征定位"的系统、又真的去改了那个特征,必然制造出"明明还在、却再也找不到"的幽灵。这给了我一种看待"一切'按某特征把东西存进某处'之事"时的清醒:每当我要把一个对象按它的某个特征(hashCode、排序键、唯一索引、缓存 key)存进一个容器或系统时,要追问"这个用来定位它的特征,在它被保管期间会变吗?如果会变,我是不是该让它不可变,或者在改它之前先把它从系统里取出来、改完再放回去"——保证"定位依据"在被依赖期间的稳定,绝不在一个东西已按某特征入库后、再偷偷改那个特征;"用不可变之物做定位依据、或改动前先取出再重新登记",是用对 HashMap key、也是和一切按特征定位的系统打交道的关键。认清 HashMap 靠 hashCode 定位、key 的 hash 在 map 里期间必须不变、首选不可变对象做 key——这,是我用一次"幽灵键"的事故,换来的、关于 Java、也关于如何尊重"按特征定位"系统之约定的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次拿一个带 setter 的对象去当 HashMap key 时,先停一秒想想"它参与 hashCode 的字段,放进去之后还会被改吗?要不要把它设成 final?",那我对着那个"size 是 1、却怎么也 get 不到"的幽灵键发懵的大半天,就值了。
—— 别看了 · 2026