Comparison method violates its general contract!

1.摘要

前一陣遇到了一個使用Collections.sort()時報異常的問題,跟小夥伴@zhuidawugui 一起排查了一下,發現問題的原因是JDK7的排序實現改爲了TimSort,之後我們又進一步研究了一下這個神奇的算法。

2.背景

先說一下爲什麼要研究這個異常,前幾天線上服務器發現日誌裏有偶發的異常:

出錯部分的代碼如下:

List<Integer>list =getUserIds();
Collections.sort(list,new Comparator<Integer>(){
    @Override
    publicint compare(Integero1,Integer o2){
        returno1>o2?1:-1;
    }
});

google了一下:JDK7中的Collections.Sort方法實現中,如果兩個值是相等的,那麼compare方法需要返回0,否則可能會在排序時拋錯,而JDK6是沒有這個限制的。

這個問題在測試時並沒有出現,線上也只是小概率復現,如何穩定的復現這個問題?看了一下源代碼,拋出異常的那段源代碼讓人根本摸不着頭腦:

if(len2== 0){
    thrownew IllegalArgumentException("Comparison method violates its general contract!");
}


爲了解開這個困惑,我們對java實現的Timsort代碼做了一些分析。

3.Timsort概述

TimSort排序是一種優化的歸併排序,它將歸併排序(merge sort) 與插入排序(insertion sort) 結合,並進行了一些優化。對於已經部分排序的數組,時間複雜度遠低於 O(n log(n)),最好可達 O(n),對於隨機排序的數組,時間複雜度爲 O(nlog(n)),平均時間複雜度 O(nlog(n))。

它的整體思路是這樣的:

  1. 遍歷數組,將數組分爲若干個升序或降序的片段,(如果是降序片段,反轉降序的片段使其變爲升序),每個片段稱爲一個Runtask
  2. 從數組中取一個RunTask,將這個RunTask壓棧。
  3. 取出棧中相鄰兩個的RunTask,做歸併排序,並將結果重新壓棧。
  4. 重複(2),(3)過程,直到所有數據處理完畢。

這篇文章就不再過多的闡述Timsort整體思路了,有興趣可以參考[譯]理解timsort, 第一部分:適應性歸併排序(Adaptive Mergesort)

4.Timsort的歸併

重點說一下Timsort中的歸併。歸併過程相對普通的歸併排序做了一定的優化,假如有如下的一段數組:

normal1

  1. 首先把數組拆成兩個RunTask,這裏稱爲A段和B段,注意,A段和B段在物理地址上是連續的:
    normal1

  2. A段的起點爲base1,剩餘元素數量爲len1;B段起點爲base2,剩餘元素數量爲len2。取B點的起點值B[base2],在A段中進行二分查找,將A段中小於等於B[base2]的段作爲merge結果的起始部分;再取A段的終點值a[base1 + len1 – 1],在B段中二分查找,將B段中大於等於a[base1 + len1 – 1]值的段作爲結果的結束部分。

    更形象的說,這裏把待歸併的數據“掐頭去尾”,只需要合併中間的數據就可以了:
    normal1

  3. 之後需要創建一個tmp數組,大小爲B段截取後的大小,並把B段剩餘的數據拷貝過去,因爲合併過程中這些數據會被覆蓋掉。

    程序會記錄corsor1和corsor2,這是待歸併數據的指針,初始位置在A段和tmp段的末尾。同時會記錄合併後數組的dest指針,位置在原B段的末尾。

    這裏還有一個小優化:生成dest指針時會直接把A段cursor1指向的數據拷貝到B段末尾,同時cursor–,dest–。因爲之前(2)步的時候已經保證了arr[cursor1]>arr[dest]
    normal1

  4. 進行歸併排序,這裏每次歸併比較時會記錄A和tmp段比較“勝利(大於對方)”的次數,比較失敗(小於對方)時會把勝利數清零。當有一個段的數據連續N次勝利時會激活另一個優化策略,在這裏假設N爲4,下圖已經是A段連續勝利了4次的情況:
    normal1

  5. 如果連續勝利N次,那麼可以假設A段的數據平均大於B段,此時會用tmp[cursor2]的值在A[base0]至A[cursor1]中查找第一個小於tmp[cursor2]的索引k,並把A[k+1]到A[cursor1]的數據直接搬移到A[dest-len,dest]。

    對於例子中的數據,tmp[cursor2]=8,在A數組中查找到小於8的第一個索引(-1),之後把A[0,1]填充到A[dest-1,dest],cursor1和dest指針左移兩個位置。
    normal1

  6. 如果cursor1>=0,之後會再用curosr1指向的數據在tmp數組中查找,由於這裏cursor1已經是-1了,循環結束。

  7. 最後把tmp裏剩餘的數據拷貝到A數組的剩餘位置中,結束。
    normal1

5.異常情況下Timsort的歸併

假設這裏實現的compare(obj o1,obj o2)如下:

publicint compare(Integero1,Integer o2){
    returno1>o2?1:-1;
}


  1. 仍然是分成A,B兩段:
    normal1

  2. 在“掐頭去尾”的時候,這時會有一些變化,程序執行到compare(B[base2],A[base1])時返回-1,A的左側留下了兩個應該被切走的“5”。
    normal1

  3. 接下來是正常的歸併過程。
    normal1

  4. 這裏同樣會觸發“勝利”>N次邏輯
    normal1

  5. 在A[base1,cursor1]中查找小於tmp[cursor2]的元素,複製,cursor1和dest左移兩位。
    normal1

  6. 此時再用A[cursor1]在tmp中查找,tmp中所有的數據都被移入A數組,cursor2、dest左移4位。tmp2剩餘元素的數量(len2)爲0。
    normal1

注意!

在第6步查找的時候,有A[base1+1]<tmp[0](tmp[0]的值等於沒有合併之前的B[base2])。
而第2步時,有B[base2]<A[base1]
而最初生成RunTask的時候,有A[base1]<=A[base1+1]
連起來就是B[base2]<A[base1]<=A[base1+1]<B[base2],這顯然是有問題的。

所以,當len2==0時,會拋出“Comparison method violates its general contract”異常。問題復現的條件是觸發“勝利N次”的優化,並且存在類似(A[base1]==A[base1+x])&&(A[base1+x]==B[base2])的數據排列。這裏應該還有幾種另外的觸發條件,精力有限,就不再深究了。

6.參考

TimSort in Java 7OpenJDK 源代碼閱讀之 TimSort




原文地址:

http://blog.2baxb.me/archives/993

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章