Comparison method violates its general contract 排查记:BigDecimal 排序的隐蔽陷阱
线上一个偶发的排序异常:
Comparison method violates its general contract!。这个异常的诡异之处在于排序代码「看起来没问题」、小数据量永远不报、数据一多就炸。这篇记录当时的排查:从 JDK7 排序算法的变更讲到 Comparator 契约,最后落到真正的根因——BigDecimal.intValue()的截断。
现场还原
原始代码用 Stream 对列表按数值倒序排序:
listRs = list.stream().sorted((n1, n2) -> {
BigDecimal value1 = n1.getValue1();
BigDecimal value2 = n2.getValue2();
if (value1.subtract(value2).intValue() > 0) {
return -1;
}
if (value1.subtract(value2).intValue() < 0) {
return 1;
}
return 0;
}).collect(Collectors.toList());异常堆栈:
java.lang.IllegalArgumentException: Comparison method violates its general contract!
at java.util.TimSort.mergeHi(TimSort.java:868)
at java.util.TimSort.mergeAt(TimSort.java:485)
at java.util.TimSort.mergeCollapse(TimSort.java:408)
at java.util.TimSort.sort(TimSort.java:214)
at java.util.TimSort.sort(TimSort.java:173)
at java.util.Arrays.sort(Arrays.java:659)
at java.util.Collections.sort(Collections.java:217)为什么 JDK6 不报、JDK7 报
这不是业务代码「以前对现在错」,而是** JDK7 换了排序算法**。
JDK6 的 Collections.sort 用的是传统归并排序,它信任比较器的结果,即使结果自相矛盾也只是排出来的顺序不好看,不会报错。JDK7 起换成了 TimSort(归并 + 插入的混合排序,也是 Python sorted 的同款算法),它在合并过程中会主动校验比较结果的 consistency——一旦发现比较器对同一组元素给出的答案自相矛盾(违反传递性或反对称性),立即抛出 IllegalArgumentException。
换句话说:JDK7 不是制造了 bug,而是把一直存在的坏比较器暴露了出来。
比较器契约
TimSort 校验的是 Comparator 的三条契约:
- 反对称性:
sgn(compare(x, y)) = -sgn(compare(y, x)); - 传递性:
compare(x, y) > 0且compare(y, z) > 0,则必须compare(x, z) > 0; - 一致性:
compare(x, y) == 0时,sgn(compare(x, z))必须等于sgn(compare(y, z))。
原代码看起来满足「大于 0 返回 -1、小于 0 返回 1、相等返回 0」,问题藏在 intValue() 里。
真正的根因:BigDecimal.intValue() 的截断
BigDecimal.intValue() 的语义是舍弃小数、仅取低 32 位整数部分。当两个 BigDecimal 的差值超过 Integer.MAX_VALUE(约 21.4 亿)时,截断后可能:
- 差值 4294967296(2 的 32 次方)→
intValue()变成 0:明明不相等的两个数被判为相等,违反一致性; - 更大数量级的差值甚至可能变号:明明
x > y却返回x < y的结果,直接击穿反对称性和传递性。
所以这份代码在小数值下完美运行,一旦列表里出现金额达到十亿级的元素,比较器就开始说谎,TimSort 的校验立刻引爆。这也是它「偶发」的本质:是否触发取决于数据本身的取值范围。
解决方案
网上流传的做法有三种,前两种是绕、第三种才是修:
其一,退回旧排序算法(治标,不推荐):
System.setProperty("java.util.Arrays.useLegacyMergeSort", "true");
// 或启动参数 -Djava.util.Arrays.useLegacyMergeSort=true让 JVM 用回 JDK6 的旧归并,不做契约校验——坏比较器继续潜伏,只是没人揭发而已。
其二,降低 JDK 版本:同上,属于把头埋进沙子。
其三,让比较委托给compareTo(正解):重写 compare 时不要自己动手算差值,交给 BigDecimal.compareTo() 处理,它返回的 -1/0/1 严格遵循契约:
listRs = list.stream().sorted((n1, n2) -> {
BigDecimal value1 = n1.getValue1();
BigDecimal value2 = n2.getValue2();
// 倒序,所以乘 -1;compareTo 返回 -1/0/1 三值,乘 -1 不会溢出
return -1 * value1.compareTo(value2);
}).collect(Collectors.toList());更简洁的等价写法是 Comparator.comparing(Xxx::getValue1).reversed(),语义一目了然。
两点经验沉淀
- Comparator 里永远不要用「做减法」的方式比较——不管是整数
a - b(Integer.MIN_VALUE溢出)还是这里的BigDecimal.subtract().intValue()(截断),都是在拿数值表示的边界赌博。正确姿势是逐分支比较>0 / <0 / ==0,或者直接用compareTo; intValue()/longValue()是有损转换,从BigDecimal取整数前先想清楚量级,或用intValueExact()——超范围时它至少会抛异常,而不是悄悄给错值。
写在后面
这个异常后来又遇到过两次,一次在同事的金额排序代码里,一次在缓存对象的权重比较里,根因都是同一个模式:用算术差代替比较结果。把它记成一条团队规范后再没出过——越是看起来无害的工具方法,越容易在数据的边界上翻车。