Skip to content

同一个金额,HashSet 里是两个,TreeSet 里是一个 ​

两笔金额 1.0 和 1.00 放进 HashSet 去重,剩下两个;换成 TreeSet,剩下一个。一个 Map<Long, String> 里明明放了键 1,get(1) 却返回 null。对象放进集合之后改了一个字段,contains 说它不在,遍历却能看到它。

Java 里「相等」有三套互相独立的定义:== 比较是不是同一个对象,equals 配合 hashCode 比较值,compareTo(或 Comparator)比较顺序上是否并列。不同的集合各用其中一套。三套定义对同一个类型给出不同的答案时,换一种集合,去重、查找、删除的结果就跟着变。

集合还有第二类容易混淆的地方:拿到手的是一份独立的副本,还是原集合的一个视图。

本文在 JDK 25.0.4.1 上把这两类情况逐个排出来。全部结果是确定的,实验见文末。

一、三套相等,各管各的集合 ​

== 引用是否同一个对象IdentityHashMap、枚举比较equals 与 hashCodeHashSet、HashMap、distinct()compareTo 或 ComparatorTreeSet、TreeMap、sorted()List.contains、remove、indexOf 只用 equalsInteger 128 == 128false(127 是 true)1.0 与 1.00equals 为 false:HashSet 里 2 个1.0 与 1.00compareTo 为 0:TreeSet 里 1 个hashCode 决定先去哪个桶找,equals 决定桶里哪个算相同:只重写 equals,HashSet 就找不到相等的对象对象放进 HashSet 之后修改了参与 hashCode 的字段:contains 与 remove 都返回 false,元素还在里面比较器认为相等的两个键(忽略大小写的 Order-1 与 ORDER-1),TreeMap 只保留一个作为键或集合元素的类型,三套关系要么保持一致,要么在放进去之前先规范化
图 1 · Java 里有三套互相独立的「相等」,不同的集合各用一套。new BigDecimal("1.0") 与 "1.00" 按 equals 不相等、按 compareTo 相等,于是放进 HashSet 是两个元素,放进 TreeSet 是一个。三套关系对同一个类型给出不同答案时,换一种集合,去重、查找和删除的结果就跟着变。

BigDecimal:equals 看精度,compareTo 只看数值。

java
BigDecimal a = new BigDecimal("1.0"), b = new BigDecimal("1.00");
a.equals(b)        // false
a.compareTo(b)     // 0
new HashSet<>(List.of(a, b)).size()   // 2
new TreeSet<>(List.of(a, b)).size()   // 1

金额作为 Map 的键、放进 Set 去重、用 distinct() 去重时,1.0 和 1.00 是两个值。数据来源不同(数据库的 DECIMAL(10,2)、JSON 里的 1.0、计算结果)时精度很容易不一致。放进哈希集合之前用 stripTrailingZeros() 或 setScale 统一精度(实验中规范化之后 HashSet 里是 1 个),比较大小一律用 compareTo。

包装类型:== 只在缓存范围内「碰巧」成立。

java
Integer i1 = 127, i2 = 127;   i1 == i2   // true
Integer i3 = 128, i4 = 128;   i3 == i4   // false

-128 到 127 的 Integer 来自缓存,是同一个对象;超出范围的每次装箱是新对象。用 == 比较两个包装对象的代码在小数字上全部通过,ID 增长过 127 之后开始出错。包装类型之间的比较用 equals 或先拆箱。

不同的包装类型永远不相等。

java
Map<Long, String> byId = new HashMap<>();
byId.put(1L, "alice");
byId.get(1)                    // null:1 被装箱成 Integer
Long.valueOf(1).equals(1)      // false

Map.get、Set.contains、List.remove 的参数类型是 Object,传错类型编译器不会报错,只是永远找不到。字面量、int 类型的循环变量、JSON 反序列化出来的数字(小的整数常被解析成 Integer)是最常见的来源。

二、hashCode 与 equals 必须一起成立 ​

哈希集合查找分两步:先用 hashCode 找到桶,再在桶里用 equals 逐个比较。两步里任何一步对不上都找不到。

只重写 equals。 两个内容相同的对象 equals 为 true,但默认的 hashCode 基于对象身份,两者几乎一定落在不同的桶里:

text
equals(相等的另一个对象)             = true
HashSet.contains(相等的另一个对象)   = false
ArrayList.contains(相等的另一个对象) = true

同一个类在 List 里表现正常,在 Set 里去不了重,原因就在这里。

放进去之后修改了参与 hashCode 的字段。 元素还在原来的桶里,但按新的哈希值去找,找的是另一个桶:

text
contains(同一个对象) = false
remove(同一个对象)   = false
size()               = 1
遍历能找到它         = true

这个元素从此删不掉,也查不到,只在遍历时出现。作为 Map 键或 Set 元素的对象,参与 equals 和 hashCode 的字段应当是不可变的;实体类如果按数据库主键判等,主键在保存之前是空、保存之后有值,放进集合之后再保存就会触发这个问题。

带数组分量的 record。 record 自动生成的 equals 对每个分量调用 equals,而数组的 equals 比较的是引用:两个内容相同的 record Payload(String name, int[] data) 不相等。分量里有数组时改用 List,或者手写 equals 与 hashCode。

三、equals 要对称 ​

父类 Point 按坐标判等,子类 ColorPoint 按坐标加颜色判等,两边都用 instanceof 检查类型:

text
p.equals(cp) = true      Point 只看坐标
cp.equals(p) = false     ColorPoint 要求对方也是 ColorPoint

List.of(cp).contains(p) = true
List.of(p).contains(cp) = false

contains 用的是参数的 equals 去比较列表里的元素,于是「a 在不在包含 b 的列表里」取决于谁是参数。这类不对称不会抛异常,只会让查找结果随写法变化。可行的做法只有两种:子类不增加参与判等的状态;或者判等时要求两边的类完全相同(getClass() == o.getClass()),接受父子类永不相等。record 和 final 的值类没有这个问题。

四、比较器认为并列的,有序集合只留一个 ​

TreeSet 与 TreeMap 完全不调用 equals,只看比较结果是不是 0:

java
TreeSet<String> set = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
set.add("Order-1"); set.add("ORDER-1");       // [Order-1]

TreeMap<String, Integer> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
map.put("Order-1", 1); map.put("ORDER-1", 2); // {Order-1=2}

第二个元素被当成重复而丢弃;TreeMap 里值被覆盖,键却保留了先来的那个写法。用「按金额排序」「按时间排序」的比较器建 TreeSet,金额或时间相同的不同对象同样只会留下一个。需要「按某个字段排序但不去重」时,比较器要在主字段之后追加一个能区分所有元素的字段(例如 ID),或者改用 List 加排序。

五、视图还是副本 ​

集合的很多方法返回的不是新集合,而是原数据的一个视图。

写法得到的是实验结果
Arrays.asList(Integer[])数组的固定长度视图set(0, 99) 改到了原数组;add 抛 UnsupportedOperationException
Arrays.asList(int[])只有一个元素(那个数组)的列表size() 是 1
list.subList(1, 3)原列表的一段视图对它 clear(),原列表从 [1, 2, 3, 4, 5] 变成 [1, 4, 5];之后直接修改原列表,再访问视图抛 ConcurrentModificationException
Collections.unmodifiableList(src)只读视图源列表追加元素后,它也看到了
List.copyOf(src)不可变的副本源列表追加元素后,它不变
map.keySet()Map 的键视图从中删除一个键,Map 里对应的条目没了
new ArrayList<>(src)新列表,元素仍是同一批对象修改副本里的元素,源里看到同样的修改

几条由此而来的规则:

  • 对外返回内部集合时返回副本。 unmodifiableList 只阻止调用方修改,挡不住它看到之后的变化;需要快照时用 List.copyOf。接收外部传入的集合并长期持有时同理。
  • subList 用完即弃。 它持有整个原列表;从一个很大的列表里取一小段长期保存,原列表就一直不能回收。需要保留时 new ArrayList<>(list.subList(a, b))。
  • 拷贝集合不拷贝元素。 可变元素需要隔离时要逐个复制。

六、几个接口上的差别 ​

List.of 不接受 null,连查询都不行。 List.of(1, 2).contains(null) 抛 NullPointerException;Arrays.asList(1, 2).contains(null) 返回 false。把 Arrays.asList 换成 List.of 看起来只是写法更新,行为并不相同。

Stream.toList() 与 Collectors.toList() 不等价。 前者的结果不可修改(add 抛异常)、允许 null 元素;后者当前返回可修改的 ArrayList,但规范没有承诺这一点。把 collect(Collectors.toList()) 批量替换成 toList() 之后,下游对结果做 add、sort 的代码会在运行时失败。

Collectors.toMap 遇到重复键抛 IllegalStateException,遇到 null 值抛 NullPointerException。 按某个字段把列表转成 Map 时,数据里只要有两条记录该字段相同就整体失败。用三参数的版本明确写出合并规则。

List<Integer> 的 remove 有两个重载。 [10, 20, 30, 1] 调用 remove(1) 删掉的是下标 1 的 20;要删值为 1 的元素得写 remove(Integer.valueOf(1))。

七、写值类型时的检查项 ​

  1. 作为键或集合元素的类,equals 与 hashCode 一起写,用同一组不可变字段;优先用 record。
  2. 实现了 Comparable 的类,让 compareTo 返回 0 与 equals 返回 true 保持一致;做不到(如 BigDecimal)就在文档里写明,并在放进集合之前规范化。
  3. 自定义比较器只用于排序时,确认它不会被拿去建 TreeSet 或 TreeMap。
  4. 方法返回集合时,在文档或类型上说明它是视图、快照还是可变的内部状态。
  5. 测试里加上这几类用例:精度不同的相等数值、超过 127 的 ID、键类型与字面量类型不同的查找、放入集合之后的修改。

配套实验

  • codesphere-labs/java/equality-and-collection-views:JDK 25.0.4.1 上 BigDecimal、包装类型、可变键、缺少 hashCode、父子类判等、比较器与 record 数组分量在各集合里的结果,以及 asList、subList、不可变集合、toList、toMap、keySet 与 remove 重载的行为(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权