同样的代码循环 10次正常 循环 100次就抛异常?自定义 Comparator 的 bug 让我丢尽颜面

21 阅读13分钟

前言

某日正在使用 claude code 快乐的 vibecoding 的时候,手机收到一条告警短信,提示生产环境一个接口每天都有 5% 左右的请求失败,报错的信息如下:

java.lang.IllegalArgumentException: Comparison method violates its general contract!

仔细看下这个接口这接口还是本人 3 年前“古法手工编程”写出来的逻辑,只不过这个异常真的是第一次见, “Comparison method violates its general contract” 这是什么鬼?

不过现在的我已经有了大模型的武器,直接堆栈贴给大模型,大模型告诉我, 这个报错是比较器设计的有 bug 违反了比较器三原则 “一致性” “自反性” 和 “对称性”

这三原则是啥意思?自定义比较器为什么要符合这三个原则?不符合会有啥结果?带着上面的这些问题,我决定好好来看看这段代码背后的底层逻辑。

代码 bug 场景再现

首先我们看下比较器的代码,比较器的代码逻辑如下:

@Data
public class ComprehensiveComparator<T> implements Comparator<T> {

    /**
     * 价格
     */
    private Function<T, BigDecimal> priceFunction;
    /**
     * 折后价
     */
    private Function<T, BigDecimal> discountPriceFunction;
    /**
     * 距离
     */
    private Function<T, Double> distanceFunction;


    private Boolean isPriceFirst;


    public ComprehensiveComparator(Function<T, BigDecimal> priceFunction,
                                   Function<T, BigDecimal> discountPriceFunction,
                                   Function<T, Double> distanceFunction) {
        this.priceFunction = priceFunction;
        this.distanceFunction = distanceFunction;
        this.discountPriceFunction = discountPriceFunction;
    }

    @Override
    public int compare(T o1, T o2) {

        double distance1 = Optional.ofNullable(distanceFunction.apply(o1)).orElse(0.0);
        double distance2 = Optional.ofNullable(distanceFunction.apply(o2)).orElse(0.0);

        BigDecimal price1 = priceFunction.apply(o1);
        BigDecimal price2 = priceFunction.apply(o2);

        // 没有价格信息的全部排到最后,然后比较价格和距离距离近和价格低的排前面
        if (price1 == null) {
            return 1;
        }
        if (price2 == null) {
            return -1;
        }

        // 比较价格 需要考虑折扣有和折扣无两种情况
        BigDecimal discountPrice1 = discountPriceFunction.apply(o1);
        BigDecimal discountPrice2 = discountPriceFunction.apply(o2);

        BigDecimal realComparePrice1 = discountPrice1 == null ? price1 : discountPrice1;
        BigDecimal realComparePrice2 = discountPrice2 == null ? price2 : discountPrice2;

        if (isPriceFirst != null && isPriceFirst) {
            // 如果价格优先则先比较价格再比较距离
            int priceCompare = realComparePrice1.compareTo(realComparePrice2);
            if (priceCompare != 0) {
                return priceCompare;
            }
            return Double.compare(distance1, distance2);
        } else {
            int distanceCompare = Double.compare(distance1, distance2);
            if (distanceCompare != 0) {
                return distanceCompare;
            }
            return realComparePrice1.compareTo(realComparePrice2);
        }

    }
}

这段代码主要是为了解决当时存在充电站点记录时,需要先按照价格排序再按照距离排序的需求。看起来就是这段代码出了问题,并且从比较器的逻辑来看,我大致推断应该是某些记录的价格字段为空,导致比较器抛出异常,于是我写了一个 main 方法准备来测试下这段代码

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class AppMapStationData {
    private BigDecimal totalDiscountPrice;
    private BigDecimal totalPrice;
    private Double distance;
}


public class MainTest {
    public static void main(String[] args) {
        ComprehensiveComparator<AppMapStationData> comparator = new ComprehensiveComparator<>(
                AppMapStationData::getTotalPrice,
                AppMapStationData::getTotalDiscountPrice,
                AppMapStationData::getDistance
        );
        
        int iteration = 10;
        
        double[] distanceVals = {2300.0, 1000.0, 2000.0, 3000.0, 4000.0};
        List<AppMapStationData> stations = new ArrayList<>();
        for (int i = 0; i < iteration; i++) {
            stations.add(AppMapStationData.builder()
                    .totalPrice(i % 2 == 0 ? new BigDecimal("2.54") : null)
                    .distance(distanceVals[i % distanceVals.length])
                    .build());
        }

        try {
            Collections.sort(stations, comparator);
            System.out.println("排序成功,没有抛出异常");
            System.out.println(String.join("\n", stations.stream().map(AppMapStationData::toString).toArray(String[]::new)));
        } catch (IllegalArgumentException e) {
            e.printStackTrace();
            System.out.println("抛出异常:" + e.getMessage());
        }
    }
}

结果上面的测试代码运行竟然毫无问题,全程没有异常,输出下面的结果,符合要把价格为空的记录放在最后,同样价格的记录按照距离排序的需求:

AppMapStationData(totalDiscountPrice=null, totalPrice=2.54, distance=1000.0)
AppMapStationData(totalDiscountPrice=null, totalPrice=2.54, distance=2000.0)
AppMapStationData(totalDiscountPrice=null, totalPrice=2.54, distance=2300.0)
AppMapStationData(totalDiscountPrice=null, totalPrice=2.54, distance=3000.0)
AppMapStationData(totalDiscountPrice=null, totalPrice=2.54, distance=4000.0)
AppMapStationData(totalDiscountPrice=null, totalPrice=null, distance=1000.0)
AppMapStationData(totalDiscountPrice=null, totalPrice=null, distance=3000.0)
AppMapStationData(totalDiscountPrice=null, totalPrice=null, distance=2300.0)
AppMapStationData(totalDiscountPrice=null, totalPrice=null, distance=2000.0)
AppMapStationData(totalDiscountPrice=null, totalPrice=null, distance=4000.0)

这可真的奇怪了咋没浮现出来生产环境的异常呢?我又仔细查看了生产环境的返回数据,发现生产环境出现报错的记录总数大约在 50 条左右,于是我把上面的代码中的 iteration 改成 50,再次运行,果然异常出现了!

java.lang.IllegalArgumentException: Comparison method violates its general contract!
    at java.util.TimSort.mergeLo(TimSort.java:777)
    at java.util.TimSort.mergeAt(TimSort.java:514)
    at java.util.TimSort.mergeCollapse(TimSort.java:441)
    at java.util.TimSort.sort(TimSort.java:245)
    at java.util.Arrays.sort(Arrays.java:1512)
    at java.util.ArrayList.sort(ArrayList.java:1464)
    at java.util.Collections.sort(Collections.java:177)
    at com.ahucoding.beast.demo.MainTest.main(MainTest.java:35)

这可就奇怪了,并且笔者多次测试迭代次数在 35次以下的时候没有异常,在 35次以上时几乎必现异常,这到底咋回事?

原因深度分析

那么这段代码为什么存在这样的问题? 从报错位置来看代码的问题出现在 Collections.sort(stations, comparator); 这段代码里面,那么Collections.sort 底层使用的排序算法是怎样的呢?

接下来的部分我们将分为两个部分来详细分析这段代码问题出现的根本原因

Collections.sort 在 Java 中的实现原理

Java 7 开始,Collections.sort 底层使用的排序算法从传统的 归并排序(MergeSort) 替换成了 TimSort。TimSort 是一种混合、稳定的排序算法,由 Tim Peters 在 2002 年为 Python 设计,后来被 Java 采纳。

TimSort 的核心思想是:现实世界中的数据往往不是完全随机的,而是存在大量已经有序的"片段"。它会把这些有序片段识别出来(TimSort 称这些片段为 "run"),然后高效地合并它们。

TimSort 的排序流程大致如下:

  1. 扫描数据,识别 run:从左到右扫描数组,找到已经升序排列的片段(严格降序的片段会被反转)。
  2. 保证 run 的最小长度:如果某个 run 太短,会用二分插入排序把它扩展到最小长度(minRun)。
  3. 压栈并合并 run:把每个 run 的起始位置和长度压到一个栈中,同时检查栈顶的几个 run 是否满足合并条件,如果满足就把相邻的 run 合并。

关键来了——TimSort 在合并 run 的时候,会做一些优化假设。它会假设比较器是"可传递"的:如果 A == B 且 B == C,那么 A == C。如果比较器不满足这个假设,TimSort 在 mergeLomergeHi 方法中检测到矛盾时,就会直接抛出 IllegalArgumentException: Comparison method violates its general contract!

为什么数据量少的时候不报错?

这里有两个原因,在 java.util.TimSort.sort 在数据量比较少的情况下会优先使用 二分插入排序 这是一种插入排序的优化版本,利用二分查找的方式快速定位到该插入的位置,源代码片段如下:

// If array is small, do a "mini-TimSort" with no merges
// MIN_MERGE 源码里面是 32
if (nRemaining < MIN_MERGE) {
    int initRunLen = countRunAndMakeAscending(a, lo, hi, c);
    binarySort(a, lo, hi, lo + initRunLen, c);
    return;
}

TimSort 对 run 的合并检查有一个阈值控制。当数据量较小的时候(通常 32 个元素以下),TimSort 可能不会触发完整的合并逻辑检查路径,或者说碰巧没有触发传递性矛盾的检测分支。

当数据量超过某个阈值后,TimSort 会产生更多的 run,合并操作的复杂度上升,比较器不一致的问题就暴露出来了。这就是为什么我测试的时候,35 次以下能跑通,35 次以上几乎必现——这不是 bug,这是 TimSort 在保护我。

TimSort 的 mergeLo 为什么能检测到"违反约定"?

mergeLo 的合并过程中,TimSort 会从两个已排序的 run 中交替取元素。它会维护一个"连赢计数器"(galloping threshold):如果某一边的元素连续多次都小于另一边,说明这两个 run 之间存在某种"一边倒"的关系,算法会进入 galloping 模式来加速合并。

但如果比较器违反传递性,就可能出现这种情况:A 在 run1 中小于 B,B 在 run2 中小于 C,但 C 在 run1 中又不小于 A——这就产生了逻辑矛盾。TimSort 检测到这种不可能出现的状态时,果断抛异常而不是返回一个错误排序的结果。

宁可报错,也不给错误结果——这是 TimSort 的设计哲学。

比较器三原则

Java 的 Comparator 接口在 Javadoc 中明确规定了一个正确的比较器必须满足以下三个数学性质:

1. 自反性(Reflexivity)

compare(x, x) == 0

任何元素和自身比较,必须返回 0。这看起来是废话,但实际上有些比较器会违反。比如在比较器中引入随机因素,或者比较了会变化的状态字段,就可能导致 compare(x, x) 不等于 0。

2. 对称性(Symmetry / Antisymmetry)

compare(x, y) == -compare(y, x)

如果 x 小于 y,那么 y 必须大于 x。如果 x 等于 y,那么 y 也必须等于 x。

这条原则看似简单,但在多字段比较中也容易出问题。比如第一个字段 x.a < y.a 返回 -1,但反过来的时候因为某些分支逻辑不同,可能没有正确地返回 1。

3. 传递性(Transitivity)

如果 compare(x, y) > 0compare(y, z) > 0,那么 compare(x, z) > 0

传递性是比较器三原则中最容易被违反的,也是本次 bug 的罪魁祸首。

4. 一致性(Consistency with equals,虽然不是必须,但强烈推荐)

如果 compare(x, y) == 0,那么 x.equals(y) 也应该为 true

这条不是强制要求,但如果违反,在使用 TreeSetTreeMap 这种依赖比较器去重的数据结构时会出现"元素丢失"的诡异现象。

我的比较器到底违反了哪条原则?

回到我的代码,让我来做一个推演。假设现在有 3 个对象:

  • A:price = 2.54, distance = 1000
  • B:price = null, distance = 2000
  • C:price = 2.54, distance = 3000

按照比较器逻辑,走的是距离优先的分支(因为 isPriceFirst 为 null,进入 else 分支):

比较逻辑路径结果
compare(A, B)A 的价格不为 null,B 的价格为 null → 价格不为 null 的排前面-1(A < B)
compare(B, C)B 的价格为 null,C 的价格不为 null → null 排后面1(B > C)
compare(A, C)都有价格,距离优先 → 比较距离 1000 vs 3000-1(A < C)

目前看起来没问题。但让我们换一个场景:

  • A:price = null, distance = 1000
  • B:price = 2.54, distance = 2000
  • C:price = null, distance = 3000
比较逻辑路径结果
compare(A, B)A 价格为 null → 返回 1(A > B)1
compare(B, C)C 价格为 null → 返回 -1(B < C)-1
compare(A, C)两者价格都为 null,距离优先 → 比较距离 1000 vs 3000-1(A < C)

到这里:A > B, B < C, A < C。传递性没被打破,但问题出在 null 值的处理不对称

  • compare(A, B) = 1(A 排后面)
  • compare(B, A) = -1(B 排前面)

这对称没问题。但关键是:两个价格都为 null 的对象之间的比较和它们与有价格对象之间的比较逻辑是割裂的。当 TimSort 把大量元素混合在一起合并的时候,这种"割裂"就可能导致传递性链条断裂。

更精确地说,在我的代码中:

if (price1 == null) {
    return 1;  // o1 排后面
}
if (price2 == null) {
    return -1; // o1 排前面
}

这里有一个致命问题:当两个对象的价格都为 null 时,上面的判断都跳过了,代码继续往下走距离比较。但此时距离比较的结果和前面的"null 排最后"语义不一致

举例:

  • A: price = null, distance = 1000
  • B: price = null, distance = 3000
  • C: price = 2.54, distance = 2000
比较结果含义
compare(A, C)1A 价格为 null,排在 C 后面
compare(B, C)1B 价格为 null,排在 C 后面
compare(A, B)-1都有 null 价格,比较距离,A 更近

A > C, B > C, A < B → A 和 B 都在 C 后面,A 又比 B 靠前。这个关系本身没问题。

但当数据量大了,TimSort 在合并不同 run 的时候,它假设如果一个元素在 run1 中大于 C,在 run2 中也应该大于 C。但我的比较器在不同分支(价格判断 vs 距离判断)之间切换的时候,没有保证这个跨 run 的一致性,最终 TimSort 在 mergeLo 中检测到了矛盾。

修复方案

知道了原因,修复就简单了。核心思路是:让 null 值的处理覆盖所有分支,确保比较器满足传递性

@Override
public int compare(T o1, T o2) {
    BigDecimal price1 = priceFunction.apply(o1);
    BigDecimal price2 = priceFunction.apply(o2);

    // 1. 先处理价格 null 的情况——保证传递性
    boolean o1HasPrice = price1 != null;
    boolean o2HasPrice = price2 != null;

    if (!o1HasPrice && !o2HasPrice) {
        // 两个都没价格,比较距离,距离近的排前面
        double distance1 = Optional.ofNullable(distanceFunction.apply(o1)).orElse(0.0);
        double distance2 = Optional.ofNullable(distanceFunction.apply(o2)).orElse(0.0);
        return Double.compare(distance1, distance2);
    }
    if (!o1HasPrice) {
        return 1;  // o1 没价格,排后面
    }
    if (!o2HasPrice) {
        return -1; // o2 没价格,排后面
    }

    // 2. 两个都有价格,继续比较
    double distance1 = Optional.ofNullable(distanceFunction.apply(o1)).orElse(0.0);
    double distance2 = Optional.ofNullable(distanceFunction.apply(o2)).orElse(0.0);

    BigDecimal discountPrice1 = discountPriceFunction.apply(o1);
    BigDecimal discountPrice2 = discountPriceFunction.apply(o2);

    BigDecimal realComparePrice1 = discountPrice1 == null ? price1 : discountPrice1;
    BigDecimal realComparePrice2 = discountPrice2 == null ? price2 : discountPrice2;

    if (isPriceFirst != null && isPriceFirst) {
        int priceCompare = realComparePrice1.compareTo(realComparePrice2);
        if (priceCompare != 0) {
            return priceCompare;
        }
        return Double.compare(distance1, distance2);
    } else {
        int distanceCompare = Double.compare(distance1, distance2);
        if (distanceCompare != 0) {
            return distanceCompare;
        }
        return realComparePrice1.compareTo(realComparePrice2);
    }
}

修复的核心改动:

  1. 把 null 值判断提前到最前面,并且在两个对象都为 null 时给出明确的比较逻辑(比较距离),而不是"跳过"null 判断后意外走到后面的分支。
  2. 保证所有路径的比较维度一致:先判断有没有价格 → 有价格的比较价格/距离 → 没价格的比较距离 → 一个有、一个没有的,有价格的排前面。

用修复后的代码重新跑 100 次循环测试,异常不再出现。

延伸思考:为什么 Java 不直接忽略这个错误?

很多人可能会问,既然比较器有问题,Java 为什么不默默容错,比如退化成一种更宽容的排序方式?

答案在 TimSort 的设计哲学里:"与其给你错误的结果,不如直接告诉你出了问题。"

想象一下,如果 TimSort 不抛异常,而是返回了一个排序错误的结果。你可能永远不会知道数据有问题——因为大部分测试场景下它看起来"能跑"。但在线上环境中,某些极端情况会让用户看到完全混乱的排序结果,而这种 bug 比直接抛异常更难定位。

IllegalArgumentException 是 Java 7 在 TimSort 中引入的保护机制。在这之前(Java 6 用的是传统归并排序),比较器不满足三原则时不会抛异常,但排序结果是不可预测的——有些时候对,有些时候错,而且错的时机完全取决于底层数据和算法路径。"静默错误"远比"直接崩溃"更可怕

总结

回顾这次排查经历,我总结了以下几个教训:

  1. 自定义 Comparator 必须满足比较器三原则:自反性、对称性、传递性。这不是"最佳实践",而是必须,否则 TimSort 会在数据量大时直接拒绝工作。

  2. null 值处理是比较器最常见的陷阱。null 不是"一个值",它是一种"缺失"。在比较器中处理 null 时,必须保证所有分支路径都满足传递性,不能出现"两个都为 null 就走另一套逻辑"导致语义断裂的情况。

  3. 小数据量测试不能替代大数据量测试。TimSort 在小数据量下可能不会触发合并逻辑的严格校验,所以"10 次不报错,100 次必报错"这种场景在排序相关的代码中非常常见。

  4. 大模型是很好的排查工具,但底层原理还是要自己懂。大模型能告诉我"违反了比较器三原则",但要真正理解为什么会违反、在什么条件下违反、如何正确修复,还是要靠自己阅读源码和分析算法。

最后感慨一下,3 年前手工写的代码,今天被 TimSort 教做人。但话说回来,如果不是 TimSort 的这层保护机制,这个 bug 可能会在生产环境潜伏更久而不被发现。感谢 Tim Peters,也感谢那个在设计 TimSort 时决定"宁可报错也不给错误结果"的工程师。