bigywater.tech
Java 内部原理 · 39

Comparator 违反契约:真实触发 JDK 自带的 Comparison method violates its general contract

不少人在生产环境真实撞见过这句报错,却不一定知道它是从哪冒出来的。真实写一个违反传递性的比较器,把这句报错真实、可控地复现出来,顺便搞清楚它的真实触发条件。

10~11~12 传递性破坏
真实验证:compare(10,11)=0,compare(11,12)=0,但 compare(10,12)=-2——传递性在这里真实被打破了
size≥32 才可能触发
真实对照:数组长度 20(低于 TimSort 的 32 阈值)跑 2000 次同样的坏比较器,真实一次都没触发这个检测

上一篇:三元表达式里的拆箱 NPE:真实验证条件选中的分支才会真实拆箱

真实验证:一个"看起来合理"的比较器,真实违反了传递性

Comparator 的契约要求满足传递性:如果 a 和 b "相等"、b 和 c "相等",那么 a 和 c 也必须"相等"。一个基于差值容忍度的比较器很容易在不知不觉间打破这一点。

ComparatorContractDemo.java static final Comparator<Integer> fuzzyComparator = (a, b) -> { int diff = a - b; if (Math.abs(diff) < 2) return 0; // 差值在 2 以内就当成"相等" return diff; };
真实实测
compare(10, 11) = 0 (真实判定为相等,因为差值 1 < 2)
compare(11, 12) = 0 (真实判定为相等,因为差值 1 < 2)
compare(10, 12) = -2 (差值 2,真实判定为不相等——传递性在这里真实被破坏了)

这种"模糊相等"的比较器在业务代码里并不少见(比如浮点数按精度容忍度比较、时间戳按秒级粒度比较),看起来很合理,但真实数学性质上并不满足传递性——10 和 11"相等"、11 和 12"相等",但 10 和 12 真实不"相等",这在严格的数学关系里是自相矛盾的。

真实验证:用一个更彻底的坏比较器,真实触发 JDK 的检测

上面那个 fuzzy 比较器在几百个元素的随机数组上不一定能真实触发 JDK 的检测(不是所有违反契约的比较器都会在合并排序过程中暴露出来)。换一个彻底随机、完全不一致的比较器,更容易复现。

RandomCmp.java(更彻底的坏比较器) Comparator<Integer> randomComparator = (a, b) -> rnd.nextBoolean() ? -1 : 1; // 连 compare(x, x) 都不保证返回 0,是最彻底的契约违反
真实实测 · 数组长度 64,连续多次尝试
trial=0 没触发
trial=1 没触发
...
trial=6 真实抛出: Comparison method violates its general contract!
在 64 个元素的数组上重复排序,真实在第 6 次尝试时触发了 JDK 自带的检测

真实验证:数组太小,这个检测真实不会触发

同样彻底随机的坏比较器,换成一个小很多的数组,重复跑很多次。

真实实测 · 数组长度 20,重复 2000 次
size=20, 跑 2000 次, 真实触发次数 = 0

2000 次真实尝试,一次都没有触发。原因在 TimSort 内部的实现细节:数组长度小于 MIN_MERGE(JDK 里定义为 32)时,排序走的是一条简化路径——直接做二分插入排序(binary insertion sort),不会进入真正的"拆分成多段有序 run、再归并"的 TimSort 主流程,而这句报错真实来自归并过程里的一致性检查。数组长度不到 32,这段检测代码根本不会被执行到,不管比较器有多离谱,也不会真实报错——错误的排序结果可能已经产生了,只是没人告诉你。

这意味着什么?这个检测是 JDK 的一份"尽力而为"的安全网,不是完备的契约验证器——数组小、或者恰好没有触发某个具体的归并模式,违反契约的比较器完全可能悄无声息地产出一个错误排序的结果,不抛任何异常。真实能依赖的,只有从一开始就确保自己写的 Comparator 满足自反、对称、传递这几条基本性质,不能指望"反正跑起来会报错提醒我"。

分步演示:比较器链是怎么真实一步步决定排序结果的

每一行都是真实运行这段代码得到的结果。

thenComparing/reversed 的组合顺序真实决定了最终排序结果;违反传递性的比较器只在数据量够大时才会被真实探测到
这一步新验证的排序结果

    参考与说明

    • 本文全部结果均在本机真实运行 Java 17 得到,触发次数(第几次尝试)因为用到真随机数,每次运行会不同,但"最终会触发"和"小数组不触发"这两个结论是稳定、可重复的。
    • 和Arrays.sort 稳定性那篇是同一个 TimSort 主题下的两篇,建议对照读。
    • 没有涉及:TimSort 内部具体在哪一步做了什么样的一致性检查、Arrays.sort(int[])(原始类型数组,用双轴快排,没有这个契约检测机制)在同样场景下的表现——这些留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号