記錄線上APP一個排序比較引發的崩潰 Comparison method violates its general contract!

huansky發表於2021-11-14

最近在做產品需求的時候上線了一個新的產品需求,給使用者多了一種新的排序排序規則,更加方便使用者找到自己想要的東西。新版本釋出後,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 了。 

相關文章