java.lang.IllegalArgumentException: Comparison method violates its general contract

一、简介

本篇文章讨论ArrayList.sort方法报错:

java.lang.IllegalArgumentException: Comparison method violates its general contract!

的触发条件。

二、背景

在 JDK7 版本以上,Comparator 要满足自反性,传递性,对称性:
1) 自反性:x,y 的比较结果和 y,x 的比较结果相反。
2) 传递性:x>y,y>z,则 x>z。
3) 对称性:x=y,则 x,z 比较结果和 y,z 比较结果相同。

三、意义

Comparator不满足自反性并非触发报错的充分条件
又由于测试环境数据的局限性导致测试无法发现非法的Comparator(从经验上看,很多情况是测试环境不报错,而生产环境报错,使得该报错具有隐匿性)
所以了解

java.lang.IllegalArgumentException: Comparison method violates its
general contract!

异常的触发条件具有排查隐患意义

四、异常复现

大家可以复制下面代码,自己run一下

import java.util.*;

public class TestList {
    public static void main(String[] args) {
    	//随机产生32个[0~3]的数字集合
        Random random = new Random();
        ArrayList<Integer> list = new ArrayList<>();
        //这里为什么是32?
        for (int i = 0; i < 32; i++) {
            list.add(random.nextInt(4));
        }
        System.out.println(list);
        list.sort((o1, o2) -> {
        	//定义一个不具备自反性的Comparator
            if(o1>o2){
                return 1;
            }
            return -1;
        });

        System.out.println(list);
    }
}

多次运行会发现两种情况:

  1. 运行正常:
[3, 3, 3, 1, 1, 2, 0, 2, 3, 2, 1, 2, 2, 3, 2, 2, 2, 2, 0, 3, 2, 0, 3, 0, 3, 3, 2, 3, 0, 0, 0, 0]
[0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
  1. 抛出“Comparison method violates its general contract!”异常:
[4, 3, 2, 4, 0, 3, 3, 2, 4, 1, 1, 0, 4, 1, 3, 1, 0, 1, 2, 3, 2, 1, 4, 1, 1, 3, 2, 3, 2, 0, 3, 0]
Exception in thread "main" java.lang.IllegalArgumentException: Comparison method violates its general contract!
	at java.util.TimSort.mergeHi(TimSort.java:899)
	at java.util.TimSort.mergeAt(TimSort.java:516)
	at java.util.TimSort.mergeCollapse(TimSort.java:441)
	at java.util.TimSort.sort(TimSort.java:245)
	at java.util.Arrays.sort(Arrays.java:1512)
	at java.util.ArrayList.sort(ArrayList.java:1464)
	at TestList.main(TestList.java:15)

为什么会出现这样两种情况,下面尝试进行分析

五、源码分析

首先看一下报错的位置,明确目标:

	private void mergeLo(int base1, int len1, int base2, int len2) {
       ....
        } else if (len1 == 0) {
            throw new IllegalArgumentException(
                "Comparison method violates its general contract!");
        } else {
            assert len2 == 0;
            assert len1 > 1;
            System.arraycopy(tmp, cursor1, a, dest, len1);
        }
    }
	private void mergeHi(int base1, int len1, int base2, int len2) {
		....
		} else if (len2 == 0) {
            throw new IllegalArgumentException(
                "Comparison method violates its general contract!");
        } else {
            assert len1 == 0;
            assert len2 > 0;
            System.arraycopy(tmp, tmpBase, a, dest - (len2 - 1), len2);
        }
	}

明确目标后从头开始,进入ArrayList.sort(Comparator c)方法

	@Override
    @SuppressWarnings("unchecked")
    public void sort(Comparator<? super E> c) {
    	//fail-fast机制相关,确保排序过程中没有元素改变
        final int expectedModCount = modCount;
        //排序方法
        Arrays.sort((E[]) elementData, 0, size, c);
        if (modCount != expectedModCount) {
            throw new ConcurrentModificationException();
        }
        modCount++;
    }

实际调用的是Arrays.sort方法,继续进入:

    public static <T> void sort(T[] a, int fromIndex, int toIndex,
                                Comparator<? super T> c) {
        //如果没定义Comparator就使用T的自然排序
        if (c == null) {
            sort(a, fromIndex, toIndex);
        } else {
            rangeCheck(a.length, fromIndex, toIndex);
            //使用jdk6的排序方法
            if (LegacyMergeSort.userRequested)
                legacyMergeSort(a, fromIndex, toIndex, c);
            else
            //主角在这里,大名鼎鼎的TimSort
                TimSort.sort(a, fromIndex, toIndex, c, null, 0, 0);
        }
    }

其中JVM的启动参数中加入如下参数会使用jdk6的排序方法:

-Djava.util.Arrays.useLegacyMergeSort=true

这里按下不表,进入今天的主角TimSort.sort

static <T> void sort(T[] a, int lo, int hi, Comparator<? super T> c,
                         T[] work, int workBase, int workLen) {
        assert c != null && a != null && lo >= 0 && lo <= hi && hi <= a.length;

        int nRemaining  = hi - lo;
        if (nRemaining < 2)
            return;  // 数组长度是 0 或 1 不需要排序

        // 如果数组小于32, 进行不合并的 "mini-TimSort"
        if (nRemaining < MIN_MERGE) {
        	//跳过从lo开始已经有序的元素,,如果运行是递减的,则反转运行(确保方法返回时运行始终是递增的)。
            int initRunLen = countRunAndMakeAscending(a, lo, hi, c);
            //二分插入排序
            binarySort(a, lo, hi, lo + initRunLen, c);
            return;
        }

        /**
         *从左到右遍历数组一次,查找自然顺序,将短自然顺序扩展到minRun元素,并合并运行以保持堆栈不变
         */
         //创建TimSort实例以维护正在进行的排序的状态。
        TimSort<T> ts = new TimSort<>(a, c, work, workBase, workLen);
        //返回指定长度数组的最小可接受run长度。短于这个长度的run将通过binarySort进行扩展。
        //粗略地说,计算如下:
        //MIN_MERGE=32
        //If n < MIN_MERGE, return n (小到不需要花里胡哨).
        //Else if n 是2的幂, return 16(MIN_MERGE/2).
        //Else 返回一个int k,MIN_MERGE/2 <= k <= MIN_MERGE,使得n/k接近,但严格小于2的幂
        int minRun = minRunLength(nRemaining);
        do {
            // 确定下一个run
            int runLen = countRunAndMakeAscending(a, lo, hi, c);

            // 如果run很小,小于minRun, 就取min(minRun, nRemaining)
            if (runLen < minRun) {
                int force = nRemaining <= minRun ? nRemaining : minRun;
                //二分插入排序
                binarySort(a, lo, lo + force, lo + runLen, c);
                runLen = force;
            }

            // 设置run的信息放到记录下来
            ts.pushRun(lo, runLen);
            //合并run
            ts.mergeCollapse();

            // 推进下一个run
            lo += runLen;
            nRemaining -= runLen;
        } while (nRemaining != 0);

        // 合并所剩下的run
        assert lo == hi;
        ts.mergeForceCollapse();
        assert ts.stackSize == 1;
    }

整体的流程为:

  1. 集合长度小于2直接返回
  2. 集合长度小于32则进行“mini-TimSort”,核心是二分插入排序,最后返回。
  3. 数组大于32时, 先算出一个合适的大小,再将输入按其升序和降序特点进行了分区。排序的输入的单位不是一个个单独的数字,而是一个个的块-分区。其中每一个分区叫一个run。针对这些 run 序列,每次拿相邻两个run出来按规则进行合并。每次合并会将这两个run合并成一个 run。合并的结果保存到栈中。合并直到消耗掉所有的run,这时将栈上剩余的 run合并到只剩一个 run 为止。这时这个仅剩的 run 便是排好序的结果。

由于“mini-TimSort”不需要合并run,因此没有调用到mergeLo或mergeHi,自然也就不会报错。这也是复现测试代码中集合长度为32的原因,也是测试环境很容易忽略非法Comparator的原因。

集合长度大于32也不一定会触发异常,所以继续进入归并逻辑,245:ts.mergeCollapse();

private void mergeCollapse() {
        while (stackSize > 1) {
            int n = stackSize - 2;
            if (n > 0 && runLen[n-1] <= runLen[n] + runLen[n+1]) {
                if (runLen[n - 1] < runLen[n + 1])
                    n--;
                mergeAt(n);
            } else if (runLen[n] <= runLen[n + 1]) {
                mergeAt(n);
            } else {
                break; // 符合规则
            }
        }
    }

合并相邻两个run

    private void mergeAt(int i) {
        assert stackSize >= 2;
        assert i >= 0;
        assert i == stackSize - 2 || i == stackSize - 3;

        int base1 = runBase[i];
        int len1 = runLen[i];
        int base2 = runBase[i + 1];
        int len2 = runLen[i + 1];
        assert len1 > 0 && len2 > 0;
        assert base1 + len1 == base2;

        /*
        * 记录合并后槽信息,run长度等
        */
        runLen[i] = len1 + len2;
        if (i == stackSize - 3) {
            runBase[i + 1] = runBase[i + 2];
            runLen[i + 1] = runLen[i + 2];
        }
        stackSize--;

        /*
         * 先用 gallopRight在 run1 里使用二分查找 查找run2 首元素 的位置k, 那么 run1 中k 前面的元素就是合并后最小的那些元素
         */
        int k = gallopRight(a[base2], a, base1, len1, 0, c);
        assert k >= 0;
        base1 += k;
        len1 -= k;
        if (len1 == 0)
            return;

        /*
         * 在run2 中查找run1 尾元素 的位置len2 ,那么run2 中 len2 后面的那些元素就是合并后最大的那些元素
         */
        len2 = gallopLeft(a[base1 + len1 - 1], a, base2, len2, len2 - 1, c);
        assert len2 >= 0;
        if (len2 == 0)
            return;

        // 合并剩余的元素,mergeLo指从低向高合并,mergeHi指从高向低合并,因为合并时需要缓存一个run,所及当len1小于等于len2时调用mergeLo
        if (len1 <= len2)
            mergeLo(base1, len1, base2, len2);
        else
            mergeHi(base1, len1, base2, len2);
    }

mergeLo和mergeHi合并方向相反,其他逻辑相同。我们看一下mergeHi:

private void mergeHi(int base1, int len1, int base2, int len2) {
        assert len1 > 0 && len2 > 0 && base1 + len1 == base2;

        // 将第二个run放到缓存
        T[] a = this.a; // For performance
        T[] tmp = ensureCapacity(len2);
        int tmpBase = this.tmpBase;
        System.arraycopy(a, base2, tmp, tmpBase, len2);

        int cursor1 = base1 + len1 - 1;  // run1中最后一个未合并元素在a位置
        int cursor2 = tmpBase + len2 - 1; // run2中最后一个未合并元素在tmp位置
        int dest = base2 + len2 - 1;     // a中已合并的位置,这里是从后往前

        // 先把run1中最后一个元素a[cursor1]放到a[dest]中,因为进入mergeHi之前计算过run1最后一个元素在run2的位置
        a[dest--] = a[cursor1--];
        if (--len1 == 0) {
            System.arraycopy(tmp, tmpBase, a, dest - (len2 - 1), len2);
            return;
        }
        //mergeAt中gallopRight查过run2首元素在run1中的位置
        //所以len2==1代表只剩run2首元素(也就是这个合并中最小的元素)
        if (len2 == 1) {
            dest -= len1;
            cursor1 -= len1;
            //把run1剩下的放到a中
            System.arraycopy(a, cursor1 + 1, a, dest + 1, len1);
            //run2首元素放到a中,结束
            a[dest] = tmp[cursor2];
            return;
        }

        Comparator<? super T> c = this.c;  // Use local variable for performance
        int minGallop = this.minGallop;    // 进入“飞奔模式”阈值,会随着“飞奔”的次数而递减
    outer:
        while (true) {
            int count1 = 0; // run1连续获胜的次数,跟“飞奔模式”有关
            int count2 = 0; // run2连续获胜的次数

            /*
             * 从后往前做直接比较,比较run1和temp(run2),dest是标尺,整体排序位置的标志,直到run1或temp获胜7次
             */
            do {
                assert len1 > 0 && len2 > 1;
                if (c.compare(tmp[cursor2], a[cursor1]) < 0) {
                    a[dest--] = a[cursor1--];
                    count1++;
                    count2 = 0;
                    if (--len1 == 0)
                        break outer;
                } else {
                    a[dest--] = tmp[cursor2--];
                    count2++;
                    count1 = 0;
                    if (--len2 == 1)
                        break outer;
                }
            } while ((count1 | count2) < minGallop);

            /*
             * 某个run获胜达到阈值后进入“飞奔”
             */
            do {
                assert len1 > 0 && len2 > 1;
                //计算run2最高的待合并元素在run1里的位置,count1既为run1获胜的个数
                count1 = len1 - gallopRight(tmp[cursor2], a, base1, len1, len1 - 1, c);
                if (count1 != 0) {
                    dest -= count1;
                    cursor1 -= count1;
                    len1 -= count1;
                    //将run1中获胜元素放到放到a中
                    System.arraycopy(a, cursor1 + 1, a, dest + 1, count1);
                    if (len1 == 0)
                        break outer;
                }
                a[dest--] = tmp[cursor2--];
                if (--len2 == 1)
                    break outer;
				//计算run1最大待合并元素在run2里的位置,count2既为run2获胜的个数
                count2 = len2 - gallopLeft(a[cursor1], tmp, tmpBase, len2, len2 - 1, c);
                if (count2 != 0) {
                    dest -= count2;
                    cursor2 -= count2;
                    len2 -= count2;
                    System.arraycopy(tmp, cursor2 + 1, a, dest + 1, count2);
                    if (len2 <= 1)  // len2 == 1 || len2 == 0
                        break outer;
                }
                a[dest--] = a[cursor1--];
                if (--len1 == 0)
                    break outer;
                minGallop--;
            } while (count1 >= MIN_GALLOP | count2 >= MIN_GALLOP);
            if (minGallop < 0)
                minGallop = 0;
            minGallop += 2;  // Penalize for leaving gallop mode
        }  // End of "outer" loop
        this.minGallop = minGallop < 1 ? 1 : minGallop;  // Write back to field

        if (len2 == 1) {
        //正常情况run2最小元素再这里最后合并至a
            assert len1 > 0;
            dest -= len1;
            cursor1 -= len1;
            System.arraycopy(a, cursor1 + 1, a, dest + 1, len1);
            a[dest] = tmp[cursor2];  // Move first elt of run2 to front of merge
        } else if (len2 == 0) {
        //非法Comparator导致len2为0,抛出异常
            throw new IllegalArgumentException(
                "Comparison method violates its general contract!");
        } else {
            assert len1 == 0;
            assert len2 > 0;
            System.arraycopy(tmp, tmpBase, a, dest - (len2 - 1), len2);
        }
    }

不满足可逆性的Comparator在运行872行,如果此时碰巧a[cursor1]为run1最小元素

count2 = len2 - gallopLeft(a[cursor1], tmp, tmpBase, len2, len2 - 1, c);

由于gallopLeft内部使用与之前顺序相反的比较

之前compare(run2,run1)
gallopLeft里相反compare(run1,run2)

会出现a[cursor1]比tmp[0]还要小的情况,于是count2=len2-0,最终会将tmp所有元素放到a中,len2会被减为0,这违背了run2[0]是最小元素的预期,最终报错。

结论

  1. 如果元素种类足够多(或者说足够随机,可以在复现代码中尝试list.add(random.nextInt(100))),使得合并一直保持在“直接合并”阶段,则不会暴露非可逆的Comparator
  2. 或者进入了“飞奔模式”,但tmp的最小元素率先在859行的gallopRight中进行“飞奔”,使run1剩下的元素全部放入a,跳出合并,也不会暴露非可逆的Comparator

你可能感兴趣的:(源码,java,算法,排序算法)