Java 基础体系 · 第 46/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java equals、hashCode 与比较:等价关系、排序和集合正确性

对象比较在 Java 中不是一个单独的问题,而是三套相互关联的规则:

  • equals 判断两个对象是否具有相同的逻辑值;
  • hashCode 把逻辑值映射为哈希值,供哈希集合和哈希映射定位候选对象;
  • ComparableComparator 定义对象的顺序,供排序和有序集合使用。

这三套规则如果相互矛盾,程序通常不会立即抛出异常,而是表现为查找失败、集合大小异常、元素“消失”、排序结果不符合预期等隐蔽问题。


1. 先区分三种“相同”

Java 中至少存在三种容易混淆的相同性。

1.1 引用相同:==

对于对象引用,== 判断两个引用是否指向同一个对象:

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

对于基本类型,== 比较值;对于对象引用,== 比较身份。对象的逻辑相等通常应使用 equals,而不是 ==

1.2 逻辑相等:equals

equals 表达的是类定义的“值相同”或“状态相同”。例如:

  • 两个 String 的字符序列相同;
  • 两个表示用户标识的对象,其标识字段相同;
  • 两个日期对象表示同一个日期。

equals 的具体含义由类决定。Object.equals 的默认实现本质上是身份比较,因此没有重写 equals 的普通类,默认行为接近:

this == other

1.3 顺序相同:比较结果为零

排序接口中,compareToComparator.compare 返回 0 表示两个对象在该排序规则下处于同一位置:

Comparator<String> byLength = Comparator.comparingInt(String::length);

byLength.compare("ab", "CD") == 0

这并不自动意味着:

"ab".equals("CD")

事实上结果为 false

因此,“比较结果为零”和“equalstrue”是两个独立概念。工程中最容易出错的地方,正是把这两个概念默认成同一个概念。


2. equals 的形式化契约:它必须构成等价关系

等价关系是一种满足特定数学性质的关系。对于非空对象,equals 应满足以下条件。

设关系 a ~ b 表示 a.equals(b)true

2.1 自反性

对任意非空对象 a

aaa \sim a

也就是:

a.equals(a) == true

如果一个对象的 equals 依赖会变化的外部状态,或者实现中错误地拒绝自身,就会破坏自反性。

2.2 对称性

对任意非空对象 ab

abbaa \sim b \Rightarrow b \sim a

代码形式是:

a.equals(b) == b.equals(a)

继承体系中最常见的对称性问题如下:

class Money {
    final int amount;

    Money(int amount) {
        this.amount = amount;
    }

    @Override
    public boolean equals(Object other) {
        return other instanceof Money money
                && amount == money.amount;
    }

    @Override
    public int hashCode() {
        return Integer.hashCode(amount);
    }
}

class Voucher extends Money {
    final String issuer;

    Voucher(int amount, String issuer) {
        super(amount);
        this.issuer = issuer;
    }

    @Override
    public boolean equals(Object other) {
        return other instanceof Voucher voucher
                && amount == voucher.amount
                && issuer.equals(voucher.issuer);
    }

    @Override
    public int hashCode() {
        return 31 * Integer.hashCode(amount) + issuer.hashCode();
    }
}

此时:

Money money = new Money(100);
Voucher voucher = new Voucher(100, "A");

System.out.println(money.equals(voucher));   // true
System.out.println(voucher.equals(money));   // false

Money.equals 只检查 amount,而 Voucher.equals 还要求对方是 Voucher,于是对称性被破坏。

解决方式不是简单地选择 instanceofgetClass 中的一个,而是要让整个继承设计与相等语义一致:

  • 如果相等只适用于完全相同的具体类,使用 getClass()
  • 如果允许父类与子类互相相等,所有参与比较的类必须遵循同一规则;
  • 需要扩展值语义时,优先使用不可变、非继承的值类型,或者重新设计类型层次。

例如,一个只允许同一具体类相等的实现:

@Override
public boolean equals(Object other) {
    if (this == other) {
        return true;
    }
    if (other == null || getClass() != other.getClass()) {
        return false;
    }

    Money that = (Money) other;
    return amount == that.amount;
}

2.3 传递性

如果:

abbca \sim b \land b \sim c

则必须有:

aca \sim c

如果 equals 根据多个字段判断,而子类、代理类或不同实现类分别采用不同字段,传递性可能被破坏。

2.4 一致性

只要参与比较的对象状态没有发生变化,多次调用结果应保持一致:

a.equals(b)

不应在没有相关状态变化时,一次返回 true,另一次返回 false

这里的“状态变化”不仅包括对象自身字段,也包括 equals 实现读取的外部可变状态。一个依赖当前时间、随机数、数据库查询结果或可变全局配置的 equals,通常无法满足这个要求。

2.5 与 null 的关系

对于任何非空对象 a

a.equals(null) == false

equals 不应因为传入 null 而抛出异常。典型的安全结构是:

if (this == other) {
    return true;
}
if (!(other instanceof User that)) {
    return false;
}

instanceofnull 返回 false,因此这个写法天然处理了 null


3. 正确重写 equalshashCode

hashCode 的核心契约只有一个方向:

a.equals(b)=truea.hashCode()=b.hashCode()a.equals(b) = true \Rightarrow a.hashCode() = b.hashCode()

反方向不成立:

a.hashCode()=b.hashCode()⇏a.equals(b)=truea.hashCode() = b.hashCode() \not\Rightarrow a.equals(b) = true

不同对象允许拥有相同哈希值,这叫哈希冲突。哈希值不是对象的唯一身份。

3.1 为什么相等对象必须有相同哈希值

哈希集合通常先根据哈希值定位候选区域,再使用 equals 确认对象。

抽象流程如下:

put(key, value)
  │
  ├─ 计算 key.hashCode()
  ├─ 根据哈希值定位桶
  ├─ 检查桶中的候选键
  └─ 使用 equals 判断是否为同一个逻辑键

查找时同样如此:

get(queryKey)
  │
  ├─ 计算 queryKey.hashCode()
  ├─ 定位桶
  ├─ 在候选键中调用 equals
  └─ 返回匹配值或报告不存在

如果两个逻辑相等的对象哈希值不同,它们可能被定位到不同的桶,查找流程甚至不会比较它们的 equals

3.2 一个完整的值对象实现

import java.util.Objects;

public final class UserId {
    private final String value;

    public UserId(String value) {
        this.value = Objects.requireNonNull(value, "value");
    }

    public String value() {
        return value;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;
        }
        if (!(other instanceof UserId that)) {
            return false;
        }
        return value.equals(that.value);
    }

    @Override
    public int hashCode() {
        return value.hashCode();
    }

    @Override
    public String toString() {
        return value;
    }
}

这里的关键关系是:

new UserId("u-1").equals(new UserId("u-1"))   // true

因此两个对象必须产生相同的哈希值。

多个字段通常使用:

@Override
public int hashCode() {
    return Objects.hash(field1, field2, field3);
}

或者显式组合字段哈希值。哈希算法可以不同,但必须与 equals 使用相同的逻辑字段。

3.3 equalshashCode 必须使用同一组字段

错误示例:

@Override
public boolean equals(Object other) {
    return other instanceof User user
            && id.equals(user.id)
            && region.equals(user.region);
}

@Override
public int hashCode() {
    return id.hashCode(); // 忽略了 region
}

这个实现并不违反“相等对象哈希值相同”的必要条件,因为相等时 id 一定相同;但它可能产生更多哈希冲突,降低性能。

反过来,如果 equals 只比较 id,而 hashCode 同时加入 region,则可能违反硬性契约:

a.equals(b) == true
a.hashCode() != b.hashCode()

这会导致 HashSetHashMap 查找失败。


4. 可变字段为什么会让哈希集合中的对象“消失”

哈希集合要求:对象作为键或元素存入集合后,参与 equalshashCode 的状态不能改变。

下面的示例可以直接运行:

import java.util.HashMap;
import java.util.Map;

public class MutableKeyDemo {
    static final class Key {
        String value;

        Key(String value) {
            this.value = value;
        }

        @Override
        public boolean equals(Object other) {
            return other instanceof Key key
                    && value.equals(key.value);
        }

        @Override
        public int hashCode() {
            return value.hashCode();
        }

        @Override
        public String toString() {
            return value;
        }
    }

    public static void main(String[] args) {
        Map<Key, String> map = new HashMap<>();

        Key key = new Key("before");
        map.put(key, "data");

        System.out.println(map.get(key)); // data

        key.value = "after";

        System.out.println(map.get(key)); // null
        System.out.println(map.containsKey(key)); // false
        System.out.println(map.size()); // 1
    }
}

插入时,映射根据 "before" 的哈希值把节点放入某个桶。修改后,get 使用 "after" 的哈希值定位另一个桶,因而找不到原节点。

原节点仍然保留在 HashMap 内部,所以 size() 仍为 1;但按照当前键状态,正常查找已经无法定位它。这不是 HashMap 自动修复失败,而是键违反了集合对稳定性的前提。

因此,适合作为哈希键的对象通常具有以下特征:

  • 参与相等判断的字段不可变;
  • 对外暴露的可变对象不会影响这些字段;
  • 对象构造完成后,其逻辑身份不再变化。

如果必须修改对象,应先移除,再修改,再重新放入:

map.remove(key);
key.value = "after";
map.put(key, "data");

但这要求 remove 发生在修改之前;修改之后可能已经无法可靠移除。


5. Comparable:对象的自然顺序

Comparable<T> 定义对象自身的自然顺序:

public interface Comparable<T> {
    int compareTo(T other);
}

返回值的语义不是必须为 -101,而是看符号:

  • 小于 0:当前对象排在参数之前;
  • 等于 0:两者在该顺序下等价;
  • 大于 0:当前对象排在参数之后。

应使用符号判断:

int result = a.compareTo(b);

if (result < 0) {
    // a 在 b 之前
}

不能依赖具体返回值:

if (a.compareTo(b) == -1) {
    // 错误假设:compareTo 不保证返回 -1
}

5.1 compareTo 的数学性质

令:

c(a,b)=a.compareTo(b)c(a,b) = a.compareTo(b)

至少需要满足以下顺序性质。

反对称性

比较结果的符号应相反:

sign(c(a,b))=sign(c(b,a))sign(c(a,b)) = -sign(c(b,a))

如果 a 小于 b,那么 b 必须大于 a

传递性

如果:

c(a,b)>0c(b,c)>0c(a,b) > 0 \land c(b,c) > 0

则必须有:

c(a,c)>0c(a,c) > 0

排序算法和有序集合依赖这一性质。如果比较器不具备传递性,排序结果可能不可预测,某些排序实现还可能抛出比较契约相关的异常。

零值的一致性

如果:

c(a,b)=0c(a,b) = 0

那么对任意 zab 相对于 z 的比较方向应一致:

sign(c(a,z))=sign(c(b,z))sign(c(a,z)) = sign(c(b,z))

直觉上,既然排序规则认为 ab 在同一位置,它们就不能对第三个对象产生矛盾的排序关系。

5.2 不要用减法实现比较

以下实现可能溢出:

@Override
public int compareTo(Task other) {
    return this.priority - other.priority;
}

例如:

int a = Integer.MAX_VALUE;
int b = -1;

System.out.println(a - b); // 溢出为负数

结果会错误地认为 a 小于 b

应使用:

@Override
public int compareTo(Task other) {
    return Integer.compare(this.priority, other.priority);
}

多个字段可以使用比较器组合:

@Override
public int compareTo(Task other) {
    return java.util.Comparator
            .comparingInt(Task::priority)
            .thenComparing(Task::name)
            .compare(this, other);
}

或者在类外定义:

Comparator<Task> order =
        Comparator.comparingInt(Task::priority)
                  .thenComparing(Task::name);

5.3 compareTo 可以与 equals 不一致,但要明确后果

Comparable 的文档建议自然顺序与 equals 一致,也就是:

x.compareTo(y) == 0

通常应当等价于:

x.equals(y)

这不是所有类都强制满足的绝对要求。BigDecimal 是标准库中的典型例外:

import java.math.BigDecimal;

BigDecimal x = new BigDecimal("1.0");
BigDecimal y = new BigDecimal("1.00");

System.out.println(x.equals(y));    // false
System.out.println(x.compareTo(y)); // 0

BigDecimal.equals 同时比较数值和 scale:

  • 1.0 的 scale 是 1
  • 1.00 的 scale 是 2

compareTo 只比较数值大小,所以认为它们相等。

这意味着“数值相等”和“对象值相等”在 BigDecimal 中不是同一个概念。业务代码必须先确定需要哪一种:

// 需要数值相等
x.compareTo(y) == 0

// 需要表示形式也相同
x.equals(y)

6. Comparator:把顺序从对象中分离出来

Comparator<T> 表示外部提供的排序规则:

@FunctionalInterface
public interface Comparator<T> {
    int compare(T first, T second);
}

它适合以下情况:

  • 一个类型需要多种排序方式;
  • 类型本身不应承担自然顺序;
  • 排序规则由业务场景决定;
  • 需要按组合字段排序。

例如:

import java.util.Comparator;

record Person(String name, int age) {}

Comparator<Person> byAgeThenName =
        Comparator.comparingInt(Person::age)
                  .thenComparing(Person::name);

Person a = new Person("Bob", 30);
Person b = new Person("Alice", 30);

System.out.println(byAgeThenName.compare(a, b) > 0); // true

年龄相同后再按姓名比较,因此 "Bob" 排在 "Alice" 之后。

6.1 Comparator 的空值策略必须明确

自然比较通常不能比较 null。如果业务允许空值,应显式指定:

Comparator<String> order =
        Comparator.nullsFirst(String::compareTo);

或:

Comparator<String> order =
        Comparator.nullsLast(String::compareTo);

不要让排序过程中偶发的 NullPointerException 才暴露出空值策略未定义的问题。

6.2 比较字段必须覆盖业务需要的区分度

下面的比较器只比较年龄:

Comparator<Person> byAge = Comparator.comparingInt(Person::age);

两个年龄相同的人比较结果为 0,即使姓名不同。作为排序规则,这可能完全正确;但作为 TreeSet 的比较器,它会让同年龄的人只能保留一个。

如果业务需要保留所有人,应补充稳定的区分字段:

Comparator<Person> byAgeThenName =
        Comparator.comparingInt(Person::age)
                  .thenComparing(Person::name);

如果姓名也可能重复,还需要继续加入唯一业务标识。


7. HashSetHashMapTreeSet 的“相等”不是同一种相等

这是理解集合正确性的核心。

7.1 哈希集合使用 hashCodeequals

对于 HashSet

set.add(element);
set.contains(query);

逻辑上会先利用哈希值缩小候选范围,再使用 equals 判断是否为同一元素。

对于 HashMap

map.put(key, value);
map.get(queryKey);

键的判定同样依赖 hashCodeequals

7.2 有序集合使用比较结果

TreeSetTreeMap 使用自然顺序或传入的 Comparator

TreeSet<T> set = new TreeSet<>(comparator);

如果:

comparator.compare(a, b) == 0

那么对这个有序集合而言,ab 占据同一个排序位置。即使:

a.equals(b) == false

集合也可能只保留一个元素。

下面的程序可以直接运行:

import java.math.BigDecimal;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;

public class CollectionEqualityDemo {
    public static void main(String[] args) {
        BigDecimal first = new BigDecimal("1.0");
        BigDecimal second = new BigDecimal("1.00");

        Set<BigDecimal> hashSet = new HashSet<>();
        hashSet.add(first);
        hashSet.add(second);

        Set<BigDecimal> treeSet = new TreeSet<>();
        treeSet.add(first);
        treeSet.add(second);

        System.out.println(first.equals(second));    // false
        System.out.println(first.compareTo(second)); // 0
        System.out.println(hashSet.size());           // 2
        System.out.println(treeSet.size());           // 1
    }
}

结果的因果链是:

  1. HashSet 通过 hashCodeequals 判断重复;
  2. BigDecimal("1.0")BigDecimal("1.00")equalsfalse
  3. 所以 HashSet 保留两个元素;
  4. TreeSet 通过 compareTo 判断排序位置;
  5. 两者的 compareTo0
  6. 所以 TreeSet 只认为有一个排序元素。

这不是 TreeSet 违反自身规则,而是两种集合采用了不同的等价定义。

7.3 不一致时可能出现的失败表现

import java.util.Comparator;
import java.util.TreeSet;

public class ComparatorSetDemo {
    public static void main(String[] args) {
        Comparator<String> byLength = Comparator.comparingInt(String::length);
        TreeSet<String> set = new TreeSet<>(byLength);

        set.add("ab");
        set.add("CD");

        System.out.println(set.size());       // 1
        System.out.println(set.contains("xy")); // true
    }
}

"ab""CD""xy" 长度都为 2,比较结果都为 0。因此:

  • 第二次 add 不会增加集合大小;
  • contains("xy") 可能返回 true,尽管集合中没有字符串 "xy"
  • first() 返回的可能是 "ab",但它代表的是“长度为 2 的等价类”。

因此,传给 TreeSetTreeMap 的比较器必须与集合的业务唯一性一致。若比较器只表达“排序优先级”,却没有表达“元素是否应视为重复”,就不应直接用它定义集合唯一性。


8. 有序关系与等价类

可以更形式化地理解 TreeSet 的行为。

给定比较器 C,定义:

aCb    C.compare(a,b)=0a \approx_C b \iff C.compare(a,b)=0

TreeSet 实际上按照这个关系划分元素。每个 \approx_C 等价类只保留一个代表元素。

如果比较器满足完整的比较契约,那么这个关系通常具有等价关系的性质:

  • C.compare(a,a) == 0:自反;
  • C.compare(a,b) == 0 时,C.compare(b,a) == 0:对称;
  • 零值关系满足传递性:传递。

但这个等价类不一定等于 equals 等价类。

例如,按长度排序时:

{ "a", "B", "x" }       // 长度为 1 的一个等价类
{ "ab", "CD", "xy" }    // 长度为 2 的一个等价类

如果集合的业务定义是“每个字符串都必须保留”,按长度的比较器不适合作为 TreeSet 的唯一性规则;如果业务定义是“每种长度只保留一个代表”,它就可能是合理的。


9. 数组、记录类和包装类型的边界

9.1 数组的 equalshashCode 不是按内容定义

数组继承自 Object,其 equals 默认是引用比较:

int[] a = {1, 2};
int[] b = {1, 2};

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

按内容比较应使用:

import java.util.Arrays;

System.out.println(Arrays.equals(a, b));       // true
System.out.println(Arrays.hashCode(a));       // 内容哈希
System.out.println(Arrays.deepEquals(...));   // 多维对象数组

如果把数组直接放进自定义值对象的 equalshashCode,必须相应使用 Arrays.equalsArrays.hashCode 或深度版本。

9.2 记录类不会自动把数组变成深度值对象

记录类会根据组件生成 equalshashCode 和访问器:

record Packet(byte[] payload) {}

但数组组件本身的相等语义仍是数组引用相等,而不是数组内容相等。因此:

new Packet(new byte[] {1, 2})
    .equals(new Packet(new byte[] {1, 2})) // 通常为 false

如果记录表示的是不可变值,通常应在边界复制并转换为合适的值类型,例如 List<Integer>,或显式实现基于 Arrays.equals 的方法。还要注意:即使自定义了 equals,也必须同步自定义 hashCode

9.3 DoubleFloat 的特殊值

浮点包装类的相等和排序要遵循其 API 规定,不能简单套用数学直觉:

  • Double.NaN 的处理与普通 IEEE 数学比较不同;
  • +0.0-0.0 在某些比较语义中存在区别;
  • Double.compareDouble.equals== 的行为并不完全相同。

因此,涉及浮点值排序、集合键或去重时,应明确采用哪一种语义,不能混用:

double a = Double.NaN;
double b = Double.NaN;

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

10. 继承、代理和类型判断的设计选择

实现 equals 时,下面两种检查表达不同的类型语义。

getClass():严格的具体类型相等

if (other == null || getClass() != other.getClass()) {
    return false;
}

优点是容易保持对称性,适合不可扩展值对象。

缺点是两个不同实现类即使拥有完全相同的状态,也不会相等。

instanceof:允许子类型参与相等

if (!(other instanceof BaseValue that)) {
    return false;
}

优点是支持接口或父类抽象下的值比较。

风险是子类可能加入新的参与字段,导致父类和子类使用不同标准,从而破坏对称性或传递性。

使用 instanceof 时,必须确保子类不能改变相等语义,或者采用专门的继承相等设计。对于持久化代理、ORM 实体、接口多实现类,尤其需要把“类型身份”纳入设计,而不是只比较字段。


11. Objects.equals 解决的是空值处理,不是语义设计

Objects.equals(a, b) 的逻辑大致是:

a == b || (a != null && a.equals(b))

它适合简化空值安全的字段比较:

return Objects.equals(name, that.name)
        && Objects.equals(email, that.email);

但它不会解决以下问题:

  • 两个字段是否真的属于对象逻辑身份;
  • 子类和父类是否允许相等;
  • 数组是否应按内容比较;
  • BigDecimal 是否应忽略 scale;
  • 时间值是否应按时区归一化;
  • 比较器是否与 equals 一致。

空值安全只是实现细节,不能替代相等语义的定义。


12. 诊断集合问题的有效路径

遇到“明明存在却查不到”“集合大小不对”时,应先判断集合使用的是哪套规则。

12.1 检查哈希集合

针对 HashSetHashMap,检查:

System.out.println(key.hashCode());
System.out.println(other.hashCode());
System.out.println(key.equals(other));

重点确认:

  1. 逻辑相等的对象是否具有相同哈希值;
  2. 插入后参与计算的字段是否发生变化;
  3. equalshashCode 是否使用同一组字段;
  4. 是否存在数组直接参与比较;
  5. 是否使用了可变集合、日期或其他可变对象作为键。

12.2 检查有序集合

针对 TreeSetTreeMap,检查:

int result = comparator.compare(a, b);

System.out.println(result);
System.out.println(a.equals(b));

重点确认:

  1. compare(a, b) == 0 是否意味着业务上的重复;
  2. 比较器是否遗漏了必要的区分字段;
  3. 是否使用减法导致整数溢出;
  4. 比较字段是否包含 null
  5. 比较器是否具有传递性。

12.3 不要依赖集合遍历顺序判断正确性

HashSetHashMap 的遍历顺序不是键的自然顺序,也不应依赖具体实现的桶布局、哈希扰动或扩容行为。即使某次运行中顺序看起来稳定,也不构成 API 保证。

需要顺序时,应明确使用:

  • TreeSet / TreeMap:按比较规则排序;
  • LinkedHashSet / LinkedHashMap:保持插入顺序;
  • 排序后的列表:显式调用排序逻辑。

这三者解决的是不同问题,不能通过观察某次 HashMap 输出顺序来替代顺序设计。


13. 线程安全不会修复错误的相等语义

HashMapHashSetTreeMapTreeSet 本身不是用于无协调并发修改的容器。并发访问可能产生数据竞争、可见性问题或结构性错误。

即使使用并发集合,也不能修复以下逻辑错误:

  • 键在插入后发生变化;
  • equalshashCode 不一致;
  • 比较器不具备传递性;
  • 比较器把业务上不同的元素判断为相等。

并发安全解决的是多个线程如何访问集合,equalshashCode 和比较器解决的是集合如何定义和定位元素。这是两个不同层次的问题。


14. 一套可执行的验证思路

为值对象编写测试时,不能只测试几个“相等”和“不相等”的例子,还应直接验证契约:

import static org.junit.jupiter.api.Assertions.*;

class UserIdTest {
    @org.junit.jupiter.api.Test
    void equalObjectsHaveEqualHashCodes() {
        UserId a = new UserId("u-1");
        UserId b = new UserId("u-1");

        assertEquals(a, b);
        assertEquals(b, a);
        assertEquals(a.hashCode(), b.hashCode());
    }

    @org.junit.jupiter.api.Test
    void differentValuesAreUsableAsDifferentMapKeys() {
        var map = new java.util.HashMap<UserId, String>();
        map.put(new UserId("u-1"), "Alice");

        assertEquals("Alice", map.get(new UserId("u-1")));
        assertNull(map.get(new UserId("u-2")));
    }
}

对于比较器,还应验证:

assertTrue(comparator.compare(a, b) < 0);
assertTrue(comparator.compare(b, a) > 0);
assertEquals(0, comparator.compare(a, a));

若比较器包含多个字段,应构造:

  • 主字段相同、次字段不同的对象;
  • 所有字段都相同但对象引用不同的对象;
  • 极端整数值;
  • null 值;
  • 多个对象组成的三元组,用于检查传递性。

这些测试直接针对失败机制,而不是仅验证某一个排序输出。


15. 选择集合前先确定“唯一性”和“顺序”

可以用以下逻辑区分集合类型:

需求 适合的机制
equals 去重,并通过哈希快速查找 HashSet
equals 判断键,并通过哈希快速定位 HashMap
按比较器去重并保持排序 TreeSet
按比较器定位键并保持排序 TreeMap
保持插入顺序,同时按 equals 去重 LinkedHashSet
只需要排序,不需要依靠排序规则去重 List 加排序

关键问题不是“哪个集合性能更好”,而是:

对两个对象来说,什么条件下它们应该被视为同一个元素?

如果答案是 equals,使用哈希集合通常更自然;如果答案是比较器返回 0,使用有序集合才符合语义;如果排序只是展示需求,就不应让排序规则意外决定数据是否丢失。

equals 定义逻辑相等,hashCode 必须支持这种相等在哈希结构中的定位,ComparableComparator 定义顺序,而有序集合会把比较结果为零的对象视为同一个排序元素。只有先明确这三套关系各自表达什么,才能让对象、排序和集合在 Java 中保持一致。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。