记录线上APP一个排序比较引发的崩溃 Comparison method violates its general contract!

最近在做产品需求的时候上线了一个新的产品需求,给用户多了一种新的排序排序规则,更加方便用户找到自己想要的东西。新版本发布后,QA 给我发了一个 线上崩溃 bug 链接,具体内容如下:

看到上面的链接,我有点懵逼了,就这排序还能给我搞出 bug 来?看到抛出的异常信息,也没有见过,于是直接百度搜索了。

一百度,发现很多人遇到这个问题,下面简单说下出现这个问题的原因:

在 JDK7 版本以上,Comparator 要满足自反性,传递性,对称性,不然 Arrays.sort,Collections.sort
会报 IllegalArgumentException 异常。

  • 自反性:当 两个相同的元素相比时,compare必须返回0,也就是compare(o1, o1) = 0;

  • 反对称性:如果compare(o1,o2) = 1,则compare(o2, o1)必须返回符号相反的值也就是 -1;

  • 传递性:如果 a>b, b>c, 则 a必然大于c。也就是compare(a,b)>0, compare(b,c)>0, 则compare(a,c)>0

相信很多人看到这里还是会很懵逼的,感觉自己写的代码是不会出现这个问题的,这里理解的主要难点是怎么复现这个崩溃。

任何问题在我们一开始看到的时候,都会觉得很奇怪,觉得自己写的代码是不会出现这种问题的,可是一旦复现后,就会突然顿悟了,还是有自己遗漏没有想到的 case 。

例子

demo1

其实违反上述规则最简单的例子就是如下: 

new Comparator<Student>() {
    @Override
    public int compare(Student o1, Student o2) {
        return o1.getId() > o2.getId() ? 1 : -1;
    }
}

出现原因:没有考虑相等的情形,所以会抛出异常。

 不过对于有基础的程序猿,一般都会考虑到等号的情形,所以上述代码还是很少会出现的。

new Comparator<Student>() {
    @Override
    public int compare(Student o1, Student o2) {
        if (o1.getId() == o2.getId()) 
            return 0;
        return o1.getId() > o2.getId() ? 1 : -1;
    }
}                

如果按照上面的来,基本就不会有问题了。当然有个点需要注意的是需要判空。

不过我的崩溃和上面的例子还是很不一样的,下面举一个特殊的例子。

demo 2 (线上崩溃例子)

相信大家都用过手机的通讯录,我手机通讯录的排序方式是 # AB…YZ 这种形式的。也就是按照用户名来进行排序的,非字母类型的需要排在前面。

我出代码的问题其实就出现在对于 # 这一类名字的处理。下面看错误代码:

    private static Comparator<CompareObject> mComparatorByPlayingAndLetter = new Comparator<CompareObject>() {
        @Override
        public int compare(CompareObject o1, CompareObject o2) {
            char firstChar = o1.name.charAt(0);
            char secondChar = o2.name.charAt(0);
            if (!isUpperLetters(firstChar) && (isUpperLetters(secondChar))) {
                return 1;
            }
            if (isUpperLetters(firstChar) && !isUpperLetters(secondChar)) {
                return -1;
            }
            if (!isUpperLetters(firstChar) && (!isUpperLetters(secondChar))) {
                return 1;
            }
            return o1.name.compareToIgnoreCase(o2.name);
        }
    };

这里我先说下自己排序的算法思想:

  • 如果一个是大写字母,一个是非大写字母,那么很好排序;

  • 如果两个都是非大写字母,我返回1或者-1都可以,这里我直接给了1,对于非大写字母后面和大写字母的比较,前面的逻辑会进行处理,剩下的就是大写字母之间的比较了。

本地测试,没问题的。QA 测试也是没问题。然后这段代码上线了。

结果昨天刚发布正式版,今天就收到 QA 抛过来的线上崩溃,不过还好只是一个崩溃量。但是为啥会崩溃,我还是没法理解,我本地测试了很多遍,也还是无法复现。也百度看了很多文章,虽然知道崩溃的理论原因,但是如果无法复现,我就还是不能理解。

并且虽然我有崩溃用户的 cuid,但是崩溃的用户的数据排序我是没法拿到的,也就是还是无法复现。后来自己在已有的数据中,加了一些特殊的字符后,终于复现了。

下面来看一下 ASCII 字符表:

 可以看到的是 AB…YZ 是处于后半部分的,数字和大部分特殊符号都是在大写字母前面,然后有部分标点符号是在大写字母后面的。

于是,我利用原有的数据,然后再在其中加入大写字母前后的特殊字符。对于这些数据,除了我这次新增的排序,还有其他排序,比如字母排序,创建时间排序等,不断对这些数据采用其他排序进行展示,然后再切到出问题的排序,多次来回切换排序算法,最终复现了该问题。

但是具体是哪些数据排序后引起的不满足规则,由于数据量比较大,我无法确定出来。但是可以知道的是,最后引起崩溃的两个名字只是雪花,真正有问题的地方在出现问题前就已经埋下了。

那对于上面的问题,如何解决呢?

    private static Comparator<CompareObject> mComparatorByPlayingAndLetter = new Comparator<CompareObject>() {
        @Override
        public int compare(CompareObject o1, CompareObject o2) {
            char firstChar = o1.name.charAt(0);
            char secondChar = o2.name.charAt(0);
            if (!isUpperLetters(firstChar) && (isUpperLetters(secondChar))) {
                return 1;
            }
            if (isUpperLetters(firstChar) && !isUpperLetters(secondChar)) {
                return -1;
            }
            if (!isUpperLetters(firstChar) && (!isUpperLetters(secondChar))) {
                return 1;
            } // 删除红色代码即可
            return o1.name.compareToIgnoreCase(o2.name);
        }
    };

 

总之,以后再写排序比较的时候,对于无法确定大小的情况,交给系统的排序,不要自己去随意改变比较值,这样就不会出现这种 case 了。 

Tags: