目录
一、问题重现
@Data
public class BusinessApplyDto implements Comparable<BusinessApplyDto>, Serializable {
private static final long serialVersionUID = 7697030805482359129L;
private String period;
private Long businessId;
@Override
public int compareTo(BusinessApplyDto target) {
String currentNo = this.period;
String targetNo = target.getPeriod();
if (currentNo == null) {
return -1;
} else if (targetNo == null) {
return 1;
}
int sort = currentNo.compareTo(targetNo);
if (sort == 0) {
if (this.businessId == null) {
return 1;
} else if (target.businessId == null) {
return -1;
}
return this.businessId.compareTo(target.businessId);
}
return -sort;
}
}
先仔细看下上面的代码,BusinessApplyDto实现了一个Comparable,然后重写了compareTo方法,compareTo方法通过比较两个字段的值来确定两个BusinessApplyDto的大小。然而compareTo方法过于复杂,初看代码不容易发现有bug,甚至上线后运行一段时间也不会有问题,因为如果没有特定的数据触发bug,线上一直都能正常运行。但是如果用下面数据进行运行,则会发生异常。
public class MainTest {
public static void main(String[] args) {
List<BusinessApplyDto> list = new ArrayList<>();
BusinessApplyDto businessApplyDto1 = new BusinessApplyDto();
businessApplyDto1.setPeriod("20210927");
businessApplyDto1.setBusinessId(5L);
list.add(businessApplyDto1);
BusinessApplyDto businessApplyDto2 = new BusinessApplyDto();
businessApplyDto2.setPeriod("20210927");
list.add(businessApplyDto2);
BusinessApplyDto businessApplyDto3 = new BusinessApplyDto();
businessApplyDto3.setPeriod("20210927");
list.add(businessApplyDto3);
BusinessApplyDto businessApplyDto4 = new BusinessApplyDto();
businessApplyDto4.setPeriod("20210927");
list.add(businessApplyDto4);
BusinessApplyDto businessApplyDto5 = new BusinessApplyDto();
businessApplyDto5.setPeriod("20210927");
list.add(businessApplyDto5);
BusinessApplyDto businessApplyDto6 = new BusinessApplyDto();
businessApplyDto6.setPeriod("20210927");
list.add(businessApplyDto6);
BusinessApplyDto businessApplyDto7 = new BusinessApplyDto();
businessApplyDto7.setPeriod("20210927");
list.add(businessApplyDto7);
BusinessApplyDto businessApplyDto8 = new BusinessApplyDto();
businessApplyDto8.setPeriod("20210927");
businessApplyDto8.setBusinessId(2L);
list.add(businessApplyDto8);
BusinessApplyDto businessApplyDto9 = new BusinessApplyDto();
businessApplyDto9.setPeriod("20210927");
list.add(businessApplyDto9);
BusinessApplyDto businessApplyDto10 = new BusinessApplyDto();
businessApplyDto10.setPeriod("20210927");
list.add(businessApplyDto10);
BusinessApplyDto businessApplyDto11 = new BusinessApplyDto();
businessApplyDto11.setPeriod("20210927");
list.add(businessApplyDto11);
BusinessApplyDto businessApplyDto12 = new BusinessApplyDto();
businessApplyDto12.setPeriod("20210927");
list.add(businessApplyDto12);
BusinessApplyDto businessApplyDto13 = new BusinessApplyDto();
businessApplyDto13.setPeriod("20210927");
list.add(businessApplyDto13);
BusinessApplyDto businessApplyDto14 = new BusinessApplyDto();
businessApplyDto14.setPeriod("20210927");
list.add(businessApplyDto14);
BusinessApplyDto businessApplyDto15 = new BusinessApplyDto();
businessApplyDto15.setPeriod("20210927");
list.add(businessApplyDto15);
BusinessApplyDto businessApplyDto16 = new BusinessApplyDto();
businessApplyDto16.setPeriod("20210927");
list.add(businessApplyDto16);
BusinessApplyDto businessApplyDto17 = new BusinessApplyDto();
businessApplyDto17.setPeriod("20210927");
list.add(businessApplyDto17);
BusinessApplyDto businessApplyDto18 = new BusinessApplyDto();
businessApplyDto18.setPeriod("20210927");
list.add(businessApplyDto18);
BusinessApplyDto businessApplyDto19 = new BusinessApplyDto();
businessApplyDto19.setPeriod("20210927");
list.add(businessApplyDto19);
BusinessApplyDto businessApplyDto20 = new BusinessApplyDto();
businessApplyDto20.setPeriod("20210927");
list.add(businessApplyDto20);
BusinessApplyDto businessApplyDto21 = new BusinessApplyDto();
businessApplyDto21.setPeriod("20210927");
list.add(businessApplyDto21);
BusinessApplyDto businessApplyDto22 = new BusinessApplyDto();
businessApplyDto22.setPeriod("20210927");
list.add(businessApplyDto22);
BusinessApplyDto businessApplyDto23 = new BusinessApplyDto();
businessApplyDto23.setPeriod("20210927");
list.add(businessApplyDto23);
BusinessApplyDto businessApplyDto24 = new BusinessApplyDto();
businessApplyDto24.setPeriod("20210927");
list.add(businessApplyDto24);
BusinessApplyDto businessApplyDto25 = new BusinessApplyDto();
businessApplyDto25.setPeriod("20210927");
list.add(businessApplyDto25);
BusinessApplyDto businessApplyDto26 = new BusinessApplyDto();
businessApplyDto26.setPeriod("20210927");
list.add(businessApplyDto26);
BusinessApplyDto businessApplyDto27 = new BusinessApplyDto();
businessApplyDto27.setPeriod("20210927");
list.add(businessApplyDto27);
BusinessApplyDto businessApplyDto28 = new BusinessApplyDto();
businessApplyDto28.setPeriod("20210927");
list.add(businessApplyDto28);
BusinessApplyDto businessApplyDto29 = new BusinessApplyDto();
businessApplyDto29.setPeriod("20210927");
list.add(businessApplyDto29);
BusinessApplyDto businessApplyDto30 = new BusinessApplyDto();
businessApplyDto30.setPeriod("20210927");
list.add(businessApplyDto30);
BusinessApplyDto businessApplyDto31 = new BusinessApplyDto();
businessApplyDto31.setPeriod("20210927");
businessApplyDto31.setBusinessId(6L);
list.add(businessApplyDto31);
BusinessApplyDto businessApplyDto32 = new BusinessApplyDto();
businessApplyDto32.setPeriod("20210927");
list.add(businessApplyDto32);
Collections.sort(list);
}
}
如果执行上述main方法,会报下面的异常:
Exception in thread "main" java.lang.IllegalArgumentException: Comparison method violates its general contract!
at java.util.ComparableTimSort.mergeLo(ComparableTimSort.java:744)
at java.util.ComparableTimSort.mergeAt(ComparableTimSort.java:481)
at java.util.ComparableTimSort.mergeCollapse(ComparableTimSort.java:406)
at java.util.ComparableTimSort.sort(ComparableTimSort.java:213)
at java.util.Arrays.sort(Arrays.java:1312)
at java.util.Arrays.sort(Arrays.java:1506)
at java.util.ArrayList.sort(ArrayList.java:1464)
at java.util.Collections.sort(Collections.java:143)
at MainTest.main(MainTest.java:148)
上述的MainTest数据中,如果businessId都是null不会报错,只有特殊数据才会触发这个错误。至于为何触发这个错误,比较直观的原因是BusinessApplyDto中的compareTo违反了sort的几个原则:
- 自反性:x,y 的比较结果和 y,x 的比较结果相反。
- 传递性:x>y,y>z,则 x>z。
- 对称性:x=y,则 x,z 比较结果和 y,z 比较结果相同。
下文深入sort源码,分析出现上述错误的根本原因。
二、Arrays.sort源码分析
查看Collections.sort可以知道底层是调用了Arrays.sort方法,Arrays.sort的代码如下所示:
public static <T> void sort(T[] a, Comparator<? super T> c) {
if (c == null) {
sort(a);
} else {
if (LegacyMergeSort.userRequested)
legacyMergeSort(a, c);
else
TimSort.sort(a, 0, a.length, c, null, 0, 0);
}
}
通过上述代码可以发现,如果c为null则会调用sort(Object[] a)方法,否则会根据LegacyMergeSort.userRequested是否为true调用legacyMergeSort方法还是TimSort.sort方法。而LegacyMergeSort.userRequested可以通过在JVM参数中添加-Djava.util.Arrays.useLegacyMergeSort=true/false来指定。sort(Object[] a)代码如下所示:
public static void sort(Object[] a) {
if (LegacyMergeSort.userRequested)
legacyMergeSort(a);
else
ComparableTimSort.sort(a, 0, a.length, null, 0, 0);
}
可见sort(Object[] a)和sort(T[] a, Comparator<? super T> c)方法大致逻辑相同,sort(Object[] a)底层调用了ComparableTimSort.sort,而sort(T[] a, Comparator<? super T> c)调用了TimSort.sort。其实ComparableTimSort和TimSort都是调用TimSort算法,只是ComparableTimSort通过Comparable比较,而TimSort是通过Comparator比较而已。legacyMergeSort方法上注释了/** To be removed in a future release. */,说明会在未来版本中去掉。接下来就不贴详细的代码了,整理了一张Arrays.sort的流程图。

从上述的流程图可以感受到Arrays.sort底层实现是比较复杂的。通过流程图可以发现legacyMergeSort的底层逻辑会判断数组,如果数组大小小于7,则使用插入排序对数组进行排序,否则使用归并排序。ComparableTimSort和TimSort逻辑一致,都是使用TimSort实现的。而最开始报的Comparison method violates its general contract!错误也是在TimSort里抛出来的,因此下面主要分析下TimSort。
三、TimSort
1、TimSort简介
TimSort由Tim Peter于2002年发明的,是一个自适应的、混合的、稳定的排序算法,融合了归并算法和二分插入排序算法的精髓,在现实世界的数据中有着特别优秀的表现,因此被用作Java和Python的内置排序算法。这个算法之所以快,是因为它充分利用了现实世界的待排序数据里面,有很多子串是已经排好序的不需要再重新排序,利用这个特性并且加上合适的合并规则可以更加高效的排序剩下的待排序序列。
2、TimSort原理
TimSort结合了二分插入排序和归并排序。在数组长度小于一定阈值时使用二分插入排序,否则计算minRun再进行二分插入排序+归并排序。这个阈值在TimSort最初实现的时候是64,后来改成32。之所以改成32是因为这个阈值能得到更好的性能。
1)、二分插入排序
二分插入排序先通过二分搜索,直接找到这个元素应该插入的位置,就可以减少很多次的比较。虽然仍然是需要移动相同数量的元素,但是复制数组的时间消耗要小于元素间的一一互换。例如数组[3,7,12,32,50,1],如果想把1插入到数组最前面,通过直接插入排序需要经过5次比较,但是使用二分插入排序,只需要2次比较就能找到插入的位置,然后使用System.arraycopy对数组进行移动。
2)、minRunLength
minRunLength方法的主要作用就是确认run的最小值,确定minRun值后,待排序数组将被分成以minRun大小为区块的一块块子数组。minRunLength的代码如下所示:
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) {
r |= (n & 1);
n >>= 1;
}
return n + r;
}
从代码中可以看出minRunLength的大小在0到32之间。n<100的时候n和minRunLength(n)的对应数据如下:
n minRunLength(n)
1 1
2 2
3 3
4 4
5 5
6 6
7 7
8 8
9 9
10 10
11 11
12 12
13 13
14 14
15 15
16 16
17 17
18 18
19 19
20 20
21 21
22 22
23 23
24 24
25 25
26 26
27 27
28 28
29 29
30 30
31 31
32 16
33 17
34 17
35 18
36 18
37 19
38 19
39 20
40 20
41 21
42 21
43 22
44 22
45 23
46 23
47 24
48 24
49 25
50 25
51 26
52 26
53 27
54 27
55 28
56 28
57 29
58 29
59 30
60 30
61 31
62 31
63 32
64 16
65 17
66 17
67 17
68 17
69 18
70 18
71 18
72 18
73 19
74 19
75 19
76 19
77 20
78 20
79 20
80 20
81 21
82 21
83 21
84 21
85 22
86 22
87 22
88 22
89 23
90 23
91 23
92 23
93 24
94 24
95 24
96 24
97 25
98 25
99 25
3)、do-while循环
计算出minRunLength之后,进入一个do-while循环,此循环主要的作用就是通过run和栈实现区块的排序。do-while里主要有这么几个步骤:
- countRunAndMakeAscending找到一个run,这个run必须是有序的,如果是降序的,则会对其进行升序操作。
- 步骤a确定的区块大小如果小于minRunLength值,则调用binarySort对run进行扩展。
- 调用ts.pushRun(lo, runLen)将当前run维护到栈中,为后面的mergeCollapse做准备。
- 调用ts.mergeCollapse()对每个区块进行归并。mergeCollapse会对不同的run区块进行merge,假设有三个连续的区块A、B、C。会遵从下述规则进行归并:
- 只会将A和B或者B和C进行合并,不会将A和C进行合并。
- 如果当前只有两个区块,则如果A<B,则将A和B进行归并。
- 如果当前有三个区块,如果A<=B+C,将A和B进行合并,直到满足A>B+C并且B>Z。
- 重复执行步骤a到步骤d,直到待排序数组全部排序完毕。
- 最后调用mergeForceCollapse归并所有runs,直到只有一个run为止。
具体数据分析:
为了便于演示,将MIN_MERGE设置成2(源代码为32);将minRun设置为2。
假设初始数组为[9,6,3,4,5,12,14,15,7,11,1,8,13,16,18,19]
1、执行步骤a,调用countRunAndMakeAscending,计算出runLen的值。如果有连续降序的则进行升序操作。第一步操作结束后,数据变成[3,6,9][4,5,12,14,15,7,11,1,8,13,16,18,19]。
2、执行步骤c,将当前run维护到栈中,当前栈区块为[3]。
3、执行步骤d,调用ts.mergeCollapse(),但是由于stackSize=1,因此无需进入mergeCollapse的while循环。
4、继续执行步骤a,调用countRunAndMakeAscending。数据变成[3,6,9][4,5,12,14,15][7,11,1,8,13,16,18,19]。
5、执行步骤c,栈的数据为[3,5]。
6、执行步骤d,因为runLen[0]=3,runLen[1]=5,所以runLen[0]<=runLen[1]。于是进入mergeAt方法。
6.1、调用gallopRight计算run1的第一个元素在run0中的位置,既run1中的4应该插在run0中的3后面。
6.2、调用gallopLeft计算run0的最后一个元素在run1中的位置,9应该插在12之前,然后忽略run1此位置之后的所有元素,因为都比run0中的元素大。
6.3、这样剩下的需要排序的元素就是[6,9][4,5]。然后调用mergeLo。完成之后的数据为:[3,4,5,6,9,12,14,15][7,11,1,8,13,16,18,19]。
7、执行步骤a,寻找连续升序或降序数据,结果数据变成[3,4,5,6,9,12,14,15][7,11][1,8,13,16,18,19]。
8、执行步骤c,当前栈区块为[8,2]。
9、执行步骤d,调用ts.mergeCollapse(),因为runLen[0]=8,runLen[1]=2,runLen[0]>runLen[1],因此不进入while循环。
10、执行步骤a,寻找连续升序或降序数据,结果为[3,4,5,6,9,12,14,15][7,11][1,8,13,16,18][19]。
11、执行步骤c,当前栈区块为[8,2,5]。
12、执行步骤d,runLen[0]=8,runLen[1]=2,runLen[2]=5,因为runLen[0]>runLen[1]+runLen[2],但是runLen[1]<=runLen[2],因此归并run0和run2。结果数据为[3,4,5,6,9,12,14,15][1,7,8,11,13,16,18][19]。
13、执行步骤c,当前栈区块为[8,7]。
14、执行步骤d,调用ts.mergeCollapse()。runLen[0]=8,runLen[1]=7,runLen[0]>runLen[1],因此不进入while循环。
15、执行步骤a、寻找连续升序或降序数据,结果为[3,4,5,6,9,12,14,15][1,7,8,11,13,16,18][19]。
16、执行步骤c,当前区块为[8,7,1]。
17、执行步骤d,调用ts.mergeCollapse(),runLen[0]=8,runLen[1]=7,runLen[2]=1,因为runLen[0]<=runLen[1]+runLen[2]并且runLen[0]>runLen[2],因此先将run1和run2进行合并。
17.1、调用gallopRight计算run2的第一个元素在run1中的位置,既run2中的19应该插在run1中的18后面。
17.2、调用gallopLeft计算run1的最后一个元素在run2中的位置,18应该插在19前面。
17.3、剩下的元素为[19]。然后调用mergeHi。完成之后的数据为:[3,4,5,6,9,12,14,15][1,7,8,11,13,16,18,19]。当前入栈的区块为[8,8]。
18、继续while循环,因为runLen[0]=8,runLen[1]=8,runLen[0]<=runLen[1]。
18.1、调用gallopRight,run1的1插在run0的3前面。
18.2、调用gallopLeft,run0的15应该插在13前面。
18.3、剩下待排序数组为[3,4,5,6,9,12,14,15][1,7,8,11,13]。然后调用mergeHi,结果数据为[1,3,4,5,6,7,8,9,11,12,13,14,15,16,18,19]。当前入栈区块大小为[16]。
19、最后调用mergeForceCollapse。由于stackSize=1,因此不要进入while循环,结束整个sort操作。
3、TimSort异常案例分析
本文最开始MainTest中的那些数据中,其实period都是相等的,因此数据可以只看businessId字段,因此上面代码中可以抽象成数组:[5,null,null,null,null,null,null,2, null,null,null,null,null,null,null,null, null,null,null,null,null,null,null,null, null,null,null,null,null,null,6,null]。归并一次后的数据结果,将数组分成两组:[2,5,null,null,null,null,null,null, null,null,null,null,null,null,null,null][6,null,null,null,null,null,null,null, null,null,null,null,null,null,null,null]。其中runLen[0]=16,runLen[1]=16。在调用mergeCollapse后,由于runLen[n] <= runLen[n + 1],因此会进入mergeAt(n)方法。
在进入mergeAt方法后,调用gallopRight后确定的值为2,因为run1中的6比run0中的5打,因此插入的索引值是2。而调用gallopLeft后确定的索引值为16。然后进入mergeLo方法,将[null,null,null,null,null,null, null,null,null,null,null,null,null,null]和[6,null,null,null,null,null,null,null, null,null,null,null,null,null,null,null]进行合并。合并后的结果是[2,5,6,null,null,null,null,null,null,null, null,null,null,null,null,null,null,null, null,null,null,null,null,null, null,null,null,null,null,null,null,null]。
当继续执行时,发现len1的值最终减到0,于是进入下图逻辑中,于是就抛出了Comparison method violates its general contract!异常。
通过分析可以知道,抛出这个异常的根本原因就是BusinessApplyDto的compareTo方法中违法了自反原则导致的。上述数组中的null代表businessId为null,因此在BusinessApplyDto中会返回1(见下面代码片段),代表升序。也就是说businessApplyDto2>=businessApplyDto3,但是在归并过程中又是businessApplyDto2<businessApplyDto3。违反了自反原则,所以最终导致抛出异常。
if (this.businessId == null) {
return 1;
}
找出问题原因后,就是需要修复bug,修复后的代码如下所示:
@Data
public class BusinessApplyDto implements Comparable<BusinessApplyDto>, Serializable {
private static final long serialVersionUID = 7697030805482359129L;
private String period;
private Long businessId;
@Override
public int compareTo(BusinessApplyDto target) {
String currentNo = this.period;
String targetNo = target.getPeriod();
if (currentNo == null) {
return -1;
} else if (targetNo == null) {
return 1;
}
int sort = currentNo.compareTo(targetNo);
if (sort == 0) {
if (this.businessId == null
&& target.businessId == null) {
return 0;
}
if (this.businessId == null) {
return 1;
} else if (target.businessId == null) {
return -1;
}
return this.businessId.compareTo(target.businessId);
}
return -sort;
}
}
四、总结
本文最开始抛砖引玉展示出现错误的现象,然后深入源码进行研究,揭开TimSort的实现原理及其中隐藏的比较器准则:自反性、传递性、对称性,最后解决上述的bug。通过本文可以发现TimSort中比较容易遇到的坑,在实现Comparable的时候,一定要确保返回结果包含-1,0,1三种结果,否则就有可能违反TimSort准则。
五、参考
世界上最快的排序算法——Timsort - 佛西先森 - 博客园
java.util.ComparableTimSort中的sort()方法简单分析_生命在于折腾-优快云博客
2212



被折叠的 条评论
为什么被折叠?



