从一个 HashMap put 后 get 不到的典型问题出发,解释可变 Key、equals/hashCode 契约、HashSet 去重和 TreeMap 比较器之间的关系,并给出 Java 工程中的建模、接口设计与排查建议。
问题背景:值还在,为什么就是取不到
在 Java 服务中,下面这类问题并不少见:对象明明已经放进了 HashMap,调试时也能看到集合里有数据,但随后使用“同一个对象”或“看起来相等的对象”去查询,却得到 null。类似的问题还会出现在 HashSet.contains 返回 false、去重结果异常,甚至缓存中出现无法清理的条目。
很多人第一反应是怀疑并发、序列化或集合实现。但在单线程代码里,更常见的原因是:作为 Key 的对象参与 equals 或 hashCode 计算的字段,在放入集合之后发生了变化。
这不是 HashMap 随机出错,而是对象的身份发生了变化,集合却不会主动替它重新整理位置。
一个可以稳定复现的例子
先定义一个订单查询条件:
import java.util.HashMap;
import java.util.Map;
public class MutableKeyDemo {
static class OrderQuery {
private String orderNo;
OrderQuery(String orderNo) {
this.orderNo = orderNo;
}
void setOrderNo(String orderNo) {
this.orderNo = orderNo;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof OrderQuery that)) {
return false;
}
return java.util.Objects.equals(orderNo, that.orderNo);
}
@Override
public int hashCode() {
return java.util.Objects.hash(orderNo);
}
@Override
public String toString() {
return "OrderQuery{orderNo='" + orderNo + "'}";
}
}
public static void main(String[] args) {
Map<OrderQuery, String> cache = new HashMap<>();
OrderQuery query = new OrderQuery("A001");
cache.put(query, "订单已支付");
System.out.println(cache.get(query)); // 订单已支付
query.setOrderNo("A002");
System.out.println(cache.get(query)); // null
System.out.println(cache.containsKey(query)); // false
System.out.println(cache.size()); // 1
System.out.println(cache); // 仍然能看到一个条目
}
}HashMap 插入元素时,会根据 Key 当时的 hashCode 计算桶的位置,并在桶内使用 equals 判断是否为同一个 Key。第一次插入时,query 的订单号是 A001,集合按照这个状态完成了定位。
但修改订单号之后,query.hashCode() 已经变成了另一个值。查询时,HashMap 会按照新的哈希值去寻找桶,自然找不到原来那个位置的条目。原来的节点并没有消失,只是它仍然躺在旧桶里,而当前 Key 已经无法通过正常查找路径抵达那里。
因此,size() 仍然是 1 并不矛盾。集合里有这个节点,不代表它还可以被当前状态的 Key 正常检索。
equals 和 hashCode 到底约束了什么
只要两个对象通过 equals 判断相等,它们就必须拥有相同的 hashCode。反过来,哈希值相同并不代表两个对象一定相等,因为不同对象可能发生哈希冲突。
对于 HashMap 和 HashSet,一次典型查询可以理解为两个阶段:
- 根据查询对象的
hashCode找到可能的桶。 - 在桶内使用
equals判断是否匹配。
所以,参与 equals 和 hashCode 的字段必须满足一个重要条件:对象作为 Key 或 Set 元素存活期间,这些字段不能改变。
这也是为什么 String、包装类等值对象适合做 Key。它们的内容不可变,放入集合后,哈希值和相等关系不会悄悄变化。
需要注意的是,final 只能保证引用本身不能重新指向另一个对象,并不保证引用指向的对象不可变:
final class QueryKey {
private final String orderNo;
QueryKey(String orderNo) {
this.orderNo = java.util.Objects.requireNonNull(orderNo);
}
public String orderNo() {
return orderNo;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof QueryKey that)) {
return false;
}
return orderNo.equals(that.orderNo);
}
@Override
public int hashCode() {
return orderNo.hashCode();
}
}这里的 String 本身不可变,因此 QueryKey 也具备稳定的身份。实际项目中,如果字段是 List、Map 或自定义可变对象,就不能只因为字段声明了 final,便认为 Key 是安全的。
工程上更稳妥的建模方式
1. 把集合定位条件建模成不可变值对象
不要直接把请求 DTO、数据库实体或带有大量 setter 的对象作为 Map Key。它们通常承担数据接收和状态变更职责,不适合作为稳定身份。
可以专门定义一个只包含定位字段的 Key 类型:
final class PriceKey {
private final long productId;
private final String region;
PriceKey(long productId, String region) {
if (productId <= 0) {
throw new IllegalArgumentException("productId must be positive");
}
this.productId = productId;
this.region = java.util.Objects.requireNonNull(region);
}
public long productId() {
return productId;
}
public String region() {
return region;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof PriceKey that)) {
return false;
}
return productId == that.productId
&& region.equals(that.region);
}
@Override
public int hashCode() {
return java.util.Objects.hash(productId, region);
}
}这个类型的职责非常单一:描述价格查询的身份。它不提供修改方法,也不暴露可变内部状态。业务对象需要变化时,创建一个新的 Key,而不是修改已经进入集合的对象。
2. 只有确实需要复合条件时,才使用对象 Key
如果 Key 只是两个简单字段,工程中也可以使用稳定的字符串或数字组合,但要注意分隔符冲突、格式变化和类型信息丢失等问题。例如把 1 和 23 拼成 123,可能与 12 和 3 发生碰撞。专用类型通常更清晰,也更容易在编译期约束调用方。
3. 集合 API 返回不可修改视图,但不要混淆两个问题
防止调用者修改集合结构,和防止 Key 状态变化,是两个不同的问题:
Map<PriceKey, Integer> source = new HashMap<>();
Map<PriceKey, Integer> view = java.util.Collections.unmodifiableMap(source);view 不允许通过自身的 API 添加或删除元素,但如果 source 后续发生变化,view 仍会反映这些变化;同时,若 Key 内部存在可变字段,包装集合也不会修复哈希定位问题。真正需要隔离时,应在边界处复制数据,并保证元素本身也符合不可变要求。
HashSet、TreeMap 中的相似陷阱
HashSet 本质上依赖对象的 hashCode 和 equals,因此可变元素会产生同样的问题:加入集合后修改参与相等判断的字段,可能导致 contains 和 remove 失败。
TreeMap 和 TreeSet 则依赖自然排序或传入的 Comparator。如果比较器认为两个对象“比较结果为零”,它们在有序集合中就会被视为同一个排序位置,即使它们的 equals 返回 false。例如只按用户名排序,却把租户 ID 忽略,就可能让不同租户的用户互相覆盖。
因此,有序集合也需要一个稳定、完整且符合业务身份的比较规则:
var users = new java.util.TreeSet<User>(
java.util.Comparator.comparing(User::tenantId)
.thenComparing(User::username)
);比较器使用的字段同样不应在元素进入集合后被修改。否则,元素可能处于错误的排序位置,后续查找结果会变得不可预测。
常见排查顺序
遇到“集合里明明有数据但查不到”时,可以按以下顺序检查:
- 确认集合实际类型,是
HashMap、HashSet还是TreeMap、TreeSet。 - 检查 Key 或元素是否在放入之后被调用过 setter,尤其关注 ID、状态、类型、租户等字段。
- 比较插入前后
hashCode是否变化,并检查equals是否使用了同一组字段。 - 确认是否混用了不同类型但字段看起来相同的对象,或者
equals没有满足对称性和传递性。 - 对有序集合检查
Comparator是否把不应相同的对象比较成了零。 - 检查是否在集合复制、缓存封装或序列化过程中生成了新的对象,并确认它们的值语义一致。
调试时可以打印 Key 的关键字段、hashCode、集合大小和遍历结果,但不要只盯着 toString()。很多类的 toString() 并没有展示参与相等判断的全部字段。
实践建议
在代码评审中,可以把以下规则作为简单检查表:
- Map Key 和 Set 元素优先使用不可变类型。
- 不把 Entity、请求 DTO、带 setter 的配置对象直接作为 Key。
- 重写
equals时必须同步重写hashCode。 equals与hashCode使用的字段集合要保持一致。- 包含集合字段的值对象要做防御性复制,避免内部集合被外部修改。
TreeMap的比较器要覆盖真正的业务唯一性,不能只为排序方便而省略字段。- 如果对象身份需要变化,先从集合中移除旧对象,再以新状态重新加入;更好的方式通常是直接创建新对象。
总结
HashMap 的核心并不是“记住某个对象”,而是根据 Key 的哈希值和相等关系组织数据。Key 一旦进入集合,它参与身份判断的状态就应当保持稳定。可变 Key 导致的查询失败,本质上是对象状态和集合索引之间失去了同步。
在工程实践中,最可靠的做法不是记住某个修复技巧,而是把“集合定位条件”单独建模为不可变值对象,并让 equals、hashCode 或比较器完整表达业务身份。这样既能减少运行时的隐蔽错误,也能让代码的意图在类型和接口层面直接呈现出来。