在 Tomcat 的官方配置文档 Apache Tomcat 8 Configuration Reference 中,其中对 maxPostSize
的描述如下:
The maximum size in bytes of the POST which will be handled by the container FORM URL parameter parsing. The limit can be disabled by setting this attribute to a value less than zero. If not specified, this attribute is set to 2097152 (2 megabytes). Note that the FailedRequestFilter can be used to reject requests that exceed this limit.
可见 maxPostSize
默认为 2MiB,但是我们发现,在最大堆大小配置较低的应用服务器上,若客户端 POST 数百兆的大字符串则会触发 OOM 而未进行 maxPostSize
的检查。查询 Tomcat 处理 Content-Type
为 multipart/form-data
的 POST 请求处理逻辑可知,数据以流的形式写入服务器本地磁盘,然后再从磁盘中读取文件创建字符串,与该问题相关的代码位于:Request.java at 8.5.66:
1 | if (part.getSubmittedFileName() == null) { |
其中关键之处在于数据以流写入服务器本地磁盘后,会调用 part.getString
创建字符串,该方法实现位于 DiskFileItem.java at 8.5.66:
1 | /** |
可以看出,先根据本地磁盘文件的大小创建一个字节数组,然后将文件读取至内存中的字节数组,最后用这个字节数组创建 String
实例。那么当我们使用 JDK 8 时,假设暂存至磁盘的文件大小为 200MiB,且存储的字符均为 ASCII 字符,易知,内存中首先会创建一个 200MiB 的字节数组,然后使用该字节数组去创建一个 String
实例,继续跟随 String
实例创建代码,将会调用至 StringCoding.java at jdk8-b120:
1 | char[] decode(byte[] ba, int off, int len) { |
即再创建一个元素个数为 200 × 1024 × 1024 的字符数组,且因为单个字符占用两个字节,即此处的字符数组占用 400MiB 内存,创建完成后,才会进行 maxPostSize
大小的比较。也就是说,在刚才的过程中,POST 大字符串将会导致内存占用增加 600MiB,如果最大堆大小为 512MiB,那么将触发 OOM。同理我们可以计算出上面的处理逻辑在 POST 一个文件大小超过 最大堆大小/3 的字符串时,将会触发 OOM。
我们先用一段示例代码验证下内存占用,示例工程位于:GitHub - tianshuang/tomcat-oom: Emulate Tomcat OOM。我们使用 TomcatOomApplication.java 启动该 Spring Boot 应用,并设置参数 -Xmx1g
,即先设置最大堆大小为 1G,此时使用 TomcatOomApplicationTests.java POST 一个仅包含 ASCII 字符的 文件大小 为 200MiB 的字符串,内存变化如下:
可以看出,内存先增加了 200MiB,然后又增加了 400MiB。注意,我在创建完字节数组时暂停了一下,所以图上才能体现出 200 多兆时的转折点。即内存由 20MiB 增长至 231MiB 再增长至 632MiB,与代码中的逻辑一致。因为我们此时设置的最大堆大小为 1G,所以不会触发 OOM,此时触发的异常为:
1 | 2022-03-04 23:24:35.464 ERROR 4126 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.web.multipart.MultipartException: Failed to parse multipart servlet request; nested exception is java.lang.IllegalStateException: The multi-part request contained parameter data (excluding uploaded files) that exceeded the limit for maxPostSize set on the associated connector] with root cause |
我们重启该 Spring Boot 应用并设置 -Xmx512m
,此时,再次 POST 一个仅包含 ASCII 字符的文件大小为 200MiB 的字符串,可以观察到内存变化如下:
即增长至 229MiB 就不再增长了,为什么呢?因为去申请内存占用为 400MiB 的字符数组时,就触发 OOM 了,异常栈帧如下:
1 | 2022-03-04 23:31:15.213 ERROR 12328 --- [nio-8080-exec-1] o.a.coyote.http11.Http11NioProtocol : Failed to complete processing of a request |
至此清楚原因后,我们很容易就可以修复该 bug,即先判断文件大小是否超过 maxPostSize
,如果不超过再进行字符串转换,如果超过则直接抛出 IllegalStateException
以避免构建大字符串,所以我提交了 PR 对该问题进行修复。
Remark
以上演示是在使用 Servlet 默认 MultipartConfig 配置的情况下,因为 maxRequestSize
的默认配置为无上限,可参见:Uploading Files with Java Servlet Technology。但其实在 Spring Boot 的自动配置逻辑中,已经将 maxRequestSize
设置为了 10MiB,可参见:MultipartProperties (Spring Boot 2.6.4 API)。所以如果为 Spring Boot 应用且未显式进行配置的情况下,在 POST 超过 10MiB 的字符串时,会在 FileItemIteratorImpl.java at 8.5.56 根据 Content-Length
预先判断并抛出异常,根本不会写入磁盘,所以演示时我在 Spring Boot 配置中恢复为了 Servlet 默认配置以复现该问题,配置参考:tomcat-oom/application.properties at main · tianshuang/tomcat-oom · GitHub。在非 Spring Boot 的应用中,大多数使用的默认配置,则容易触发上述的异常。
以上演示使用的 JDK 版本为 8,如果使用的 JDK 为 9 或以上的版本,则可以观察到更低的内存占用,因为引入了字符串压缩,可参考:JEP 254: Compact Strings 与 JDK 9 Release Notes。当使用 9 或更高版本的 JDK 且传输的字符串均为 ASCII 字符时,单个字符仅占用一个字节,而不像 JDK 8 占用两个字节。
Reference
part.getString will cause OOM without checking maxPostSize, checking maxPostSize first will avoid OOM caused by huge string by tianshuang · Pull Request #419 · apache/tomcat · GitHub
Fix #419. Check parameter value size before conversion to String · apache/tomcat@3e9dd49 · GitHub
Apache Tomcat 8 (8.5.76) - Changelog
CPU and memory live charts | IntelliJ IDEA