qinghuan
← Java SE / 全部笔记

单元 19 / Java SE

P0 · 必会主线L1 · 基础

equals 与 hashCode:让“相同”有一致的含义

先决定两个对象怎样才算相同,再让 equals 和 hashCode 配合,理解为什么改动 Map 的键会影响查找。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能为业务对象定义一致相等规则,并解释可变键为什么会导致查询异常。

学习理由、准备要求与知识点

为什么学它

相等与哈希约定直接影响去重、Map 查询和业务键的可靠性。

先读这些内容

知识点清单

  • 引用 == 与内容 equals
  • Object 默认相等语义
  • 自反对称传递一致
  • 相等必须同哈希
  • 哈希冲突
  • 不可变值键
  • record 组件相等
  • 可变键失效风险

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

比较两个对象前,先决定你说的“相同”是什么意思。例如两份用户资料是按用户编号判断相同,还是只有同一个对象才算相同?引用上的 == 判断是不是同一个对象,Object 默认的 equals 也是如此;String 的 equals 则比较内容。金额还可能需要同时比较币种和数值,不能没想清业务规则就让 IDE 按所有字段生成比较方法。

equals 的规则必须自洽:对象等于自己,交换比较方向不改变结果,a 等于 b 且 b 等于 c 时 a 也应等于 c;相关内容未变,结果也应一致。非空对象与 null 比较应为 false。两个对象 equals 为 true 时,hashCode 必须相同;反过来不成立。哈希集合先用哈希值缩小查找范围,再比较对象,不能只把哈希值当成唯一编号。

参与相等与哈希的字段应在作为键期间保持稳定。可变键放入 HashMap 后再改相关字段,后续查询可能无法按新的哈希定位原条目。优先使用稳定 ID 或由不可变组件组成的值对象。Java17 record 自动提供基于组件的相等与哈希,但它是浅不可变;数组组件默认仍按数组自身的相等语义,不会自动变成逐元素比较。

文中这几个词,先认一认

身份相等
两个引用指向内存里的同一个对象,引用上的 == 检查的就是这种相同。
值相等
按这个类型规定的内容判断相同,例如字符串逐个字符相同。
哈希冲突
两个不同的键算出了相同哈希值,这是允许的,还要继续比较键本身。
相等契约
对象应等于自己;a 等于 b 时,b 也应等于 a;a 等于 b 且 b 等于 c 时,a 也应等于 c。比较依据没变就应保持结果,非空对象与 null 不相等;判断相等的对象还必须给出相同的 hashCode。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

哈希容器先用哈希信息找到候选位置,再检查是不是同一个键对象,或用 equals 判断是否相等。多个键的哈希发生冲突,并不表示它们就是同一个键;只要它们彼此不相等,且 equals 与 hashCode 遵守约定,容器仍能区分它们。

反过来,两个按业务规则相等的对象如果给出不同哈希值,容器就可能去不同位置查找,导致去重或读取不符合预期。所以要让 equals 与 hashCode 配合,而不是要求所有键都不发生冲突。

继承会让值相等更难设计。父类按编号比较,子类再要求额外字段相等,可能使父对象认为子对象相等,而子对象认为父对象不同,破坏对称性。使用 getClass、instanceof 或可扩展相等协议都有不同约束,不能只背某个模板;对于明确的值对象,限制继承常能缩小推理范围。

“相同”要按具体类型判断。数组的 equals 看的是不是同一个数组对象;要逐个比较元素,应使用相应的 Arrays 方法。BigDecimal.equals 还会比较 scale:数值相同,表示方式不同,也可能被判为不相等;compareTo 比较数值时,则可能把这些值判为相等,返回 0。

因此选择 HashSet 或 TreeSet 前,要确认业务想用 equals 与 hashCode 去重,还是用排序比较结果去重,不能把这两套判断当成总是一样。

正在加载流程图…

哈希负责缩小候选,equals 负责确认;冲突不会自动合并不同键。
查看流程图源码(Mermaid)
flowchart TD
 A["明确逻辑键字段"] --> B["计算一致的 hashCode"]
 B --> C["定位候选桶或结构"]
 C --> D{"候选键 equals 是否成立"}
 D -->|"是"| E["读取或更新同一映射"]
 D -->|"否"| F["保留不同键,继续处理冲突"]
 A --> G["作为键期间保持相关字段稳定"]

按顺序看,每一步做了什么

  1. 先写明领域相等的含义。
  2. 选择稳定且一致的比较字段。
  3. 同时设计 equals 与 hashCode。
  4. 覆盖独立实例、null 与冲突测试。
  5. 检查继承和容器的比较规则是否一致。

03对照实现代码

空值安全相等辅助为何仍依赖类型自身契约 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/util/Objects.java · java.util.Objects#equals(Object,Object)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
public final class SourceModel {
    public static boolean sameValue(Object left, Object right) {
        if (left == right) return true; // 同一引用也包含两边同时为 null。
        if (left == null) return false;
        // 辅助函数把实际内容比较交给左操作数的类型。
        return left.equals(right);
    }
}

真实 Objects.equals 先检查引用是否相同,再在左值非空时调用左对象的 equals。模型把短路表达式展开为分支,便于观察空值处理顺序。它没有反射遍历字段,也没有递归比较任意对象图;内容比较仍然由被调用类型决定。

当两边都是 null,身份分支返回 true;只有左边为 null 时返回 false;左边非空、右边为 null 时调用左对象的 equals,正确实现应返回 false。这个辅助方法减少调用点的空值判断,却不能替类型修复一个不遵守契约的 equals 实现。

如果 left.equals(right) 和反方向比较结果不同,交换 Objects.equals 的两个参数也可能改变答案。集合与通用算法要求相等比较保持对称,应该修复类型自己的规则,而不是每次调用都双向比较、猜业务想要哪个结果。

Objects.equals 不负责计算哈希值。hashCode 和 hash 工具能帮助生成数值,但用哪些字段仍要由作者决定。若把 equals 改为按内容判断相等,却保留 Object 的默认 hashCode,就无法保证相等的不同实例一定有相同哈希;应配套实现。自动生成的方法也要检查可变字段和继承情况。源码虽然短,正确使用仍取决于业务如何定义“相同”。

代码先调用谁,再调用谁

  1. Objects.equals 先比较引用身份
  2. 左引用非空时调用其 equals
  3. 具体类型执行自己的相等规则
  4. 哈希容器另行依赖相等与 hashCode 一致

04动手写一个例子

按不可变复合键计数并保留首次出现顺序

记录由 group 与 id 组成的 Key,按两组件共同相等汇总出现次数,输出顺序按键首次出现。允许空输入,拒绝 null 列表和 null 键。哈希冲突的不同键必须分别计数。

输入 1
[(A,1),(B,2),(A,1)]
输出 1
[(A,1):2,(B,2):1]
输入 2
[(Aa,1),(BB,1)]
输出 2
两项分别为1
输入 3
[]
输出 3
[]

用 Key 表达复合键,避免把两个字段拼成字符串后依赖易冲突的分隔符。LinkedHashMap 负责按首次插入顺序保存计数;遇到已有逻辑键就增加对应次数,遇到新键才增加一个输出位置。不同实例但组件相等应该进入同一计数项。

合并条件来自 Key.equals,而不是原对象身份或哈希值。特意使用 Aa 与 BB 字符串构造碰撞样本,可以说明相同哈希并不意味着两个不同组名被合并。测试直接确认这组键发生哈希冲突,再确认结果仍然有两项,才能真正覆盖碰撞处理而非仅测试普通不同键。

扫描结束后把映射转成结果列表,使输出格式与内部数据结构分开。次数用精确加法表达不能静默溢出的契约;对于单次 Java List 的普通长度约束通常不会达到溢出,但未来改成持续流时该策略仍清楚。复杂度依赖散列表操作前提,并不把所有敌意碰撞输入都当作常数查找。

为什么这样写是对的处理前缀后,每个已出现逻辑键的次数准确,映射顺序等于这些键首次出现的顺序。

时间 良好哈希分布下期望 O(n);异常冲突分布可能增加比较成本。额外空间 O(k),k 为不同逻辑键数量,另有同阶输出。

每条记录查询更新一次,结束时遍历 k 个键生成结果;具体最坏成本还取决于散列分布、容器冲突结构与键比较成本。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 43 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public class Main {
    record Key(String group, int id) {
        Key { Objects.requireNonNull(group, "group"); }
    }
    record Count(Key key, int times) {}
    static List<Count> count(List<Key> input) {
        if (input == null) throw new IllegalArgumentException("input");
        Map<Key,Integer> counts = new LinkedHashMap<>();
        for (Key key : input) {
            if (key == null) throw new IllegalArgumentException("key");
            // 相等键更新原位置,不改变首次插入顺序。
            counts.merge(key, 1, Math::addExact);
        }
        List<Count> output = new ArrayList<>();
        counts.forEach((key, times) -> output.add(new Count(key,times)));
        return output;
    }
    static void require(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }
    public static void main(String[] args) {
        Key a = new Key("A",1), b = new Key("B",2);
        require(count(List.of(a,b,new Key("A",1))).equals(
                List.of(new Count(a,2),new Count(b,1))), "order and equality");
        require(count(List.of()).isEmpty(), "empty");
        // 确认样本实际冲突,再验证不同逻辑键没有被合并。
        Key x = new Key("Aa",1), y = new Key("BB",1);
        require(x.hashCode() == y.hashCode() && !x.equals(y), "collision setup");
        require(count(List.of(x,y)).size() == 2, "collision handling");
        require(count(List.of(new Key("A",1),new Key("A",2))).size() == 2, "composite key");
        // 非法元素与合法空列表采用不同契约。
        List<Key> invalid = new ArrayList<>(); invalid.add(null);
        boolean rejected = false;
        try { count(invalid); } catch (IllegalArgumentException e) { rejected = true; }
        require(rejected, "null element");
        System.out.println("PASS 6");
    }
}

这些特殊情况也要试一试

  • 两个独立但同值 Key 应合并。
  • 哈希碰撞但不相等的 Key 保持不同。
  • 相同 group 不同 id 是不同键。
  • 首次顺序不随计数更新改变。
进阶选读 · 可选深入:敌意碰撞与规范化键

场景外部输入可控制大量键,或业务要求不同写法视为同一键。

做法先限制输入规模并设计稳定规范化,再评估冲突分布和比较成本;不要用随机改变每次 hashCode 的方式试图消除碰撞。

复杂度需同时分析哈希成本、equals 成本与容器结构,不能只看键数量。

好处与代价规范化可能改变业务身份,抗碰撞措施也不能替代配额与资源边界。

05项目里怎么用

实体身份与值对象相等应分开讨论。数据库实体在持久化前可能没有 ID,若把尚未分配的 null ID 视为相等,会意外合并多个新对象;若生成后再改变参与哈希的字段,放在集合中的键又可能失效。可以避免把这种生命周期变化对象直接作为哈希键,改用稳定业务键或专门标识。

缓存、去重和重复请求处理都需要一致的键规则,但 Java 的 equals 不会自动与数据库唯一约束、其他语言比较方式保持一致。应明确大小写、空白整理和租户编号等规则。漏掉租户,可能让不同用户的数据混到一起;把无关更新时间加进键,又可能让本应相同的对象查不到缓存。

测试相等契约要有不同实例和多个对象组合。自反测试容易通过,对称与传递需要两个或三个有代表性的对象,哈希一致性则应在相等样本上明确断言。碰撞样本验证容器正确性,可变键实验帮助理解风险;正式设计优先消除可变键,而不是写更多补救查找逻辑。

容易踩的坑

  • 只重写 equals,不重写 hashCode。
  • 认为哈希相同就应当合并。
  • 把可变字段放入长期使用的键。
  • 父子类相等规则不同导致对称或传递失败。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
对象身份键实例关系清楚同值不同实例不合并对象图转换与实例跟踪
不可变值键可跨实例查找且容易测试必须选择正确业务字段缓存、映射与去重
排序等价键支持有序范围查询比较器的零值语义需与集合目的匹配TreeMap 与有序索引

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01相同 hashCode 是否要求 equals 为 true?

展开参考答案与推导

不要求。哈希空间有限,冲突合法且不可完全避免;只有相等对象必须同哈希。容器还要检查 equals,不能把哈希值直接作为唯一业务标识。

02为什么 Objects.equals 不能修复坏 equals?

展开参考答案与推导

它主要处理 null 和同引用的快捷情况,其余比较直接委托左对象。若类型自身不对称、不传递或依赖不稳定状态,工具方法会继续受到这些问题影响。

03record 是否自动深度比较数组组件?

展开参考答案与推导

不会。自动相等基于组件的相等语义,数组通常仍使用身份比较;组件也可能可变。若业务需要内容相等与不可变,需要专门设计复制和比较,而不是仅把 class 改成 record。

换个情况,还会处理吗

01键插入后改名称,再也 get 不到,为什么?

展开参考答案与推导

若名称参与 hashCode,条目仍按插入时的信息存放,后续查询用新哈希可能找不到原位置。应使用稳定键;不要以重新扫描整个 Map 当作常规修复,否则容器契约仍已被破坏。

02跨租户计数被合并,应该只换更强哈希吗?

展开参考答案与推导

先检查 equals 的键字段是否包含租户。漏掉业务维度属于逻辑身份错误,更强哈希不会恢复被相等规则合并的信息;应修正复合键及相关持久化或缓存契约。

03HashSet 与 TreeSet 的去重结果不同怎么办?

展开参考答案与推导

检查 equals/hashCode 与比较器 compare 返回零的含义是否一致,例如 BigDecimal 的 scale 差异。选择容器前明确业务等价关系,不能默认所有集合都按同一种“相同”判断。

写代码试一试

01验证相等键哈希一致

输入 / 条件
Key(A,1) 与新 Key(A,1)
输出 / 预期
equals true 且哈希相同

提示 同时断言两项。

展开参考答案与推导

创建两个独立实例,先断言 equals,再断言 hashCode 相同。不要只用同一个对象与自己比较,那无法检查跨实例的值键设计是否正确。

02把键增加租户维度

输入 / 条件
租户T1和T2,同group和id
输出 / 预期
应分别计数

提示 键组件应包含租户。

展开参考答案与推导

把 Key 改为包含 tenant 的不可变记录,并更新构造校验与预期结果。测试同租户相同值合并、不同租户不合并,确认 equals 和 hashCode 一起覆盖新维度。

03验证更新不移动顺序

输入 / 条件
A,B,A,B,A
输出 / 预期
A:3,B:2

提示 使用默认插入顺序。

展开参考答案与推导

构造五项输入并比较结果列表严格等于 A 三次在前、B 两次在后。不要仅比较映射内容,因为内容相同并不能证明输出顺序符合接口承诺。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能为业务对象定义一致相等规则,并解释可变键为什么会导致查询异常。

本篇重点加练

重点加练相等契约与稳定业务键,让去重规则在需求变化后仍可检验。

  • 自反对称传递一致
  • 不可变值键

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    分别创建两个普通 Point 实例、两个内容相同的 String 实例和一个共享引用,列出 == 与 equals 的比较对象;先写出推演依据再执行,不使用字符串驻留的偶然行为构造证据。

    提交内容
    对象构造代码、比较记录及依据。
    怎样算完成
    • 能区分默认行为与类型重写
    • 样本包含同值不同实例

    练到的知识点引用 == 与内容 equals · Object 默认相等语义

  2. 作业 02原理问答

    为“租户编号+订单编号”值对象写一份相等契约,设计检验自反、对称、传递、一致性及哈希约束的测试样例组。说明每条性质需要多少对象和哪些比较。

    提交内容
    契约及逐项样本表。
    怎样算完成
    • 每条契约均有独立可执行检查
    • 不把同哈希当作全部相等判据

    练到的知识点自反对称传递一致 · 相等必须同哈希

  3. 作业 03原理问答

    为一个自定义相等关系制定反例搜索计划:只测自反还遗漏哪些对象组合?给出测试数据产生规则及每组观察值,不使用已有 Point 父子类样例。

    提交内容
    对象组合表和契约测试草案。
    怎样算完成
    • 每项性质有独立检查
    • 样本不只包含完全相同的字段

    练到的知识点自反对称传递一致

综合应用

  1. 作业 04编程实践

    实现不可变 DocumentKey 与文档计数器:租户不同的同编号文档不得混算,同租户同编号按同一业务键处理;测试中主动构造不同键发生哈希碰撞的情形。

    提交内容
    完整 Java 程序、自写碰撞及边界测试、运行证据。
    怎样算完成
    • 验证业务隔离和计数合同
    • 碰撞测试确认输入确实碰撞
    • 说明键字段稳定性

    练到的知识点哈希冲突 · 不可变值键

  2. 作业 05排错实验

    本地创建含 List<String> 组件的 record,作为 Map 键插入后通过外部别名修改列表。记录插入前后相等、哈希、查询和遍历现象,再将组件改为数组重复观察;不预设两组实验具有相同语义。

    提交内容
    两组最小复现、观测表及改进后测试。
    怎样算完成
    • 记录真实运行现象
    • 区分组件类型带来的语义
    • 改进方案有隔离性验证

    练到的知识点record 组件相等 · 可变键失效风险

  3. 作业 06面试场景

    用户实体创建时 id 为空,保存数据库后才获得 id,昵称允许修改。团队希望把新实体放入去重集合一直保存到提交结束,评审三类字段作为键的适用边界。

    提交内容
    生命周期时间线、候选键比较及验收用例。
    怎样算完成
    • 考虑未持久化阶段
    • 考虑键在容器内的变化
    • 业务身份定义明确

    练到的知识点自反对称传递一致 · 不可变值键 · 可变键失效风险

  4. 作业 07编程实践

    设计图书副本键:馆藏机构和副本编号决定身份,书名仅用于展示;实现键与登记操作,再修改展示数据验证身份政策,包含 null 字段和大小写政策。

    提交内容
    Java 实现、自写相等契约与登记测试、运行证据。
    怎样算完成
    • 字段选择由业务定义支持
    • 跨实例与展示变化分别验证

    练到的知识点自反对称传递一致 · 不可变值键

  5. 作业 08设计评审

    课程代码由“CS101”迁移为“CS-101”,历史数据两种格式并存。提出稳定键的迁移合同,规定新写入、历史查找和冲突记录如何识别,不要求实际迁移数据库。

    提交内容
    规范化边界、迁移样本与验收规则。
    怎样算完成
    • 保留原始身份判定依据
    • 多种写法的关系由显式合同决定

    练到的知识点不可变值键

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    掌握继承后:为 Point(x,y) 与 ColoredPoint(x,y,color) 设计一个小型比较实验,检查父子类不同相等政策对集合的影响;提出一个可维护的领域建模方案。

    提交内容
    三对象比较矩阵、容器实验和建模说明。
    怎样算完成
    • 检查对称与传递
    • 说明扩展规则和代价

    练到的知识点自反对称传递一致 · 相等必须同哈希 · record 组件相等

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    图书副本键新增“分馆”维度,但旧系统没有分馆字段。判断是否可以让新旧对象互相 equals 而仍遵守契约;提出至少两种兼容目标并分别评估。

    提交内容
    新旧三对象比较矩阵与兼容性结论。
    怎样算完成
    • 比较涵盖对称和传递
    • 结论注明领域身份前提

    练到的知识点自反对称传递一致 · 不可变值键

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗