记一个 IllegalArgumentException

记一个诡异的 IllegalArgumentException: Comparison method violates its general contract!

问题说明

完整的异常日志如下:

1
2
3
4
5
6
7
8
9
10
11
12
09-25 14:17:14.253 7992-8182/com.tencent.ibg.joox E/AndroidRuntime: FATAL EXCEPTION: BitmapProfilerRefQueue
Process: com.tencent.ibg.joox, PID: 7992
java.lang.IllegalArgumentException: Comparison method violates its general contract!
at java.util.TimSort.mergeHi(TimSort.java:882)
at java.util.TimSort.mergeAt(TimSort.java:499)
at java.util.TimSort.mergeForceCollapse(TimSort.java:440)
at java.util.TimSort.sort(TimSort.java:219)
at java.util.TimSort.sort(TimSort.java:169)
at java.util.Arrays.sort(Arrays.java:2010)
at kotlin.collections.ArraysKt___ArraysJvmKt.sortWith(_ArraysJvm.kt:1862)
at kotlin.collections.CollectionsKt___CollectionsKt.sortedWith(_Collections.kt:947)
at com.tencent.tip.bitmapprofiler.extension.BitmapTraceCollector.sortBySize(BitmapTraceCollector.kt:227)

BitmapTraceCollector.sortBySize() 的源码如下:

1
2
3
4
fun sortBySize(): List<BitmapTrace> = ArrayList<BitmapTrace>(traces)
.filter { it.isBitmapAlive() }
.sortedBy { it.currentBitmapSize() }
.reversed()

BitmapTrace 类定义如下:

1
2
3
4
5
6
7
8
9
10
11
class BitmapTrace constructor(
bitmap: Bitmap,
queue: ReferenceQueue<Bitmap>?,
val threadName: String,
private val exception: Exception,
val createdTime: Long,
var destroyedTime: Long
) : WeakReference<Bitmap>(bitmap, queue) {
fun isBitmapAlive() = get() != null
fun currentBitmapSize(): Int = get()?.allocationByteCount ?: 0
}

写个测试来看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class ContractTest {

@Test
fun testContract() {
while (true) {
runTest()
}
}

private fun runTest() {
val n = 500000
val rand = Random()
val list = ArrayList<MockBitmapTrace>()
for (i in 0..n) {
list.add(MockBitmapTrace(Bmp(rand.nextInt())))
}
list.filter { it.isBitmapAlive() }
.sortedBy { it.currentBitmapSize() }
.reversed()
}

}

private class MockBitmapTrace(referent: Bmp) : WeakReference<Bmp>(referent) {

private val allocationByteCount: Int? = null

fun isBitmapAlive() = get() != null
fun currentBitmapSize(): Int = get()?.allocationByteCount ?: 0

}

data class Bmp(var allocationByteCount: Int)

这个测试在一个死循环中跑 runTest(),不过 10s 就出现 Comparison method violates its general contract

-w1035

问题原因

其实 Java 官方文档对这种行为有描述,见

描述如下:

Area: API: Utilities
Synopsis: Updated sort behavior for Arrays and Collections may throw an IllegalArgumentException
Description: The sorting algorithm used by java.util.Arrays.sort and (indirectly) by java.util.Collections.sort has been replaced. The new sort implementation may throw an IllegalArgumentException if it detects a Comparable that violates the Comparable contract. The previous implementation silently ignored such a situation.
If the previous behavior is desired, you can use the new system property, java.util.Arrays.useLegacyMergeSort, to restore previous mergesort behavior.
Nature of Incompatibility: behavioral

简单来说,java.util.Arrays.sort()java.util.Collections.sort() 两个排序方法底层使用的算法有更新。使用这两个方法对数组或集合排序时,如果检查到 Comparable 没有遵守 Comparable contract 时会抛出 IllegalArgumentException。而以前版本中会忽略该异常。可以设置 java.util.Arrays.useLegacyMergeSort 系统属性来切换到以前的不会抛出异常的 mergesort

解决方法

对于标准的 JVM,可以使用以下方法

1
java -Djava.util.Arrays.useLegacyMergeSort=true

对于 Android,上述方法不可行。Android SDK java.util.Arrays.sort() 方法源码如下:

1
2
3
4
5
6
7
public static void sort(Object[] a) {
// Android-changed: LegacyMergeSort is no longer supported
// if (LegacyMergeSort.userRequested)
// legacyMergeSort(a);
// else
ComparableTimSort.sort(a, 0, a.length, null, 0, 0);
}

直接不支持 LegacyMergeSort !!

所以只能老老实实分析出问题的代码为什么没有正确的实现 Comparable contract。其实很容易缩小问题范围,是使用 BitmapTrace.currentBimapSize() 的返回值对 BitmapTrace 列表来排序的:

1
sortedBy { it.currentBitmapSize() }

BitmapTrace.currentBimapSize() 方法会不会有问题?

1
fun currentBitmapSize(): Int = get()?.allocationByteCount ?: 0

不难理解,它的返回值是不稳定的。当前对象是个 WeakReference,这个对象未被回收时 currentBitmapSize() 返回 Bitmap 内存大小,这个对象被回收时返回 0。所以如果排序持续过程足够长,其中发生了 GC,很可能以下描述的这种局面。参考来源

一开始时有三个对象 A, B, C,大小分别如下:

1
2
3
A: 100
B: 50
C: 20

这时 A > B, B > C。所以可以断定 A > C 必然成立。由于 GC 导致 A 对象被回收,所以 A, B, C 三个对象大小恰好变成了这样:

1
2
3
A: 0
B: 50
C: 20

这里 A > C 不成立了。JVM 一旦检测到这一点,就立即抛出 java.lang.IllegalArgumentException: Comparison method violates its general contract!。(要不要这么严肃???)

所以解决办法是保证 currentBitmapSize() 返回值是稳定的、不受 GC 影响的。

测试代码修改如下,使用 oldBitmapSize() 代替原来的 currentBitmapSize()。这个测试跑一下午也没有出现 Comparison method violates its general contract! 异常,问题完美修复!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class ContractTest {

@Test
fun testContract() {
while (true) {
runTest()
}
}

private fun runTest() {
val n = 500000
val rand = Random()
val list = ArrayList<MockBitmapTrace>()
for (i in 0..n) {
list.add(MockBitmapTrace(Bmp(rand.nextInt())))
}
list.filter { it.isBitmapAlive() }
//.sortedBy { it.currentBitmapSize() }
.sortedBy { it.oldBitmapSize() }
.reversed()
}

}

private class MockBitmapTrace(referent: Bmp) : WeakReference<Bmp>(referent) {

private val allocationByteCount: Int = 0

fun isBitmapAlive() = get() != null
fun currentBitmapSize(): Int = get()?.allocationByteCount ?: 0
fun oldBitmapSize(): Int = allocationByteCount

}

data class Bmp(var allocationByteCount: Int)

测试用例 gist