同一个金额,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 上把这两类情况逐个排出来。全部结果是确定的,实验见文末。
一、三套相等,各管各的集合
new BigDecimal("1.0") 与 "1.00" 按 equals 不相等、按 compareTo 相等,于是放进 HashSet 是两个元素,放进 TreeSet 是一个。三套关系对同一个类型给出不同答案时,换一种集合,去重、查找和删除的结果就跟着变。BigDecimal:equals 看精度,compareTo 只看数值。
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。
包装类型:== 只在缓存范围内「碰巧」成立。
Integer i1 = 127, i2 = 127; i1 == i2 // true
Integer i3 = 128, i4 = 128; i3 == i4 // false-128 到 127 的 Integer 来自缓存,是同一个对象;超出范围的每次装箱是新对象。用 == 比较两个包装对象的代码在小数字上全部通过,ID 增长过 127 之后开始出错。包装类型之间的比较用 equals 或先拆箱。
不同的包装类型永远不相等。
Map<Long, String> byId = new HashMap<>();
byId.put(1L, "alice");
byId.get(1) // null:1 被装箱成 Integer
Long.valueOf(1).equals(1) // falseMap.get、Set.contains、List.remove 的参数类型是 Object,传错类型编译器不会报错,只是永远找不到。字面量、int 类型的循环变量、JSON 反序列化出来的数字(小的整数常被解析成 Integer)是最常见的来源。
二、hashCode 与 equals 必须一起成立
哈希集合查找分两步:先用 hashCode 找到桶,再在桶里用 equals 逐个比较。两步里任何一步对不上都找不到。
只重写 equals。 两个内容相同的对象 equals 为 true,但默认的 hashCode 基于对象身份,两者几乎一定落在不同的桶里:
equals(相等的另一个对象) = true
HashSet.contains(相等的另一个对象) = false
ArrayList.contains(相等的另一个对象) = true同一个类在 List 里表现正常,在 Set 里去不了重,原因就在这里。
放进去之后修改了参与 hashCode 的字段。 元素还在原来的桶里,但按新的哈希值去找,找的是另一个桶:
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 检查类型:
p.equals(cp) = true Point 只看坐标
cp.equals(p) = false ColorPoint 要求对方也是 ColorPoint
List.of(cp).contains(p) = true
List.of(p).contains(cp) = falsecontains 用的是参数的 equals 去比较列表里的元素,于是「a 在不在包含 b 的列表里」取决于谁是参数。这类不对称不会抛异常,只会让查找结果随写法变化。可行的做法只有两种:子类不增加参与判等的状态;或者判等时要求两边的类完全相同(getClass() == o.getClass()),接受父子类永不相等。record 和 final 的值类没有这个问题。
四、比较器认为并列的,有序集合只留一个
TreeSet 与 TreeMap 完全不调用 equals,只看比较结果是不是 0:
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))。
七、写值类型时的检查项
- 作为键或集合元素的类,
equals与hashCode一起写,用同一组不可变字段;优先用 record。 - 实现了
Comparable的类,让compareTo返回 0 与equals返回 true 保持一致;做不到(如BigDecimal)就在文档里写明,并在放进集合之前规范化。 - 自定义比较器只用于排序时,确认它不会被拿去建
TreeSet或TreeMap。 - 方法返回集合时,在文档或类型上说明它是视图、快照还是可变的内部状态。
- 测试里加上这几类用例:精度不同的相等数值、超过 127 的 ID、键类型与字面量类型不同的查找、放入集合之后的修改。
配套实验
- codesphere-labs/java/equality-and-collection-views:JDK 25.0.4.1 上
BigDecimal、包装类型、可变键、缺少hashCode、父子类判等、比较器与 record 数组分量在各集合里的结果,以及asList、subList、不可变集合、toList、toMap、keySet与remove重载的行为(验证记录)
参考资料