起因
前些时候线上用户反馈新上线的一个功能出现问题,查询日志发现关键词
Comparison method violates its general contract
上网查了一下
发现有这样一个规则
在 JDK7 版本以上,Comparator 要满足自反性,传递性,对称性:
自反性:当 两个相同的元素相比时,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
再回头看了一下自己项目的代码
Collections.sort(deptPaths,new Comparator<String>(){ @Override public int compare(String o1, String o2) { return o2.split("/").length > o1.split("/").length ? -1 : 1; } });
重点是比较的部分,我写了一个非严格的比较(假设大于0是交换,则当前逻辑大于才交换,小于或者等于不交换),这很明显违反了自反性。知道了问题,立马修改比较函数变成
o1.split("/").length - o2.split("/").length
测试了下没有问题,先解决了线上问题
疑问
问题是解决了,但是仍有几个疑问
1.为什么jdk7排序算法会有这个比较器的限制
2.在测试的时候也有测试相同长度的数据,排序并未报错,这个报错到底是什么操作导致的
探究
1.为什么jdk7排序算法会有这个比较器的限制?
再看一遍日志,很明显最终报错的位置是在TimSort这个类
而TimSort是jdk7以后java的内置排序算法
TimSort是一种稳定排序算法,简单的说是归并排序和插入排序的优化算法,他需要严格单调去保证排序稳定性
对TimSort算法具体流程后续会写文章阐述,此处先解释具体出错的原因,继续打断点调试,扒下源码,找到了最终报错的位置
这是一个归并的操作,进行了块长度的检测,报错的原因实际上需要归并的块长度为0,归并排序中插入的位置不对,无法继续归并
可以看到这个归并排序的限制条件,即第一个块的第一个元素必须大于第二个块的第一个元素,第一个块的最后一个元素必须大于最后一个块的所有元素
* Merges two adjacent runs in place, in a stable fashion. The first * element of the first run must be greater than the first element of the * second run (a[base1] > a[base2]), and the last element of the first run * (a[base1 + len1-1]) must be greater than all elements of the second run.
那为什么要做这个限制呢
再深挖一点,可以找到这样一个函数,在这个归并函数中用作找插入点,也就是timsort 算法中的gallop模式
这是获取插入位置的函数,简单解释下gallop操作,这是timsort算法的一个核心优化点,在gallop操作时会设置一个阈值,用以判断代排列的元素是否基本有序
举个列子在[1,3,5,7,9,11,13,17,33,67]这样一个有序数组中插入37,从左到右需要比较10次,但是如果知道了其是有序的那么就可以用二分插入,插入次数就减少到了4次,而gallop操作就是在连续比较次数超过gallop阈值时且比较结果相同时采取二分的方式查找插入点。
问题也就出在这,为了简化,将gallop阈值设为3,如果是[1,1,1,1,1,1,1,2,2,2,4,4,5,6,6,7,11,88]这样的数组插入2,前三次1比较后,触发gallop操作,经过三次二分比较,插在第8个位置上,但是如果没有gallop操作常规插入,找到最近的大于点插入应该是第11位,很明显gallop的插入点就不满足稳定性了
虽然其实看起来排序结果没问题,实际上在报错出打断点,看a(slot_5)(需要排序的数组),也可以看到数据已经被排序完成,但是元素的顺序已经改变了,对于稳定排序的算法来说实际上就是结果错了
timsort算法需要保证稳定,从而保证“先到先得”,以确保更多使用场景的正确性,故而严格限制了比较函数,所以Java在实现时对归并排序做了限制,不符合归并条件的抛出异常,以确保排序稳定性
2.为什么有些不符合比较规则的排序没问题呢?
因为不会傻傻地一开始就去校验你的输入满不满足规则,只会在比较过程中才会去校验,如果你的 数据量小或者你的输入不是类这种有结构的输入,根本不会走timsort实际上会走插入排序和快排,即使走了timsort,能否触发gallop,触发gallop后插入位置是否不对,这些都满足了,才会在归并排序的过程中进行检查时报这个错。
补充
另外如果不想改比较函数或者比较函数比较难改
-Djava.util.Arrays.useLegacyMergeSort=true
设置jvm参数让其不走timsort也是可以的