SpringBoot 应用出错 Comparison method violates its general contract!

出现此错误的大致环境如下

  1. SpringBoot 2.7.17, SpringWeb 项目,所引用入的 spring-webmvc-5.3.30, spring 6 已解决
  2. JDK 1.8 或 JDK 17
  3. 依赖了 jackson-dataformat-xml:2.12.6 和 jackson-dataformat-cbor:2.12.6, 它会在 RestTemplate 加上 application/xml, application/cbor 等 Accept 类型
  4. 代码中用 RestTemplate 调用此应用的 Endpoint, 未设置任何头

后面会详细列出能重现此问题的 pom.xml 配置及 Java 代码

在执行
restTemplate.getForEntity("http://localhost:8080/test2", String.class)

时出现如下错误
 1java.lang.IllegalArgumentException: Comparison method violates its general contract!
 2    at java.base/java.util.TimSort.mergeHi(TimSort.java:903) ~[na:na]
 3    at java.base/java.util.TimSort.mergeAt(TimSort.java:520) ~[na:na]
 4    at java.base/java.util.TimSort.mergeCollapse(TimSort.java:448) ~[na:na]
 5    at java.base/java.util.TimSort.sort(TimSort.java:245) ~[na:na]
 6    at java.base/java.util.Arrays.sort(Arrays.java:1307) ~[na:na]
 7    at java.base/java.util.ArrayList.sort(ArrayList.java:1721) ~[na:na]
 8    at org.springframework.http.MediaType.sortBySpecificityAndQuality(MediaType.java:794) ~[spring-web-5.3.30.jar:5.3.30]
 9    at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor.writeWithMessageConverters(AbstractMessageConverterMethodProcessor.java:254) ~[spring-webmvc-5.3.30.jar:5.3.30]
10    at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.handleReturnValue(RequestResponseBodyMethodProcessor.java:183) ~[spring-webmvc-5.3.30.jar:5.3.30]
11    at org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite.handleReturnValue(HandlerMethodReturnValueHandlerComposite.java:78) ~[spring-web-5.3.30.jar:5.3.30]
12    at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:135) ~[spring-webmvc-5.3.30.jar:5.3.30]
13    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895) ~[spring-webmvc-5.3.30.jar:5.3.30]
14    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808) ~[spring-webmvc-5.3.30.jar:5.3.30]
15    at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-5.3.30.jar:5.3.30]
16    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1072) ~[spring-webmvc-5.3.30.jar:5.3.30]
17    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:965) ~[spring-webmvc-5.3.30.jar:5.3.30]
18    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) ~[spring-webmvc-5.3.30.jar:5.3.30]
19    at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) ~[spring-webmvc-5.3.30.jar:5.3.30]

问题出在对 MediaType  的排序上

前面先摆出了错误,现在详细列出项目代码

Maven pom.xml
 1<?xml version="1.0" encoding="UTF-8"?>
 2<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 3    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
 4    <modelVersion>4.0.0</modelVersion>
 5    <parent>
 6        <groupId>org.springframework.boot</groupId>
 7        <artifactId>spring-boot-starter-parent</artifactId>
 8        <version>2.7.17</version>
 9        <relativePath/> <!-- lookup parent from repository -->
10    </parent>
11    <groupId>com.example</groupId>
12    <artifactId>demo</artifactId>
13    <version>0.0.1-SNAPSHOT</version>
14    <name>demo</name>
15    <description>Demo project for Spring Boot</description>
16    <properties>
17        <java.version>17</java.version>
18    </properties>
19    <dependencies>
20        <dependency>
21            <groupId>org.springframework.boot</groupId>
22            <artifactId>spring-boot-starter-web</artifactId>
23        </dependency>
24        <dependency>
25            <groupId>com.fasterxml.jackson.dataformat</groupId>
26            <artifactId>jackson-dataformat-xml</artifactId>
27            <version>2.12.6</version>
28        </dependency>
29        <dependency>
30            <groupId>com.fasterxml.jackson.dataformat</groupId>
31            <artifactId>jackson-dataformat-cbor</artifactId>
32            <version>2.12.6</version>
33        </dependency>
34<!--        <dependency>
35            <groupId>com.amazonaws</groupId>
36            <artifactId>aws-java-sdk-lambda</artifactId>
37            <version>1.12.472</version>
38        </dependency>-->
39        <dependency>
40            <groupId>org.springframework.boot</groupId>
41            <artifactId>spring-boot-starter-test</artifactId>
42            <scope>test</scope>
43        </dependency>
44    </dependencies>
45
46    <build>
47        <plugins>
48            <plugin>
49                <groupId>org.springframework.boot</groupId>
50                <artifactId>spring-boot-maven-plugin</artifactId>
51            </plugin>
52        </plugins>
53    </build>
54
55</project>

可能不会直接依赖 jackson-dataformat-cbor, 但 aws-java-sdk-lambda 会依赖于它。

DemoController.java
 1@RestController
 2public class DemoController {
 3
 4    @GetMapping("/test1")
 5    public String test1() {
 6        RestTemplate restTemplate = new RestTemplateBuilder().build();
 7        return restTemplate.getForEntity("http://localhost:8080/test2", String.class).getBody();
 8    }
 9
10    @GetMapping("/test2")
11    public String test2() {
12        return "hello";
13    }
14}

代码很简单,/test1 中用 RestTemplate 调用 /test2, 在执行
return restTemplate.getForEntity("http://localhost:8080/test2", String.class).getBody();
时出现前面的错误

我们可以用 curl 命令来测试
curl http://localhost:8080/test1
我们跟踪到错误代码行 MediaType.sortBySpecificityAndQuality(List<MediaType> mediaType)
1public static void sortBySpecificityAndQuality(List<MediaType> mediaTypes) {
2    Assert.notNull(mediaTypes, "'mediaTypes' must not be null");
3        if (mediaTypes.size() > 1) {
4            mediaTypes.sort(MediaType.SPECIFICITY_COMPARATOR.thenComparing(MediaType.QUALITY_VALUE_COMPARATOR));
5        }
6}

不就是对一个列表进行排序,为何会出错。十分好奇这其中会有什么内容

50 个元素,从对像的 ID 来看,里面有许多重复的元素,从这点来看就是 Spring 的问题。如果去除重复的元素就只有 6160 ~ 6171,共 19 个元素。

如果调试光标停在此处直接执行 
mediaTypes.sort(MediaType.SPECIFICITY_COMPARATOR.thenComparing(MediaType.QUALITY_VALUE_COMPARATOR))
就会出现最前面的错误

再次执行错误则消失,其实只要执行 mediaTypes.sort(MediaType.SPECIFICITY_COMPARATOR) 这部分代码就会出错

那这 50 个大量重复的元素是如何得来的呢,进到 org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor

从两个已经有重复的元素中再次组合出来的。

Google 一下大概知道问题出在排序上,TimSort 要求元素大小有传递性,如已知 A>B, B>C, 那么 A 必须大于 C。本人通过简单的自定义 Comparator 的方式尚未能用简单的代码重现出相同的错误。不过我们可以从上面提取出待排序的列表,找出相关的 Comparator 实现,剔除不必要的逻辑,得到下面可重现相同问题的代码
 1public ArraySortErrorDemo {
 2    public static void main(String[] args) throws Exception {
 3        List<MediaType> mediaTypes = Lists.newArrayList(
 4            MediaType.APPLICATION_XML,
 5            MediaType.APPLICATION_XML,
 6            new MediaType("application", "xml", StandardCharsets.UTF_8),
 7            MediaType.APPLICATION_XML,
 8            new MediaType("application", "xml", StandardCharsets.UTF_8),
 9            MediaType.APPLICATION_XML,
10            MediaType.TEXT_XML,
11            MediaType.TEXT_XML,
12            new MediaType("text", "xml", StandardCharsets.UTF_8),
13            new MediaType("text", "xml", StandardCharsets.UTF_8),
14            MediaType.APPLICATION_JSON,
15            MediaType.APPLICATION_JSON,
16            MediaType.APPLICATION_CBOR,
17            new MediaType("application", "*+xml"),
18            new MediaType("application", "*+xml"),
19            new MediaType("application", "xml", StandardCharsets.UTF_8),
20            new MediaType("application", "*+xml", StandardCharsets.UTF_8),
21            new MediaType("application", "xml", StandardCharsets.UTF_8),
22            new MediaType("application", "*+json"),
23            new MediaType("application", "*+json"),
24            MediaType.APPLICATION_JSON,
25            new MediaType("application", "*+json"),
26            MediaType.TEXT_PLAIN,
27            MediaType.ALL,
28            MediaType.APPLICATION_JSON,
29            new MediaType("application", "*+json"),
30            MediaType.APPLICATION_JSON,
31            new MediaType("application", "*+json"),
32            MediaType.APPLICATION_CBOR,
33            new MediaType("application", "xml", StandardCharsets.UTF_8),
34            new MediaType("application", "*+xml", StandardCharsets.UTF_8),
35            new MediaType("application", "*+xml", StandardCharsets.UTF_8)
36        );
37        mediaTypes.sort(SPECIFICITY_COMPARATOR);
38    }
39
40    public static final Comparator<MediaType> SPECIFICITY_COMPARATOR = (mediaType1, mediaType2) -> {
41        if (!mediaType1.getType().equals(mediaType2.getType())) {
42            return 0;
43        } else {
44            int paramsSize1 = mediaType1.getParameters().size();
45            int paramsSize2 = mediaType2.getParameters().size();
46            return Integer.compare(paramsSize2, paramsSize1);
47        }
48    };
49}

MediaType 是 Spring Web 的 org.springframework.http.MediaType

有了上面简短的可重现错误的代码,我们可以有两个方向上的解决办法
  1. 如何规避严格的 TimSort 排序规则
  2. 如何改变 List<MediaType> 列表

使用早先的归并排序

TimSort 是 JDK 1.8 加进来的,想要避免用 TimSort,即用 JDK 1.8 之前的排序方式,可以配置系统属性,如用 Java 代码
System.setProperty("java.util.Arrays.useLegacyMergeSort", "true");
有了这一系统属性后,执行前面的 ArraySortErrorDemo 就不会出错了。JDK 应用该属性选择排序算法的代码在 java.util.Arrays 类中,其中相关代码
1    static final class LegacyMergeSort {
2        @SuppressWarnings("removal")
3        private static final boolean userRequested =
4            java.security.AccessController.doPrivileged(
5                new sun.security.action.GetBooleanAction(
6                    "java.util.Arrays.useLegacyMergeSort")).booleanValue();
7    }

算法选择

1    public static void sort(Object[] a) {
2        if (LegacyMergeSort.userRequested)
3            legacyMergeSort(a);
4        else
5            ComparableTimSort.sort(a, 0, a.length, null, 0, 0);
6    }

用传统的 legacyMergeSort() 就不会有问题,TimSort 产生了麻烦。

使用 RestTemplate 时加上 Accept 头

通过明确设定 RestTemplate 的 Accept 头会影响到 Spring 获得的所支持 MediaType 的列表,在一定程度上减少 TimSort 排序时出问题的概率,但不能完全避免。

RestTemplate  请求  /test2 的代码改成
1RestTemplate restTemplate = new RestTemplate();
2RequestEntity<Void> requestEntity = RequestEntity.get(URI.create("http://localhost:8080/test2"))
3     .header(HttpHeaders.ACCEPT, "*/*").build();
4restTemplate.exchange(requestEntity, String.class).getBody();

根据实际 /test2 返回的类型,Accept 头改成 text/plain 等都可正常得到响应。当 Accept: */* 时从 /test1 中的 RestTemplate 发出的 HTTP 请求是
GET /test2 HTTP/1.1\r\n
Accept: */*\r\n
User-Agent: Java/17.0.9\r\n
Host: localhost:8080\r\n
Connection: keep-alive\r\n
\r\n
但是用 RestTemplateBuild 设置 DefaultHeader 就不行了,比如下面的用法仍然用相同的出错出现
1RestTemplate restTemplate = new RestTemplateBuilder()
2            .defaultHeader(HttpHeaders.ACCEPT, "*/*")
3            .build();
4restTemplate.getForEntity(URI.create("http://localhost:8080/test2"), String.class).getBody();

在 defaultHeader 中设置的 Accept 对 RestTemplate 没有任何影响,比如我们把上面的 defaultHeader(HttpHeaders.ACCEPT, "*/*) 改成
.defaultHeader(HttpHeaders.ACCEPT, "
观察到的 HTTP 请求是
GET /test2 HTTP/1.1\r\n
Accept: text/plain, application/xml, text/xml, application/json, application/cbor, application/*+xml, application/*+json, */*\r\n
User-Agent: Java/17.0.9\r\n
Host: localhost:8080\r\n
Connection: keep-alive\r\n
\r\n
Accept 头没有变化

升级到 Spring Boot 3

如果把 Spring Boot 升级到 3, 所引入的 Spring 是 6,它已解决此问题。SpringBoot 2 中只提升 spring-web 到 6.0 会有兼容问题,如找不到 jakarta/servlet/ServletRequest 等。

Spring 6 在 org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor 中对 MediaType 的排序算法用了冒泡排序,在该类的方法
 1protected <T> void writeWithMessageConverters(@Nullable T value, MethodParameter returnType,
 2        ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage)
 3        throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
 4......
 5    List<MediaType> compatibleMediaTypes = new ArrayList<>();
 6            determineCompatibleMediaTypes(acceptableTypes, producibleTypes, compatibleMediaTypes);
 7    ......
 8    MimeTypeUtils.sortBySpecificity(compatibleMediaTypes);
 9    ......
10}

Spring Web 在获得了 MediaType 列表后,不再直接调用 Arrays.sort() 方法,JDK 1.8 以上会使用 TimSort 排序算法,从而避开了 Comparison method violates its general contract! 的错误。 

冒泡排序无疑是最低效的,不过这个列表中的元素不会太多,不会有太大的关系。

总结

  1. 从 Spring 6 对此问题的解决,他们也意识到 JDK 1.8 的 TimSort 是个问题,过份要求元素的排序规则
  2. 其实 Spring 在取得 MediaType 列表之后应该根据对象引用去重后出现的元素
  3. RestTemplate 自动会带入的 Accept 头的值请参考 RestTemplate 的默认构造函数,其中会根据是否可找到类附加 Accept 头的值
  4. 如果清楚不会用到 application/cbor,可从 Maven 中把 jackson-dataformat-cbor 依赖排除
  5. 一方面是更新到 JDK 8 用到了 TimSort 的问题,更应该及时更新到 SpringBoot 3 让新的  Spring 6 帮我们解决该问题
  6. 使用 "java.util.Arrays.useLegacyMergeSort" 会影响到系统全局,可能会降低某些列表排序的性能,给 RestTemplate 添加 Accept 头不是稳妥的解决办法,升级依赖才是王道
永久链接 https://yanbin.blog/springboot-error-comparison-method-violates-its-general-contract/, 来自 隔叶黄莺 Yanbin's Blog
[版权声明] 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。