从一个 HashMap put 后 get 不到的典型问题出发,解释可变 Key、equals/hashCode 契约、HashSet 去重和 TreeMap 比较器之间的关系,并给出 Java 工程中的建模、接口设计与排查建议。

问题背景:值还在,为什么就是取不到

在 Java 服务中,下面这类问题并不少见:对象明明已经放进了 HashMap,调试时也能看到集合里有数据,但随后使用“同一个对象”或“看起来相等的对象”去查询,却得到 null。类似的问题还会出现在 HashSet.contains 返回 false、去重结果异常,甚至缓存中出现无法清理的条目。

很多人第一反应是怀疑并发、序列化或集合实现。但在单线程代码里,更常见的原因是:作为 Key 的对象参与 equalshashCode 计算的字段,在放入集合之后发生了变化

这不是 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 正常检索。

equalshashCode 到底约束了什么

只要两个对象通过 equals 判断相等,它们就必须拥有相同的 hashCode。反过来,哈希值相同并不代表两个对象一定相等,因为不同对象可能发生哈希冲突。

对于 HashMapHashSet,一次典型查询可以理解为两个阶段:

  1. 根据查询对象的 hashCode 找到可能的桶。
  2. 在桶内使用 equals 判断是否匹配。

所以,参与 equalshashCode 的字段必须满足一个重要条件:对象作为 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 也具备稳定的身份。实际项目中,如果字段是 ListMap 或自定义可变对象,就不能只因为字段声明了 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 只是两个简单字段,工程中也可以使用稳定的字符串或数字组合,但要注意分隔符冲突、格式变化和类型信息丢失等问题。例如把 123 拼成 123,可能与 123 发生碰撞。专用类型通常更清晰,也更容易在编译期约束调用方。

3. 集合 API 返回不可修改视图,但不要混淆两个问题

防止调用者修改集合结构,和防止 Key 状态变化,是两个不同的问题:

Map<PriceKey, Integer> source = new HashMap<>();
Map<PriceKey, Integer> view = java.util.Collections.unmodifiableMap(source);

view 不允许通过自身的 API 添加或删除元素,但如果 source 后续发生变化,view 仍会反映这些变化;同时,若 Key 内部存在可变字段,包装集合也不会修复哈希定位问题。真正需要隔离时,应在边界处复制数据,并保证元素本身也符合不可变要求。

HashSetTreeMap 中的相似陷阱

HashSet 本质上依赖对象的 hashCodeequals,因此可变元素会产生同样的问题:加入集合后修改参与相等判断的字段,可能导致 containsremove 失败。

TreeMapTreeSet 则依赖自然排序或传入的 Comparator。如果比较器认为两个对象“比较结果为零”,它们在有序集合中就会被视为同一个排序位置,即使它们的 equals 返回 false。例如只按用户名排序,却把租户 ID 忽略,就可能让不同租户的用户互相覆盖。

因此,有序集合也需要一个稳定、完整且符合业务身份的比较规则:

var users = new java.util.TreeSet<User>(
        java.util.Comparator.comparing(User::tenantId)
                .thenComparing(User::username)
);

比较器使用的字段同样不应在元素进入集合后被修改。否则,元素可能处于错误的排序位置,后续查找结果会变得不可预测。

常见排查顺序

遇到“集合里明明有数据但查不到”时,可以按以下顺序检查:

  1. 确认集合实际类型,是 HashMapHashSet 还是 TreeMapTreeSet
  2. 检查 Key 或元素是否在放入之后被调用过 setter,尤其关注 ID、状态、类型、租户等字段。
  3. 比较插入前后 hashCode 是否变化,并检查 equals 是否使用了同一组字段。
  4. 确认是否混用了不同类型但字段看起来相同的对象,或者 equals 没有满足对称性和传递性。
  5. 对有序集合检查 Comparator 是否把不应相同的对象比较成了零。
  6. 检查是否在集合复制、缓存封装或序列化过程中生成了新的对象,并确认它们的值语义一致。

调试时可以打印 Key 的关键字段、hashCode、集合大小和遍历结果,但不要只盯着 toString()。很多类的 toString() 并没有展示参与相等判断的全部字段。

实践建议

在代码评审中,可以把以下规则作为简单检查表:

  • Map Key 和 Set 元素优先使用不可变类型。
  • 不把 Entity、请求 DTO、带 setter 的配置对象直接作为 Key。
  • 重写 equals 时必须同步重写 hashCode
  • equalshashCode 使用的字段集合要保持一致。
  • 包含集合字段的值对象要做防御性复制,避免内部集合被外部修改。
  • TreeMap 的比较器要覆盖真正的业务唯一性,不能只为排序方便而省略字段。
  • 如果对象身份需要变化,先从集合中移除旧对象,再以新状态重新加入;更好的方式通常是直接创建新对象。

总结

HashMap 的核心并不是“记住某个对象”,而是根据 Key 的哈希值和相等关系组织数据。Key 一旦进入集合,它参与身份判断的状态就应当保持稳定。可变 Key 导致的查询失败,本质上是对象状态和集合索引之间失去了同步。

在工程实践中,最可靠的做法不是记住某个修复技巧,而是把“集合定位条件”单独建模为不可变值对象,并让 equalshashCode 或比较器完整表达业务身份。这样既能减少运行时的隐蔽错误,也能让代码的意图在类型和接口层面直接呈现出来。

最后修改:2026 年 08 月 29 日
如果觉得我的文章对你有用,请随意赞赏