Skip to content

对象比较规则

1. 这是什么

对象比较规则主要包括 ==equalshashCodecompareToComparator
它们决定了对象如何判等、如何散列、如何排序。

一句话理解:

  • == 解决“是不是同一个对象引用”
  • equals 解决“内容上算不算相等”
  • hashCode 解决“哈希结构里应该落到哪里”
  • compareTo / Comparator 解决“排序时谁在前谁在后”

2. 为什么重要

如果对象比较规则设计不正确,很多看起来奇怪的问题都会出现:

  • HashSet 去重失效
  • HashMap 放进去的 key 又取不出来
  • TreeSet 莫名其妙少元素
  • 排序结果不稳定
  • 缓存命中异常

这不是语法细节,而是 Java 建模能力里非常关键的一环。

3. 先建立直觉:对象“相同”到底有几种含义

在 Java 里,“相同”至少有三种常见语义:

语义典型工具含义
引用相同==两个变量是否指向同一个对象
逻辑相等equals两个对象内容上是否应视为同一个值
排序结果相同compareTo / Comparator 返回 0两个对象在排序规则下是否被视为同一位置

这三件事很像,但绝不是同一件事。
很多 bug 的根源,就是把这三种“相同”混在了一起。

4. 核心内容

4.1 ==equals 的区别

== 比较的是:

  • 基本类型时:值是否相等
  • 引用类型时:是否是同一个对象

equals 比较的是:

  • 逻辑意义上的相等性
  • 默认实现和 == 一样
  • 但很多类会重写它,例如 StringIntegerBigDecimal

看一个最典型的例子:

java
String a = new String("java");
String b = new String("java");

System.out.println(a == b);      // false
System.out.println(a.equals(b)); // true

这里说明:

  • ab 不是同一个对象,所以 ==false
  • 但它们内容一样,所以 equalstrue

4.2 默认 equals 行为是什么

如果一个类不重写 equals,那么它继承自 Object 的默认实现,效果基本等同于:

  • 只有两个引用指向同一个对象时才算相等

这意味着:

  • 如果你的类表达的是“值对象”概念,例如用户编号、订单编号、坐标、日期范围
  • 那往往应该按“业务字段是否相等”来重写 equals

4.3 hashCode 的契约要求

hashCode 是给哈希结构使用的。
它的核心契约非常重要:

  • 如果两个对象 equalstrue,那么它们的 hashCode 必须相同
  • 如果两个对象 equalsfalse,它们的 hashCode 可以相同,也可以不同
  • 在参与比较的字段不变的前提下,多次调用 hashCode 结果应保持一致

最重要的一条是:

  • 相等对象必须具有相同哈希值

但反过来不成立:

  • 哈希值相同,不代表对象一定相等

因为哈希冲突是允许存在的。

4.4 为什么重写 equals 通常要一起重写 hashCode

因为哈希结构,例如:

  • HashSet
  • HashMap
  • LinkedHashSet
  • LinkedHashMap

都先依赖 hashCode 找桶,再依赖 equals 确认是否同一个逻辑对象。

如果你只重写 equals,不重写 hashCode,就可能导致:

  • 逻辑上相等的对象被放进不同桶
  • HashSet 无法正确去重
  • HashMap 用“看起来一样”的 key 取不回值

4.5 Comparable 是自然顺序

如果一个类“自己知道自己该怎么排序”,通常会实现 Comparable<T>

java
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

java
Comparator<User> byName = Comparator.comparing(User::getName);
Comparator<User> byAgeDesc = Comparator.comparingInt(User::getAge).reversed();

它适合:

  • 同一类对象需要多种排序方式
  • 你不想把排序规则耦合到实体类自身

4.7 哪些集合用哪套比较规则

容器主要依赖什么
HashSet / HashMaphashCode + equals
LinkedHashSet / LinkedHashMaphashCode + equals,外加顺序链
TreeSet / TreeMapcompareToComparator
Collections.sort / List.sortcompareToComparator

这张表非常重要,因为它解释了为什么:

  • HashSet 里能共存的对象,未必能在 TreeSet 里共存
  • 排序规则如果把两个对象比较成 0TreeSet 就可能把它们当成重复元素

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 为什么这会出问题

如果你定义:

  • equalsid 判断
  • Comparator 却只按 score 判断

那么两个不同 id、但 score 一样的对象:

  • HashSet 看来是两个元素
  • TreeSet 看来可能是一个元素

这会让代码阅读者非常困惑。

工程上的建议是:

  • 如果对象会进入 TreeSet / TreeMap
  • 尽量保证排序相等规则和逻辑相等规则语义一致,或者至少你非常清楚差异

7. 对象作为 Map key 的设计注意点

7.1 最重要的原则:参与比较的字段尽量不可变

如果 key 参与 equals / hashCode 的字段发生变化,就会出现严重问题:

  • 放进去时按旧哈希落桶
  • 修改后按新哈希去查找
  • 结果就是“明明是同一个对象,却找不到了”

所以:

  • 作为 HashMap key 的对象,最好设计成不可变对象
  • 至少参与 equals / hashCode 的字段不要在放入后再变化

7.2 key 设计的推荐做法

  • 尽量使用不可变字段
  • 明确比较基于哪些业务字段
  • equalshashCode 使用相同字段集合
  • 不要把临时状态、可变计数器、时间戳等易变字段纳入哈希计算

8. 常见问题

8.1 重写了 equals 却没重写 hashCode

这是最经典的错误。
如果你的对象要放进哈希结构,这种写法基本注定会出问题。

8.2 把 == 当成内容比较

尤其是比较:

  • String
  • 包装类型
  • 自定义对象

时,如果你真正想比较内容,应优先考虑 equals

8.3 排序规则和判等规则互相冲突

你以为只是“排个序”,但一旦对象进入 TreeSet / TreeMap,排序规则就同时决定了去重行为。

8.4 让可变对象直接作为哈希结构 key

这会导致:

  • get 失败
  • containsKey 失败
  • 逻辑上已经存在的元素仿佛“消失”

9. 动手验证

这一节可以直接复制运行,边看边验证。

9.1 准备一个可运行示例

新建文件 ObjectComparisonDemo.java,内容如下:

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 编译并运行

bash
javac ObjectComparisonDemo.java
java ObjectComparisonDemo

如果你在 PowerShell 中操作,也直接执行同样两条命令即可。

9.3 你应该观察到什么

输出不一定逐字完全一致,但应包含这些关键信息:

text
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=false

9.4 每一行在验证什么

  • refEqual=falsecontentEqual=true:说明引用相等和内容相等不是一回事
  • hashSetSize=1:说明 HashSet 去重依赖 equals + hashCode
  • hashMapGetByEqualKey=first:说明逻辑上相等的 key 可以取回原值
  • naturalOrder=[Jerry:16, Tom:18, Bob:20]:说明 Comparable 定义了自然顺序
  • badTreeSetSize=1:说明排序规则如果把两个元素比较成 0TreeSet 会把它们视为重复
  • goodTreeSetSize=2:说明补充分组字段后,排序规则和去重行为更合理
  • mutableKeyAfterChange=nullmutableKeyContainsKey=false:说明可变 key 会破坏哈希结构查找

9.5 再做两个故意验证

你可以继续做下面两个实验:

  1. User.hashCode() 改成固定返回 1
  2. goodTreeSet 的比较器改回只按 score

你可以观察:

  • 哈希冲突增加后,语义仍然正确,但性能会更差
  • 排序规则不完整时,TreeSet 会继续错误去重

10. 练习建议

下面这些练习做完,这一章就不只是“知道”,而是真正理解了:

  • 自定义一个类,分别放进 HashSetTreeSet 中观察行为差异
  • 分别实现 ComparableComparator,体会“自然顺序”和“外部规则”的区别
  • 写一个故意有 bug 的 equals / hashCode,观察 HashMap 的异常表现
  • 用一个可变对象当 HashMap key,再复现实验中的“放进去取不出来”现象
  • 总结一份“判等、哈希、排序”三者关系表

11. 自测问题

  • 为什么重写 equals 时通常也要一起重写 hashCode
  • HashSetTreeSet 的“去重依据”有什么不同?
  • compareToComparator 的使用差别是什么?
  • 为什么可变对象不适合作为 HashMap 的 key?
  • 排序规则和逻辑判等规则不一致时,会出现什么后果?

12. 自测核对要点

如果你的回答能覆盖下面这些点,说明这一章基本掌握到位了:

  • == 比较引用,equals 比较逻辑相等
  • equals 相等的对象,hashCode 必须相同
  • HashMap / HashSet 依赖 hashCode + equals
  • TreeMap / TreeSet 依赖 compareToComparator
  • 排序相等不一定等于逻辑相等,所以排序规则可能影响去重结果
  • 作为哈希 key 的对象应尽量不可变,至少参与比较的字段不可变