12

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

 8 months ago
source link: https://yanbin.blog/springboot-error-comparison-method-violates-its-general-contract/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

2024-01-07 | 阅读(10)

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

  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)

时出现如下错误

java.lang.IllegalArgumentException: Comparison method violates its general contract!
    at java.base/java.util.TimSort.mergeHi(TimSort.java:903) ~[na:na]
    at java.base/java.util.TimSort.mergeAt(TimSort.java:520) ~[na:na]
    at java.base/java.util.TimSort.mergeCollapse(TimSort.java:448) ~[na:na]
    at java.base/java.util.TimSort.sort(TimSort.java:245) ~[na:na]
    at java.base/java.util.Arrays.sort(Arrays.java:1307) ~[na:na]
    at java.base/java.util.ArrayList.sort(ArrayList.java:1721) ~[na:na]
    at org.springframework.http.MediaType.sortBySpecificityAndQuality(MediaType.java:794) ~[spring-web-5.3.30.jar:5.3.30]
    at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor.writeWithMessageConverters(AbstractMessageConverterMethodProcessor.java:254) ~[spring-webmvc-5.3.30.jar:5.3.30]
    at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.handleReturnValue(RequestResponseBodyMethodProcessor.java:183) ~[spring-webmvc-5.3.30.jar:5.3.30]
    at org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite.handleReturnValue(HandlerMethodReturnValueHandlerComposite.java:78) ~[spring-web-5.3.30.jar:5.3.30]
    at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:135) ~[spring-webmvc-5.3.30.jar:5.3.30]
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895) ~[spring-webmvc-5.3.30.jar:5.3.30]
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808) ~[spring-webmvc-5.3.30.jar:5.3.30]
    at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-5.3.30.jar:5.3.30]
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1072) ~[spring-webmvc-5.3.30.jar:5.3.30]
    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:965) ~[spring-webmvc-5.3.30.jar:5.3.30]
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) ~[spring-webmvc-5.3.30.jar:5.3.30]
    at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) ~[spring-webmvc-5.3.30.jar:5.3.30]

问题出在对 MediaType  的排序上

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

Maven pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.17</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.dataformat</groupId>
            <artifactId>jackson-dataformat-xml</artifactId>
            <version>2.12.6</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.dataformat</groupId>
            <artifactId>jackson-dataformat-cbor</artifactId>
            <version>2.12.6</version>
        </dependency>
<!--        <dependency>
            <groupId>com.amazonaws</groupId>
            <artifactId>aws-java-sdk-lambda</artifactId>
            <version>1.12.472</version>
        </dependency>-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

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

DemoController.java

@RestController
public class DemoController {
    @GetMapping("/test1")
    public String test1() {
        RestTemplate restTemplate = new RestTemplateBuilder().build();
        return restTemplate.getForEntity("http://localhost:8080/test2", String.class).getBody();
    @GetMapping("/test2")
    public String test2() {
        return "hello";

代码很简单,/test1 中用 RestTemplate 调用 /test2, 在执行

return restTemplate.getForEntity("http://localhost:8080/test2", String.class).getBody();

时出现前面的错误

我们可以用 curl 命令来测试

curl http://localhost:8080/test1

我们跟踪到错误代码行 MediaType.sortBySpecificityAndQuality(List<MediaType> mediaType)

public static void sortBySpecificityAndQuality(List<MediaType> mediaTypes) {
    Assert.notNull(mediaTypes, "'mediaTypes' must not be null");
        if (mediaTypes.size() > 1) {
            mediaTypes.sort(MediaType.SPECIFICITY_COMPARATOR.thenComparing(MediaType.QUALITY_VALUE_COMPARATOR));

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

spring-boot-media-type-2.png

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

如果调试光标停在此处直接执行 

mediaTypes.sort(MediaType.SPECIFICITY_COMPARATOR.thenComparing(MediaType.QUALITY_VALUE_COMPARATOR))

就会出现最前面的错误

spring-boot-media-type-5-800x400.png

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

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

spring-boot-media-type-3-800x602.png

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

Google 一下大概知道问题出在排序上,TimSort 要求元素大小有传递性,如已知 A>B, B>C, 那么 A 必须大于 C。本人通过简单的自定义 Comparator 的方式尚未能用简单的代码重现出相同的错误。不过我们可以从上面提取出待排序的列表,找出相关的 Comparator 实现,剔除不必要的逻辑,得到下面可重现相同问题的代码

public ArraySortErrorDemo {
    public static void main(String[] args) throws Exception {
        List<MediaType> mediaTypes = Lists.newArrayList(
            MediaType.APPLICATION_XML,
            MediaType.APPLICATION_XML,
            new MediaType("application", "xml", StandardCharsets.UTF_8),
            MediaType.APPLICATION_XML,
            new MediaType("application", "xml", StandardCharsets.UTF_8),
            MediaType.APPLICATION_XML,
            MediaType.TEXT_XML,
            MediaType.TEXT_XML,
            new MediaType("text", "xml", StandardCharsets.UTF_8),
            new MediaType("text", "xml", StandardCharsets.UTF_8),
            MediaType.APPLICATION_JSON,
            MediaType.APPLICATION_JSON,
            MediaType.APPLICATION_CBOR,
            new MediaType("application", "*+xml"),
            new MediaType("application", "*+xml"),
            new MediaType("application", "xml", StandardCharsets.UTF_8),
            new MediaType("application", "*+xml", StandardCharsets.UTF_8),
            new MediaType("application", "xml", StandardCharsets.UTF_8),
            new MediaType("application", "*+json"),
            new MediaType("application", "*+json"),
            MediaType.APPLICATION_JSON,
            new MediaType("application", "*+json"),
            MediaType.TEXT_PLAIN,
            MediaType.ALL,
            MediaType.APPLICATION_JSON,
            new MediaType("application", "*+json"),
            MediaType.APPLICATION_JSON,
            new MediaType("application", "*+json"),
            MediaType.APPLICATION_CBOR,
            new MediaType("application", "xml", StandardCharsets.UTF_8),
            new MediaType("application", "*+xml", StandardCharsets.UTF_8),
            new MediaType("application", "*+xml", StandardCharsets.UTF_8)
        mediaTypes.sort(SPECIFICITY_COMPARATOR);
    public static final Comparator<MediaType> SPECIFICITY_COMPARATOR = (mediaType1, mediaType2) -> {
        if (!mediaType1.getType().equals(mediaType2.getType())) {
            return 0;
        } else {
            int paramsSize1 = mediaType1.getParameters().size();
            int paramsSize2 = mediaType2.getParameters().size();
            return Integer.compare(paramsSize2, paramsSize1);

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 类中,其中相关代码

    static final class LegacyMergeSort {
        @SuppressWarnings("removal")
        private static final boolean userRequested =
            java.security.AccessController.doPrivileged(
                new sun.security.action.GetBooleanAction(
                    "java.util.Arrays.useLegacyMergeSort")).booleanValue();
    public static void sort(Object[] a) {
        if (LegacyMergeSort.userRequested)
            legacyMergeSort(a);
            ComparableTimSort.sort(a, 0, a.length, null, 0, 0);

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

使用 RestTemplate 时加上 Accept 头

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

RestTemplate  请求  /test2 的代码改成

RestTemplate restTemplate = new RestTemplate();
RequestEntity<Void> requestEntity = RequestEntity.get(URI.create("http://localhost:8080/test2"))
     .header(HttpHeaders.ACCEPT, "*/*").build();
restTemplate.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 就不行了,比如下面的用法仍然用相同的出错出现

RestTemplate restTemplate = new RestTemplateBuilder()
            .defaultHeader(HttpHeaders.ACCEPT, "*/*")
            .build();
restTemplate.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 的排序算法用了冒泡排序,在该类的方法

protected <T> void writeWithMessageConverters(@Nullable T value, MethodParameter returnType,
        ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage)
        throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
......
    List<MediaType> compatibleMediaTypes = new ArrayList<>();
            determineCompatibleMediaTypes(acceptableTypes, producibleTypes, compatibleMediaTypes);
    ......
    MimeTypeUtils.sortBySpecificity(compatibleMediaTypes);
    ......

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 头不是稳妥的解决办法,升级依赖才是王道
Categories: Spring

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK