TimSort in Java 7
Superpippo 發表於 2012-09-25
1. 為什麼寫這篇文章
這篇文章的根源是在產品中發現了一個詭異的bug:只能在產品環境下重現,在我的本地開發環境無法重現,而雙方的程式碼沒有任何區別。最後用remote debug的方法找到異常所在:
Exception in thread “main” java.lang.IllegalArgumentException: Comparison method violates its general contract!
Google了這個錯誤,是由於Java 7內建的新排序演算法導致的。這才猛然想起產品的編譯環境最近升級到了Java 7。
2. 結論
在Java 6中Arrays.sort()和Collections.sort()使用的是MergeSort,而在Java 7中,內部實現換成了TimSort,其對物件間比較的實現要求更加嚴格:
Comparator的實現必須保證以下幾點(出自這兒):
- a) sgn(compare(x, y)) == -sgn(compare(y, x))
- b) (compare(x, y)>0) && (compare(y, z)>0) 意味著 compare(x, z)>0
- c) compare(x, y)==0 意味著對於任意的z:sgn(compare(x, z))==sgn(compare(y, z))均成立
而我們的程式碼中,某個compare()實現片段是這樣的:
public int compare(ComparatorTest o1, ComparatorTest o2) { return o1.getValue() > o2.getValue() ? 1 : -1; }
這就違背了a)原則:假設X的value為1,Y的value也為1;那麼compare(X, Y) ≠ –compare(Y, X)。 PS: TimSort不僅內建在各種JDK 7的版本,也存在於Android SDK中(儘管其並沒有使用JDK 7)。
3. 解決方案
-
3.1 更改內部實現:例如對於上個例子,就需要更改為
java public int compare(ComparatorTest o1, ComparatorTest o2) { return o1.getValue() == o2.getValue() ? 0 : (o1.getValue() > o2.getValue() ? 1 : -1); }
-
3.2 Java 7預留了一個介面以便於使用者繼續使用Java 6的排序演算法:在啟動引數中(例如eclipse.ini)新增
-Djava.util.Arrays.useLegacyMergeSort=true
-
3.3 將這個IllegalArgumentException手動捕獲住(不推薦)
4. TimSort在Java 7中的實現
那麼為什麼Java 7會將TimSort作為排序的預設實現,甚至在某種程度上犧牲它的相容性(在stackoverflow上有大量的問題是關於這個新異常的)呢?接下來我們不妨來看一看它的實現。
首先建議大家先讀一下這篇文章以簡要理解TimSort的思想。
4.1) 如果傳入的Comparator為空,則使用ComparableTimSort的sort實現。
if (c == null) { Arrays.sort(a, lo, hi); return; }
4.2) 傳入的待排序陣列若小於MIN_MERGE(Java實現中為32,Python實現中為64),則
- a) 從陣列開始處找到一組連線升序或嚴格降序(找到後翻轉)的數
- b) Binary Sort:使用二分查詢的方法將後續的數插入之前的已排序陣列
// If array is small, do a "mini-TimSort" with no merges if (nRemaining < MIN_MERGE) { int initRunLen = countRunAndMakeAscending(a, lo, hi, c); binarySort(a, lo, hi, lo + initRunLen, c); return; }
4.3) 開始真正的TimSort過程:
4.3.1) 選取minRun大小,之後待排序陣列將被分成以minRun大小為區塊的一塊塊子陣列
- a) 如果陣列大小為2的N次冪,則返回16(MIN_MERGE / 2)
- b) 其他情況下,逐位向右位移(即除以2),直到找到介於16和32間的一個數
private static int minRunLength(int n) { assert n >= 0; int r = 0; // Becomes 1 if any 1 bits are shifted off while (n >= MIN_MERGE) { n |= (n & 1); n >>= 1; } return n + r; }
4.3.2) 類似於4.2.a找到初始的一組升序數列 4.3.3) 若這組區塊大小小於minRun,則將後續的數補足(採用binary sort插入這個陣列) 4.3.4) 為後續merge各區塊作準備:記錄當前已排序的各區塊的大小 4.3.5) 對當前的各區塊進行merge,merge會滿足以下原則(假設X,Y,Z為相鄰的三個區塊):
- a) 只對相鄰的區塊merge
- b) 若當前區塊數僅為2,If X<=Y,將X和Y merge
- b) 若當前區塊數>=3,If X<=Y+Z,將X和Y merge,直到同時滿足X>Y+Z和Y>Z
do { // Identify next run int runLen = countRunAndMakeAscending(a, lo, hi, c); // If run is short, extend to min(minRun, nRemaining) if (runLen < minRun) { int force = nRemaining <= minRun ? nRemaining : minRun; binarySort(a, lo, lo + force, lo + runLen, c); runLen = force; } // Push run onto pending-run stack, and maybe merge ts.pushRun(lo, runLen); ts.mergeCollapse(); // Advance to find next run lo += runLen; nRemaining -= runLen; } while (nRemaining != 0);
4.3.6) 重複4.3.2 ~ 4.3.5,直到將待排序陣列排序完 4.3.7) Final Merge:如果此時還有區塊未merge,則合併它們
// Merge all remaining runs to complete sort assert lo == hi; ts.mergeForceCollapse(); assert ts.stackSize == 1;
5. Demo
這一節用一個具體的例子來演示整個演算法的演進過程:
注意:為了演示方便,我將TimSort中的minRun直接設定為2,否則我不能用很小的陣列演示。。。同時把MIN_MERGE也改成2(預設為32),這樣避免直接進入binary sort。
- 初始陣列為[7,5,1,2,6,8,10,12,4,3,9,11,13,15,16,14]
- 尋找連續的降序或升序序列 (4.3.2)
[1,5,7]
[2,6,8,10,12,4,3,9,11,13,15,16,14] 1. 入棧 (4.3.4) 當前的棧區塊為[3] 1. 進入merge迴圈 (4.3.5) do not merge因為棧大小僅為1 1. 尋找連續的降序或升序序列 (4.3.2)
[1,5,7] [2,6,8,10,12]
[4,3,9,11,13,15,16,14] 1. 入棧 (4.3.4) 當前的棧區塊為[3, 5] 1. 進入merge迴圈 (4.3.5) merge因為runLen[0]<=runLen[1] gallopRight:尋找run1的第一個元素應當插入run0中哪個位置(”2”應當插入”1”之後),然後就可以忽略之前run0的元素(都比run1的第一個元素小) gallopLeft:尋找run0的最後一個元素應當插入run1中哪個位置(”7”應當插入”8”之前),然後就可以忽略之後run1的元素(都比run0的最後一個元素大) 這樣需要排序的元素就僅剩下[5,7] [2,6],然後進行mergeLow 完成之後的結果:
[1,2,5,6,7,8,10,12]
[4,3,9,11,13,15,16,14] 1. 入棧 (4.3.4) 當前的棧區塊為[8] 退出當前merge迴圈因為棧中的區塊僅為1 1. 尋找連續的降序或升序序列 (4.3.2)
[1,2,5,6,7,8,10,12] [3,4]
[9,11,13,15,16,14] 1. 入棧 (4.3.4) 當前的棧區塊大小為[8,2] 1. 進入merge迴圈 (4.3.5) do not merge因為runLen[0]>runLen[1] 1. 尋找連續的降序或升序序列 (4.3.2)
[1,2,5,6,7,8,10,12] [3,4] [9,11,13,15,16]
[14] 1. 入棧 (4.3.4) 當前的棧區塊為[8,2,5] 1. do not merege run1與run2因為不滿足runLen[0]<=runLen[1]+runLen[2] merge run2與run3因為runLen[1]<=runLen[2] gallopRight:發現run1和run2就已經排好序 完成之後的結果:
[1,2,5,6,7,8,10,12] [3,4,9,11,13,15,16]
[14] 1. 入棧 (4.3.4) 當前入棧的區塊大小為[8,7] 退出merge迴圈因為runLen[0]>runLen[1] 1. 尋找連續的降序或升序序列 (4.3.2) 最後只剩下[14]這個元素:[1,2,5,6,7,8,10,12] [3,4,9,11,13,15,16] [14] 1. 入棧 (4.3.4) 當前入棧的區塊大小為[8,7,1] 1. 進入merge迴圈 (4.3.5) merge因為runLen[0]<=runLen[1]+runLen[2] 因為runLen[0]>runLen[2],所以將run1和run2先合併。(否則將run0和run1先合併) gallopRight & gallopLeft 這樣需要排序的元素剩下[13,15] [14],然後進行mergeHigh 完成之後的結果:
[1,2,5,6,7,8,10,12] [3,4,9,11,13,14,15,16]
當前入棧的區塊為[8,8] 1. 繼續merge因為runLen[0]<=runLen[1] gallopRight & gallopLeft 需要排序的元素剩下[5,6,7,8,10,12] [3,4,9,11],然後進行mergeHigh 完成之後的結果:
[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]
當前入棧的區塊大小為[16] 1. 不需要final merge因為當前棧大小為1 1. 結束
6. 如何重現文章開始提到的Exception
這一節將剝離複雜的業務邏輯,用一個最簡單的例子(不修改TimSort.java內建的各種引數)重現文章開始提到的Exception。因為儘管google出來的結果中非常多的人提到了這個Exception及解決方案,但並沒有人給出一個可以重現的例子和測試資料。另一方面,我也想從其他角度來加深對這個問題的理解。
構造測試資料的過程是個反人類的過程:( 大家不要學我。。
以下是能重現這個問題的程式碼:
public class ReproJava7Exception { public static void main(String[] args) { int[] sample = new int[] {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-2,1,0,-2,0,0,0,0}; List<Integer> list = new ArrayList<Integer>(); for (int i : sample) list.add(i); // use the native TimSort in JDK 7 Collections.sort(list, new Comparator<Integer>() { @Override public int compare(Integer o1, Integer o2) { // miss the o1 = o2 case on purpose return o1 > o2 ? 1 : -1; } }); } }