读完这篇,你能做什么
能为业务对象定义一致相等规则,并解释可变键为什么会导致查询异常。
学习理由、准备要求与知识点
为什么学它
相等与哈希约定直接影响去重、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 去重,还是用排序比较结果去重,不能把这两套判断当成总是一样。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["明确逻辑键字段"] --> B["计算一致的 hashCode"]
B --> C["定位候选桶或结构"]
C --> D{"候选键 equals 是否成立"}
D -->|"是"| E["读取或更新同一映射"]
D -->|"否"| F["保留不同键,继续处理冲突"]
A --> G["作为键期间保持相关字段稳定"]按顺序看,每一步做了什么
- 先写明领域相等的含义。
- 选择稳定且一致的比较字段。
- 同时设计 equals 与
hashCode。 - 覆盖独立实例、
null与冲突测试。 - 检查继承和容器的比较规则是否一致。
03对照实现代码
Microsoft OpenJDK 17.0.20.1;java.base/java/util/Objects.java · java.util.Objects#equals(Object,Object)
以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。
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,就无法保证相等的不同实例一定有相同哈希;应配套实现。自动生成的方法也要检查可变字段和继承情况。源码虽然短,正确使用仍取决于业务如何定义“相同”。
代码先调用谁,再调用谁
Objects.equals先比较引用身份- 左引用非空时调用其 equals
- 具体类型执行自己的相等规则
- 哈希容器另行依赖相等与
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 的普通长度约束通常不会达到溢出,但未来改成持续流时该策略仍清楚。复杂度依赖散列表操作前提,并不把所有敌意碰撞输入都当作常数查找。
为什么这样写是对的处理前缀后,每个已出现逻辑键的次数准确,映射顺序等于这些键首次出现的顺序。
每条记录查询更新一次,结束时遍历 k 个键生成结果;具体最坏成本还取决于散列分布、容器冲突结构与键比较成本。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
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 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能为业务对象定义一致相等规则,并解释可变键为什么会导致查询异常。
本篇重点加练
重点加练相等契约与稳定业务键,让去重规则在需求变化后仍可检验。
- 自反对称传递一致
- 不可变值键
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01代码推演
分别创建两个普通 Point 实例、两个内容相同的
String实例和一个共享引用,列出 == 与 equals 的比较对象;先写出推演依据再执行,不使用字符串驻留的偶然行为构造证据。- 提交内容
- 对象构造代码、比较记录及依据。
- 怎样算完成
- 能区分默认行为与类型重写
- 样本包含同值不同实例
练到的知识点引用 == 与内容 equals · Object 默认相等语义
- 作业 02原理问答
为“租户编号+订单编号”值对象写一份相等契约,设计检验自反、对称、传递、一致性及哈希约束的测试样例组。说明每条性质需要多少对象和哪些比较。
- 提交内容
- 契约及逐项样本表。
- 怎样算完成
- 每条契约均有独立可执行检查
- 不把同哈希当作全部相等判据
练到的知识点自反对称传递一致 · 相等必须同哈希
- 作业 03原理问答
为一个自定义相等关系制定反例搜索计划:只测自反还遗漏哪些对象组合?给出测试数据产生规则及每组观察值,不使用已有 Point 父子类样例。
- 提交内容
- 对象组合表和契约测试草案。
- 怎样算完成
- 每项性质有独立检查
- 样本不只包含完全相同的字段
练到的知识点自反对称传递一致
综合应用
- 作业 04编程实践
实现不可变
DocumentKey与文档计数器:租户不同的同编号文档不得混算,同租户同编号按同一业务键处理;测试中主动构造不同键发生哈希碰撞的情形。- 提交内容
- 完整 Java 程序、自写碰撞及边界测试、运行证据。
- 怎样算完成
- 验证业务隔离和计数合同
- 碰撞测试确认输入确实碰撞
- 说明键字段稳定性
练到的知识点哈希冲突 · 不可变值键
- 作业 05排错实验
本地创建含
List<String>组件的 record,作为Map键插入后通过外部别名修改列表。记录插入前后相等、哈希、查询和遍历现象,再将组件改为数组重复观察;不预设两组实验具有相同语义。- 提交内容
- 两组最小复现、观测表及改进后测试。
- 怎样算完成
- 记录真实运行现象
- 区分组件类型带来的语义
- 改进方案有隔离性验证
练到的知识点record 组件相等 · 可变键失效风险
- 作业 06面试场景
用户实体创建时 id 为空,保存数据库后才获得 id,昵称允许修改。团队希望把新实体放入去重集合一直保存到提交结束,评审三类字段作为键的适用边界。
- 提交内容
- 生命周期时间线、候选键比较及验收用例。
- 怎样算完成
- 考虑未持久化阶段
- 考虑键在容器内的变化
- 业务身份定义明确
练到的知识点自反对称传递一致 · 不可变值键 · 可变键失效风险
- 作业 07编程实践
设计图书副本键:馆藏机构和副本编号决定身份,书名仅用于展示;实现键与登记操作,再修改展示数据验证身份政策,包含
null字段和大小写政策。- 提交内容
- Java 实现、自写相等契约与登记测试、运行证据。
- 怎样算完成
- 字段选择由业务定义支持
- 跨实例与展示变化分别验证
练到的知识点自反对称传递一致 · 不可变值键
- 作业 08设计评审
课程代码由“CS101”迁移为“CS-101”,历史数据两种格式并存。提出稳定键的迁移合同,规定新写入、历史查找和冲突记录如何识别,不要求实际迁移数据库。
- 提交内容
- 规范化边界、迁移样本与验收规则。
- 怎样算完成
- 保留原始身份判定依据
- 多种写法的关系由显式合同决定
练到的知识点不可变值键
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
掌握继承后:为
Point(x,y)与ColoredPoint(x,y,color)设计一个小型比较实验,检查父子类不同相等政策对集合的影响;提出一个可维护的领域建模方案。- 提交内容
- 三对象比较矩阵、容器实验和建模说明。
- 怎样算完成
- 检查对称与传递
- 说明扩展规则和代价
练到的知识点自反对称传递一致 · 相等必须同哈希 · record 组件相等
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 条件变化
图书副本键新增“分馆”维度,但旧系统没有分馆字段。判断是否可以让新旧对象互相 equals 而仍遵守契约;提出至少两种兼容目标并分别评估。
- 提交内容
- 新旧三对象比较矩阵与兼容性结论。
- 怎样算完成
- 比较涵盖对称和传递
- 结论注明领域身份前提
练到的知识点自反对称传递一致 · 不可变值键
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
Java 17 API:Object (新标签页)↗
逐条阅读 equals 与
hashCode的一致性约定,设计稳定业务键。 - 实践规范英文
Java 17 语言规范:类型、值与变量 (新标签页)↗
核对基本类型范围、引用类型、类型变量与可具体化类型,理解编译期约束。
- 深入参考文档英文
Java 17 API:HashMap (新标签页)↗
阅读散列分布、迭代、fail-fast 与非线程安全前提,区分实现和约定。
- 深入参考文档英文
OpenJDK 17u java.util.Objects (新标签页)↗
定位
Objects.equals的身份快捷判断、null处理与动态委托,联系Object契约。 - 深入参考文档英文
Java17 LinkedHashMap API (新标签页)↗
区分默认插入顺序与可选访问顺序。
- 深入参考文档英文
Java17 Record API (新标签页)↗
查看记录类组件相等与哈希的语言承诺。