出现此错误的大致环境如下
- SpringBoot 2.7.17, SpringWeb 项目,所引用入的 spring-webmvc-5.3.30, spring 6 已解决
- JDK 1.8 或 JDK 17
- 依赖了 jackson-dataformat-xml:2.12.6 和 jackson-dataformat-cbor:2.12.6, 它会在 RestTemplate 加上 application/xml, application/cbor 等 Accept 类型
- 代码中用 RestTemplate 调用此应用的 Endpoint, 未设置任何头
后面会详细列出能重现此问题的 pom.xml 配置及 Java 代码
在执行
restTemplate.getForEntity("http://localhost:8080/test2", String.class)
时出现如下错误
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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
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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
<?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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@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)
1 2 3 4 5 6 |
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)); } } |
不就是对一个列表进行排序,为何会出错。十分好奇这其中会有什么内容
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 实现,剔除不必要的逻辑,得到下面可重现相同问题的代码
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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
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
有了上面简短的可重现错误的代码,我们可以有两个方向上的解决办法
- 如何规避严格的 TimSort 排序规则
- 如何改变 List<MediaType> 列表
使用早先的归并排序
TimSort 是 JDK 1.8 加进来的,想要避免用 TimSort,即用 JDK 1.8 之前的排序方式,可以配置系统属性,如用 Java 代码
System.setProperty("java.util.Arrays.useLegacyMergeSort", "true");
有了这一系统属性后,执行前面的 ArraySortErrorDemo 就不会出错了。JDK 应用该属性选择排序算法的代码在 java.util.Arrays 类中,其中相关代码
1 2 3 4 5 6 7 |
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(); } |
算法选择
1 2 3 4 5 6 |
public static void sort(Object[] a) { if (LegacyMergeSort.userRequested) legacyMergeSort(a); else ComparableTimSort.sort(a, 0, a.length, null, 0, 0); } |
用传统的 legacyMergeSort() 就不会有问题,TimSort 产生了麻烦。
使用 RestTemplate 时加上 Accept 头
通过明确设定 RestTemplate 的 Accept 头会影响到 Spring 获得的所支持 MediaType 的列表,在一定程度上减少 TimSort 排序时出问题的概率,但不能完全避免。
RestTemplate 请求 /test2 的代码改成
1 2 3 4 |
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 就不行了,比如下面的用法仍然用相同的出错出现
1 2 3 4 |
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 的排序算法用了冒泡排序,在该类的方法
1 2 3 4 5 6 7 8 9 10 |
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!
的错误。
冒泡排序无疑是最低效的,不过这个列表中的元素不会太多,不会有太大的关系。
总结
- 从 Spring 6 对此问题的解决,他们也意识到 JDK 1.8 的 TimSort 是个问题,过份要求元素的排序规则
- 其实 Spring 在取得 MediaType 列表之后应该根据对象引用去重后出现的元素
- RestTemplate 自动会带入的 Accept 头的值请参考 RestTemplate 的默认构造函数,其中会根据是否可找到类附加 Accept 头的值
- 如果清楚不会用到 application/cbor,可从 Maven 中把 jackson-dataformat-cbor 依赖排除
- 一方面是更新到 JDK 8 用到了 TimSort 的问题,更应该及时更新到 SpringBoot 3 让新的 Spring 6 帮我们解决该问题
- 使用 "java.util.Arrays.useLegacyMergeSort" 会影响到系统全局,可能会降低某些列表排序的性能,给 RestTemplate 添加 Accept 头不是稳妥的解决办法,升级依赖才是王道