TreeSet 里的数据少了一半:判重看的是 Comparator,不是 equals
本文首发于 CSDN,同步发布于其他技术社区。文中每个结论都给了可自行验证的出处。
先说一个线上问题。有一批订单要去重后落库,同事用了TreeSet加自定义Comparator,比较键是「金额 + 省份」。跑完之后条数比源数据少了一截,日志干干净净,没有异常。
排查到后半夜才定位到:同一个省、金额相同的两笔不同订单,compare返回了 0,add直接返回 false,第二笔没进集合。代码没有 bug,数据结构也没有 bug,是「用Comparator判重」这件事本身有个前提没被写进注释。
TreeSet判重靠的是compare的返回值,不是equals。这个规则在 javadoc 里写得很清楚,但很多人只记住了「TreeSet是排好序的 Set」。
TreeSet 判重到底看什么?
java.util.Comparator的 javadoc 里有一段专门讲这件事,标题叫「Consistency with equals」,原文是:
It is generally the case, butnotstrictly required that
(compare(x, y)==0) == (x.equals(y)). Generally speaking, any comparator that violates this condition should clearly indicate this fact.
也就是说,compare返回 0 和equals返回 true 本来就不必等价。这在排序场景下完全无害——排序只关心相对顺序,两个「相等」的元素谁在前都行。
但把同一个 comparator 交给TreeSet或TreeMap,性质立刻变了。同一页文档接着写:
Caution should be exercised when using a comparator capable of imposing an ordering inconsistent with equals to order a sorted set (or sorted map).
然后是那个几乎可以当 bug 报告模板用的例子:
For example, suppose one adds two elements
aandbsuch that(a.equals(b) && c.compare(a, b) != 0)to an emptyTreeSetwith comparatorc. The secondaddoperation will return true (and the size of the tree set will increase) … even though this is contrary to the specification of theSet.addmethod.
注意最后半句:这本身就违反了Set.add的规范。javadoc 没有回避这个矛盾,它直接告诉你:用一个与equals不一致的 comparator 去驱动TreeSet,Set契约会被破坏。
反过来更常见的版本是「少数据」:compare返回 0 但equals是 false。这时候第二个元素会被当成「已存在」,add返回 false,静默丢弃。
compare(x,y) | x.equals(y) | 排序结果 | TreeSet 的行为 | TreeMap 的行为 |
|---|---|---|---|---|
| 0 | true | 正常 | 视为同一元素,不加入 | 视为同一 key,覆盖 value |
| 0 | false | 正常 | 丢数据,add返回 false | 丢 key,后一个覆盖前一个 |
| 非 0 | true | 正常 | 同一逻辑元素被存两份 | 同一逻辑 key 存两项 |
| 抛异常 | — | 未定义 | 结构可能已改动一半 | 同左 |
第三行是很多人没想过的:equals判定相等的两个对象,只要compare不给 0,TreeSet就会把它们当两个元素存起来。这时候set.contains(a)会返回 true,但set.size()里真的有两个,equals语义上没有任何区别的两个东西。
那「Comparison method violates its general contract」是哪来的?
网上大量文章把这句异常说成「javadoc 里写了会抛」,这是一个流传很广的误读。
Comparator的 javadoc 在最需要写「抛什么」的位置,写的是契约本身,而不是异常。它规定实现者必须保证三件事:
The implementor must ensure that
signum(compare(x, y)) == -signum(compare(y, x))for allxandy. (This implies thatcompare(x, y)must throw an exception if and only ifcompare(y, x)throws an exception.)
The implementor must also ensure that the relation is transitive:
((compare(x, y)>0) && (compare(y, z)>0))impliescompare(x, z)>0.
Finally, the implementor must ensure that
compare(x, y)==0implies thatsignum(compare(x, z))==signum(compare(y, z))for allz.
三条分别是反对称、传递、与第三方的一致性。文献里通常叫它「全序关系」,但 javadoc 用的是更朴素的写法。
关键是:这三条是「实现者必须保证」,不是「违反时框架会抛异常」。那句Comparison method violates its general contract!是 TimSort 在合并阶段做一致性检查时抛出来的运行时行为,属于实现细节。换个排序实现、换个数据规模、或者数组没触发那条合并路径,同样的错误 comparator 可能什么都不抛,只是排出一个不合法的顺序。
这也解释了为什么线上表现差异极大:本地测几十条数据不报错,生产跑几十万条直接IllegalArgumentException。不是本地「没问题」,是本地样本太少,没走到检测路径。
一个违反传递性的 comparator 长什么样?
最典型的是「优先级 + 兜底字段」的组合。下面这段按「状态优先,其次按时间」排:
Comparator<Task>bad=(a,b)->{if(a.isUrgent()&&!b.isUrgent())return-1;if(!a.isUrgent()&&b.isUrgent())return1;returna.getCreatedAt().compareTo(b.getCreatedAt());};看起来没问题,但它漏了一种情况:a和b都紧急、b和c都不紧急……这类分支漏写的后果就是传递性断裂,进而给出互相矛盾的三元组。
正确的写法是把优先级抽成一个可比较的键,让整条比较链只有一个出口:
Comparator<Task>good=Comparator.comparing(Task::isUrgent).reversed()// 紧急的在前.thenComparing(Task::getCreatedAt);// 同优先级按时间comparing+thenComparing的好处不是短,而是它保证每一层都只有三种返回值,组合出来的关系一定是全序。手写 if 分支最容易在这里出错。
怎么在代码里挡住这类问题?
光靠「小心点」没用,这类 bug 的共同特征是代码看起来对、数据正常时不报错。能落地的做法有三条。
第一条,排序和去重分开用不同的结构。如果目的是去重,用HashSet(走equals/hashCode),再对结果排序;如果目的是「有序 + 按 comparator 唯一」,那就明确接受compare == 0即视为同一个 key,并把这个语义写进注释。不要用一个TreeSet同时承担两个语义。
第二条,给 comparator 加一致性断言。测试里抽几十个真实对象,随机三元组验证一遍传递性,成本很低:
for(Ta:sample)for(Tb:sample)for(Tc:sample){if(cmp.compare(a,b)<=0&&cmp.compare(b,c)<=0)assertTrue(cmp.compare(a,c)<=0,"传递性断裂");assertEquals(Integer.signum(cmp.compare(a,b)),-Integer.signum(cmp.compare(b,a)),"反对称性断裂");}样本不需要多,二三十个真实数据就够把大部分手写分支的问题暴露出来。
第三条,别在 comparator 里做可能抛异常的事。null取值、解析字符串、远程查询,这些一旦抛出来,排序结构已经改动了一半。要处理null就用Comparator.nullsFirst/nullsLast,它是显式声明,不会在运行时给你惊喜。
| 做法 | 适用场景 | 代价 |
|---|---|---|
HashSet去重 + 事后排序 | 只要去重,顺序是一次性的 | 多一次遍历 |
TreeSet+ 一致性 comparator | 需要长期维护有序且唯一 | 必须保证compare==0即equals |
| 测试里随机三元组校验 | 所有手写 comparator | 几十行测试代码 |
Comparator.nullsFirst | 字段可能为null | 改变排序位置,需确认业务语义 |
这张表我是当成评审清单用的:只要看到TreeSet的构造参数是一个 lambda,就回去问一句「这个 comparator 和equals一致吗」。
为什么这类问题在 JDK 8 之后变多了?
因为Comparator从「一个接口」变成了一堆组合子。JDK 8 加入comparing、thenComparing、reversed、nullsFirst之后,写 comparator 从十几行降到一行:用得越多,塞进TreeSet的也越多。组合子本身是安全的——只要每一层都守契约。风险在起点:comparing接受的函数如果返回值本身比较语义就有问题,问题会一路传下去。
我自己的习惯是:只要 comparator 会进TreeSet/TreeMap,就先确认它是不是直接或间接来自comparing,再写一条「compare==0时两个对象equals也必须为 true」的测试。这条测试比任何代码规范都好使,因为它会直接挂。
这套排查清单我最初是散在几个笔记里的,后来用 AI墨衍 归拢成一份文档,跨项目复用的时候省了不少翻聊天记录的时间。顺带一提,CSDN 官方会员说明里的发文额度、GEO检测与图文同步三项,也是我做技术内容分发时实际会用到的东西。
常见问题
Q:TreeSet用的 comparator 违反契约,一定会抛IllegalArgumentException吗?
A:不一定。javadoc 没有把这件事定义成异常,那句话来自 TimSort 实现的检测。数据量小、没走到检测路径时,可能静默排出一个不合法的顺序。
Q:compare返回 0 但equals为 false,为什么add会返回 false?
A:因为TreeSet的红黑树用compare定位和判重,compare==0就走到同一个节点。javadoc 的对应例子写的是反方向的场景,原理相同。
Q:那我能不能让compare永远不用返回 0?
A:不行。如果两个对象在业务上确实等价,compare返回非 0 会让TreeSet存两份,equals语义上就出现了重复元素。
Q:Comparator.equals和对象的equals是一回事吗?
A:不是。Comparator.equals比较的是两个 comparator 是否施加相同的顺序,javadoc 专门定义了它,而且大多数实现直接继承Object的equals(即按引用比较)。
Q:HashMap会有这个问题吗?
A:不会。HashMap用的是hashCode+equals,跟Comparator完全无关。但TreeMap有,规则和TreeSet一样。
Q:用Stream.sorted()会不会遇到同样的问题?
A:会。sorted()用的是同一个Comparable/Comparator,契约违反时同样可能抛异常或者排错。区别是它不会静默丢数据,因为它不做判重。
Q:double字段排序有什么额外注意的?
A:Double.compare把-0.0排在0.0前面,而-0.0 == 0.0为 true;NaN会被排在最后,且不等于自己。如果数据里可能有这两种值,compare==0与equals的一致性就更难保证。
本文引用的Comparator契约原文来自 Java SE 21 官方 javadoc(java.util.Comparator的「Consistency with equals」与compare方法说明两节)。资料整理自 Oracle JDK 官方文档。如果你也在做多平台技术内容分发,CSDN 官方会员说明里的发文额度、GEO检测与图文同步是 AI墨衍 这套流程里实际用到的三项。