对象比较规则
1. 这是什么
对象比较规则主要包括 ==、equals、hashCode、compareTo 和 Comparator。
它们决定了对象如何判等、如何散列、如何排序。
一句话理解:
==解决“是不是同一个对象引用”equals解决“内容上算不算相等”hashCode解决“哈希结构里应该落到哪里”compareTo/Comparator解决“排序时谁在前谁在后”
2. 为什么重要
如果对象比较规则设计不正确,很多看起来奇怪的问题都会出现:
HashSet去重失效HashMap放进去的 key 又取不出来TreeSet莫名其妙少元素- 排序结果不稳定
- 缓存命中异常
这不是语法细节,而是 Java 建模能力里非常关键的一环。
3. 先建立直觉:对象“相同”到底有几种含义
在 Java 里,“相同”至少有三种常见语义:
| 语义 | 典型工具 | 含义 |
|---|---|---|
| 引用相同 | == | 两个变量是否指向同一个对象 |
| 逻辑相等 | equals | 两个对象内容上是否应视为同一个值 |
| 排序结果相同 | compareTo / Comparator 返回 0 | 两个对象在排序规则下是否被视为同一位置 |
这三件事很像,但绝不是同一件事。
很多 bug 的根源,就是把这三种“相同”混在了一起。
4. 核心内容
4.1 == 和 equals 的区别
== 比较的是:
- 基本类型时:值是否相等
- 引用类型时:是否是同一个对象
equals 比较的是:
- 逻辑意义上的相等性
- 默认实现和
==一样 - 但很多类会重写它,例如
String、Integer、BigDecimal等
看一个最典型的例子:
String a = new String("java");
String b = new String("java");
System.out.println(a == b); // false
System.out.println(a.equals(b)); // true这里说明:
a和b不是同一个对象,所以==为false- 但它们内容一样,所以
equals为true
4.2 默认 equals 行为是什么
如果一个类不重写 equals,那么它继承自 Object 的默认实现,效果基本等同于:
- 只有两个引用指向同一个对象时才算相等
这意味着:
- 如果你的类表达的是“值对象”概念,例如用户编号、订单编号、坐标、日期范围
- 那往往应该按“业务字段是否相等”来重写
equals
4.3 hashCode 的契约要求
hashCode 是给哈希结构使用的。
它的核心契约非常重要:
- 如果两个对象
equals为true,那么它们的hashCode必须相同 - 如果两个对象
equals为false,它们的hashCode可以相同,也可以不同 - 在参与比较的字段不变的前提下,多次调用
hashCode结果应保持一致
最重要的一条是:
- 相等对象必须具有相同哈希值
但反过来不成立:
- 哈希值相同,不代表对象一定相等
因为哈希冲突是允许存在的。
4.4 为什么重写 equals 通常要一起重写 hashCode
因为哈希结构,例如:
HashSetHashMapLinkedHashSetLinkedHashMap
都先依赖 hashCode 找桶,再依赖 equals 确认是否同一个逻辑对象。
如果你只重写 equals,不重写 hashCode,就可能导致:
- 逻辑上相等的对象被放进不同桶
HashSet无法正确去重HashMap用“看起来一样”的 key 取不回值
4.5 Comparable 是自然顺序
如果一个类“自己知道自己该怎么排序”,通常会实现 Comparable<T>。
public class User implements Comparable<User> {
private final long id;
public User(long id) {
this.id = id;
}
@Override
public int compareTo(User other) {
return Long.compare(this.id, other.id);
}
}这叫自然顺序。
常见场景:
Collections.sort(list)Arrays.sort(array)TreeSet/TreeMap默认排序
4.6 Comparator 是外部排序规则
如果对象本身不适合固定一种排序方式,或者你想临时切换排序规则,就用 Comparator。
Comparator<User> byName = Comparator.comparing(User::getName);
Comparator<User> byAgeDesc = Comparator.comparingInt(User::getAge).reversed();它适合:
- 同一类对象需要多种排序方式
- 你不想把排序规则耦合到实体类自身
4.7 哪些集合用哪套比较规则
| 容器 | 主要依赖什么 |
|---|---|
HashSet / HashMap | hashCode + equals |
LinkedHashSet / LinkedHashMap | hashCode + equals,外加顺序链 |
TreeSet / TreeMap | compareTo 或 Comparator |
Collections.sort / List.sort | compareTo 或 Comparator |
这张表非常重要,因为它解释了为什么:
- 在
HashSet里能共存的对象,未必能在TreeSet里共存 - 排序规则如果把两个对象比较成
0,TreeSet就可能把它们当成重复元素
5. equals 的基本契约
一个规范的 equals 通常要满足这些性质:
- 自反性:
x.equals(x)必须为true - 对称性:
x.equals(y)和y.equals(x)结果一致 - 传递性:如果
x.equals(y)且y.equals(z),那么x.equals(z)也应为true - 一致性:对象状态不变时,多次比较结果应一致
- 非空性:
x.equals(null)应返回false
你不一定要背术语,但这些性质要体现在实现里。
6. 排序相等和逻辑相等不是一回事
这是对象比较里非常容易踩坑的一点。
6.1 compareTo 返回 0 的含义
当两个对象:
compareTo返回0- 或
Comparator.compare(a, b)返回0
就表示它们在这套排序规则下是“同一排序位置”。
对于 TreeSet / TreeMap 来说,这通常意味着:
- 后来的元素会被视为“重复”
- 不一定真的插进去
6.2 为什么这会出问题
如果你定义:
equals按id判断Comparator却只按score判断
那么两个不同 id、但 score 一样的对象:
- 在
HashSet看来是两个元素 - 在
TreeSet看来可能是一个元素
这会让代码阅读者非常困惑。
工程上的建议是:
- 如果对象会进入
TreeSet/TreeMap - 尽量保证排序相等规则和逻辑相等规则语义一致,或者至少你非常清楚差异
7. 对象作为 Map key 的设计注意点
7.1 最重要的原则:参与比较的字段尽量不可变
如果 key 参与 equals / hashCode 的字段发生变化,就会出现严重问题:
- 放进去时按旧哈希落桶
- 修改后按新哈希去查找
- 结果就是“明明是同一个对象,却找不到了”
所以:
- 作为
HashMapkey 的对象,最好设计成不可变对象 - 至少参与
equals/hashCode的字段不要在放入后再变化
7.2 key 设计的推荐做法
- 尽量使用不可变字段
- 明确比较基于哪些业务字段
equals和hashCode使用相同字段集合- 不要把临时状态、可变计数器、时间戳等易变字段纳入哈希计算
8. 常见问题
8.1 重写了 equals 却没重写 hashCode
这是最经典的错误。
如果你的对象要放进哈希结构,这种写法基本注定会出问题。
8.2 把 == 当成内容比较
尤其是比较:
String- 包装类型
- 自定义对象
时,如果你真正想比较内容,应优先考虑 equals。
8.3 排序规则和判等规则互相冲突
你以为只是“排个序”,但一旦对象进入 TreeSet / TreeMap,排序规则就同时决定了去重行为。
8.4 让可变对象直接作为哈希结构 key
这会导致:
get失败containsKey失败- 逻辑上已经存在的元素仿佛“消失”
9. 动手验证
这一节可以直接复制运行,边看边验证。
9.1 准备一个可运行示例
新建文件 ObjectComparisonDemo.java,内容如下:
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.TreeSet;
public class ObjectComparisonDemo {
static final class User {
private final long id;
private final String name;
private final int score;
User(long id, String name, int score) {
this.id = id;
this.name = name;
this.score = score;
}
long getId() {
return id;
}
String getName() {
return name;
}
int getScore() {
return score;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof User)) {
return false;
}
User other = (User) obj;
return id == other.id;
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public String toString() {
return "User{id=" + id + ",name=" + name + ",score=" + score + "}";
}
}
static final class Student implements Comparable<Student> {
private final String name;
private final int age;
Student(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public int compareTo(Student other) {
return Integer.compare(this.age, other.age);
}
@Override
public String toString() {
return name + ":" + age;
}
}
static final class MutableKey {
private String code;
MutableKey(String code) {
this.code = code;
}
void setCode(String code) {
this.code = code;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof MutableKey)) {
return false;
}
MutableKey other = (MutableKey) obj;
return Objects.equals(code, other.code);
}
@Override
public int hashCode() {
return Objects.hash(code);
}
@Override
public String toString() {
return "MutableKey{" + code + "}";
}
}
public static void main(String[] args) {
String a = new String("java");
String b = new String("java");
System.out.println("refEqual=" + (a == b));
System.out.println("contentEqual=" + a.equals(b));
User u1 = new User(1L, "Alice", 90);
User u2 = new User(1L, "Alice-Clone", 95);
HashSet<User> hashSet = new HashSet<>();
hashSet.add(u1);
hashSet.add(u2);
System.out.println("hashSetSize=" + hashSet.size());
HashMap<User, String> userMap = new HashMap<>();
userMap.put(u1, "first");
System.out.println("hashMapGetByEqualKey=" + userMap.get(u2));
List<Student> students = new ArrayList<>();
students.add(new Student("Tom", 18));
students.add(new Student("Jerry", 16));
students.add(new Student("Bob", 20));
students.sort(null);
System.out.println("naturalOrder=" + students);
TreeSet<User> badTreeSet = new TreeSet<>(Comparator.comparingInt(User::getScore));
badTreeSet.add(new User(10L, "A", 90));
badTreeSet.add(new User(11L, "B", 90));
System.out.println("badTreeSetSize=" + badTreeSet.size());
TreeSet<User> goodTreeSet = new TreeSet<>(
Comparator.comparingInt(User::getScore).thenComparingLong(User::getId));
goodTreeSet.add(new User(10L, "A", 90));
goodTreeSet.add(new User(11L, "B", 90));
System.out.println("goodTreeSetSize=" + goodTreeSet.size());
MutableKey key = new MutableKey("order-1");
HashMap<MutableKey, String> cache = new HashMap<>();
cache.put(key, "cached-value");
System.out.println("mutableKeyBeforeChange=" + cache.get(key));
key.setCode("order-2");
System.out.println("mutableKeyAfterChange=" + cache.get(key));
System.out.println("mutableKeyContainsKey=" + cache.containsKey(key));
}
}9.2 编译并运行
javac ObjectComparisonDemo.java
java ObjectComparisonDemo如果你在 PowerShell 中操作,也直接执行同样两条命令即可。
9.3 你应该观察到什么
输出不一定逐字完全一致,但应包含这些关键信息:
refEqual=false
contentEqual=true
hashSetSize=1
hashMapGetByEqualKey=first
naturalOrder=[Jerry:16, Tom:18, Bob:20]
badTreeSetSize=1
goodTreeSetSize=2
mutableKeyBeforeChange=cached-value
mutableKeyAfterChange=null
mutableKeyContainsKey=false9.4 每一行在验证什么
refEqual=false、contentEqual=true:说明引用相等和内容相等不是一回事hashSetSize=1:说明HashSet去重依赖equals+hashCodehashMapGetByEqualKey=first:说明逻辑上相等的 key 可以取回原值naturalOrder=[Jerry:16, Tom:18, Bob:20]:说明Comparable定义了自然顺序badTreeSetSize=1:说明排序规则如果把两个元素比较成0,TreeSet会把它们视为重复goodTreeSetSize=2:说明补充分组字段后,排序规则和去重行为更合理mutableKeyAfterChange=null、mutableKeyContainsKey=false:说明可变 key 会破坏哈希结构查找
9.5 再做两个故意验证
你可以继续做下面两个实验:
- 把
User.hashCode()改成固定返回1 - 把
goodTreeSet的比较器改回只按score
你可以观察:
- 哈希冲突增加后,语义仍然正确,但性能会更差
- 排序规则不完整时,
TreeSet会继续错误去重
10. 练习建议
下面这些练习做完,这一章就不只是“知道”,而是真正理解了:
- 自定义一个类,分别放进
HashSet和TreeSet中观察行为差异 - 分别实现
Comparable和Comparator,体会“自然顺序”和“外部规则”的区别 - 写一个故意有 bug 的
equals/hashCode,观察HashMap的异常表现 - 用一个可变对象当
HashMapkey,再复现实验中的“放进去取不出来”现象 - 总结一份“判等、哈希、排序”三者关系表
11. 自测问题
- 为什么重写
equals时通常也要一起重写hashCode? HashSet和TreeSet的“去重依据”有什么不同?compareTo和Comparator的使用差别是什么?- 为什么可变对象不适合作为
HashMap的 key? - 排序规则和逻辑判等规则不一致时,会出现什么后果?
12. 自测核对要点
如果你的回答能覆盖下面这些点,说明这一章基本掌握到位了:
==比较引用,equals比较逻辑相等equals相等的对象,hashCode必须相同HashMap/HashSet依赖hashCode+equalsTreeMap/TreeSet依赖compareTo或Comparator- 排序相等不一定等于逻辑相等,所以排序规则可能影响去重结果
- 作为哈希 key 的对象应尽量不可变,至少参与比较的字段不可变