技术 TimSort

Comparison method violates its general contract 排查记:BigDecimal 排序的隐蔽陷阱

线上一个偶发的排序异常:Comparison method violates its general contract!。这个异常的诡异之处在于排序代码「看起来没问题」、小数据量永远不报、数据一多就炸。这篇记录当时的排查:从 JDK7 排序算法的变更讲到 Comparator 契约,最后落到真正的根因——BigDecimal.intValue() 的截断。

现场还原

原始代码用 Stream 对列表按数值倒序排序:

java
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());

异常堆栈:

text
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 的三条契约:

  1. 反对称性:sgn(compare(x, y)) = -sgn(compare(y, x));
  2. 传递性:compare(x, y) > 0 且 compare(y, z) > 0,则必须 compare(x, z) > 0;
  3. 一致性: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 的校验立刻引爆。这也是它「偶发」的本质:是否触发取决于数据本身的取值范围。

解决方案

网上流传的做法有三种,前两种是绕、第三种才是修:

其一,退回旧排序算法(治标,不推荐):

java
System.setProperty("java.util.Arrays.useLegacyMergeSort", "true");
// 或启动参数 -Djava.util.Arrays.useLegacyMergeSort=true

让 JVM 用回 JDK6 的旧归并,不做契约校验——坏比较器继续潜伏,只是没人揭发而已。

其二,降低 JDK 版本:同上,属于把头埋进沙子。

其三,让比较委托给compareTo(正解):重写 compare 时不要自己动手算差值,交给 BigDecimal.compareTo() 处理,它返回的 -1/0/1 严格遵循契约:

java
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(),语义一目了然。

两点经验沉淀

  1. Comparator 里永远不要用「做减法」的方式比较——不管是整数 a - b(Integer.MIN_VALUE 溢出)还是这里的 BigDecimal.subtract().intValue()(截断),都是在拿数值表示的边界赌博。正确姿势是逐分支比较 >0 / <0 / ==0,或者直接用 compareTo;
  2. intValue() / longValue() 是有损转换,从 BigDecimal 取整数前先想清楚量级,或用 intValueExact()——超范围时它至少会抛异常,而不是悄悄给错值。

写在后面

这个异常后来又遇到过两次,一次在同事的金额排序代码里,一次在缓存对象的权重比较里,根因都是同一个模式:用算术差代替比较结果。把它记成一条团队规范后再没出过——越是看起来无害的工具方法,越容易在数据的边界上翻车。