一句话总结:
JDK7中的Collections.Sort方法实现中,应用了比较运算的基本属性:若A大于B,则B小于A,若A等于B,则B等于A。所以要求传入compare方法在传入参数交换时,返回值正负也需要交换,或恒为0,否则可能会在排序时抛错。
现象:
昨晚偶然发现XX业务线上接口调用返回服务器内部异常。而调用仿真环境接口返回正常。查看日志发现报错如下:
2015-01-14 22:14:17 291 [WARN] ApiServletApiServlet process error! act:query_join_groups, PARAMS:{}
java.lang.IllegalArgumentException:Comparison method violates its general contract!
at java.util.TimSort.mergeHi(TimSort.java:868)
atjava.util.TimSort.mergeAt(TimSort.java:485)
atjava.util.TimSort.mergeCollapse(TimSort.java:408)
at java.util.TimSort.sort(TimSort.java:214)
atjava.util.TimSort.sort(TimSort.java:173)
at java.util.Arrays.sort(Arrays.java:659)
atjava.util.Collections.sort(Collections.java:217)
atcn.sina.groupchat.processor.QueryJoinGroupsProcessor.process(QueryJoinGroupsProcessor.java:54)
排查过程:
查看出错的业务代码如下
Collections.sort(infos, new Comparator
@Override
public int compare(UserGroupInfo info1, UserGroupInfo info2) {
return info2.joinTime > info1.joinTime ? 1 : -1;
}
});
查看了具体报错的位置,发现只有如下代码:
if (len2== 0) {
throw new IllegalArgumentException("Comparison method violates its generalcontract!");
}
Google了一下出错,发现JDK6和JDK7的sort实现不同,对于JDK7才会有上述问题。解决的办法是compare方法在传入对象相等时必须返回0,但是没有详细描述出错的原因。
查了一下JDK版本1.6到1.7的改动,Collections.Sort方法实现从普通归并排序改成了TimSort排序。不太理解为什么更换排序会导致相同输入报错,并且报错的地方判断逻辑很突兀。于是简单的了解的了一下java timsort的实现,和大家分享一下。
TimSort排序是一种优化的归并排序,对于降序和升降序片段混合的输入有很大的性能提升。OpenJDK关于TimSort的实现如下:
1. 遍历数组,将数组分为若干个升序或降序的片段,反转降序的片段使其变为升序,每个片段成为一个Runtask
2. 将切分好的RunTask压栈
3. 对栈中相邻的RunTask做归并,归并过程相对普通的归并排序做了一定的优化,主要有两步
a) 设做归并的两段分别为A,B,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]值的段作为merge结果的结束部分。
b) 之后进行普通归并,将两段终点标为cursor1和cursor2,倒序归并
这里有一个优化,如果连续N(图中假设为4)次某段的cursor指向的值都大于另一段,则可以预期该段的平均值大于另一段,仿照(a)中的方法,用cursor的值分别对另一段进行切割,提高归并速度。如下图中所示,cursor1指向的值(10,10,11,11)已经连续4次大于cursor2指向的值(8),触发切割逻辑,用cursor2指向的值去切割1段,使cursor1左移,然后用cursor1切割2段,由于cursor1指向值(10)大于2段中所有的值,没有进行实际切割。
最终,将2段的值arraycopy到1段0~3的位置,归并结束。
代码如下:
// 普通归并过程,count记录连续大于的次数,到达一个阈值时,进行二分法切割,提高归并速度
do {
// tmp为B段的复制
if (c.compare(tmp[cursor2], a[cursor1]) < 0) {
a[dest--] = a[cursor1--];
count1++;
count2 = 0;
if (--len1 == 0)
break outer;
} else {
…
}
}while ((count1 | count2) < minGallop);
// 到达阈值后,用预期平均值较小的段的最大值去切割另一段,方法和(a)中类似
do {
count1 = len1 - gallopRight(tmp[cursor2], a, base1, len1, len1 - 1, c);
if (count1 != 0) {
dest -= count1;
cursor1 -= count1;
len1 -= count1;
System.arraycopy(a, cursor1 + 1, a, dest + 1, count1);
if (len1 == 0)
break outer;
}
a[dest--] = tmp[cursor2--];
if (--len2 == 1)
break outer;
// 出问题的地方,gallopLeft是在B段中查找A[Cursor1]的位置,如有相等的情况,取最左的位置,如果B段全部大于A[Cursor1],则返回0
count2 = len2 - gallopLeft(a[cursor1], tmp, 0, len2, len2 - 1, c);
if (count2 != 0) {
…
len2-= count2;
…
}
…
}while (count1 >= MIN_GALLOP | count2 >= MIN_GALLOP);
…
// 最终对len2进行合法检测
if (len2 == 0) {
throw newIllegalArgumentException("Comparison method violates its generalcontract!");
}
关于代码中最后的len2值检测,是因为(a)中切割后,A中所有的值都大于B段的起点B[base2]。在之后的普通归并中,如果出现count2>=minGallop的情况,进行加速归并优化时,按照之前的推论,gallopLeft返回值大于等于1(cursor1必然大于B[base2]),从而推出len2 > 0。
当传入的比较方法返回有问题时,会破坏以上推论,以出现问题的代码为例,当传入两个相等值时,返回-1(交换参数后还是返回-1,违背了之前的要求)。过程如下所示:
1. 归并前的A段和B段
2. 用B段的起点和A段的终点互相切割之后,由于compare方法的问题,A段中的1和2位置的5被保留,破坏了A段中所有值都大于B[base2]的条件
3. 之后是普通的归并过程
----à
4. 之后由于连续的A段大于B段,触发了切割,B段的cursor2将A段切割到cursor1的位置;当用cursor1对B段进行切割时,由于compare方法的问题,gallopLeft会返回0,从而导致len2值等于0,引起报错。
后续
1. 测试环境的JDK版本需要和线上环境保持一致
2. 排查代码,修复此类问题