Spring Boot 内存性能

工程 | Dave Syer | 2015年12月10日 | ...

有时有人会说 Spring 和 Spring Boot 是“重量级”的,也许仅仅是因为它们允许应用程序超出其重量,为很少的用户代码提供大量功能。在本文中,我们专注于内存使用,并探讨是否可以量化使用 Spring 的效果?具体来说,我们希望更多地了解与其他 JVM 应用程序相比,使用 Spring 的实际开销。我们首先使用 Spring Boot 创建一个基本应用程序,并在应用程序运行时查看几种不同的测量方法。然后,我们查看一些比较点:纯 Java 应用程序、使用 Spring 但不使用 Spring Boot 的应用程序、使用 Spring Boot 但不使用自动配置的应用程序,以及一些 Ratpack 示例应用程序。

基础 Spring Boot 应用程序

作为基线,我们构建了一个静态应用程序,其中包含一些 webjars 并且 spring.resources.enabled=true。这对于提供美观的静态内容(可能带有一两个 REST 端点)非常合适。我们用于测试的应用程序的源代码位于 github 上。如果您有 JDK 1.8 可用并在您的路径上,可以使用 mvnw 包装器脚本构建它(mvnw package)。可以像这样启动它

$ java -Xmx32m -Xss256k -jar target/demo-0.0.1-SNAPSHOT.jar

然后我们添加一些负载,只是为了预热线程池并强制执行所有代码路径。

$ ab -n 2000 -c 4 https://127.0.0.1:8080/

我们可以在 application.properties 中尝试限制线程数量

server.tomcat.max-threads: 4

但最终对数字没有太大影响。从下面的分析中,我们得出结论,使用我们正在使用的堆栈大小最多可以节省 1MB。我们分析的所有 Spring Boot webapps 都有相同的配置。

我们可能需要担心类路径有多大,以便估计内存会发生什么变化。尽管互联网上有一些说法称 JVM 会将类路径上的所有 jar 文件映射到内存中,但我们实际上没有发现任何证据表明类路径的大小会对正在运行的应用程序产生任何影响。作为参考,基础示例中依赖项 jar 文件(不包括 JDK)的大小为 18MB

$ jar -tvf  target/demo-0.0.1-SNAPSHOT.jar | grep lib/.*.jar | awk '{tot+=$1;} END {print tot}'
18893563

这包括 Spring Boot Web 和 Actuator 启动器,以及 3 或 4 个用于静态资源和 webjar 定位器的 webjars。一个完全最小的 Spring Boot 应用程序,包括 Spring 和一些日志记录,但没有 web 服务器,大约需要 5MB 的 jar 文件。

JVM 工具

要测量内存使用情况,JVM 中有一些工具。您可以从 JConsole 或 JVisualVM(使用 JConsole 插件,以便您可以检查 MBeans)获取大量有用的信息。

我们基础应用程序的堆使用情况呈锯齿状,上限为堆大小,下限为静止状态下使用的内存量。在负载下的应用程序平均重量约为 25MB(手动 GC 后为 22MB)。JConsole 还报告了 50MB 的非堆使用量(这与您从 java.lang:type=Memory MBean 获取的结果相同)。非堆使用量细分为元空间:32MB,压缩类空间:4MB,代码缓存:13MB(您可以从 java.lang:type=MemoryPool,name=* MBeans 获取这些数字)。有 6200 个类和 25 个线程,包括我们用于测量它们的监控工具添加的一些线程。

这是一张来自静止状态下负载应用程序的堆使用情况图表,随后是手动垃圾回收(中间的双凹陷)以及较低堆使用量的新的平衡状态。

JVM 中除了 JConsole 之外,其他一些工具也可能很有趣。第一个是 jps,它可用于获取要使用其他工具检查的应用程序的进程 ID

$ jps
4289 Jps
4330 demo-0.0.1-SNAPSHOT.jar

然后我们有 jmap 直方图

$ jmap -histo 4330 | head

 num     #instances         #bytes  class name
----------------------------------------------
   1:          5241        6885088  [B
   2:         21233        1458200  [C
   3:          2548        1038112  [I
   4:         20970         503280  java.lang.String
   5:          6023         459832  [Ljava.lang.Object;
   6:         13167         421344  java.util.HashMap$Node
   7:          3386         380320  java.lang.Class

此数据用途有限,因为您无法将“大型”对象追溯到其所有者。为此,您需要更完善的探查器,例如 YourKit。YourKit 会为您执行聚合并显示列表(尽管它执行此操作的细节尚不清楚)。

类加载器统计信息也可能很有启发性,并且 jmap 有一种方法可以检查应用程序中的类加载器。它需要以 root 身份运行

$ sudo ~/Programs/jdk1.8.0/bin/jmap -clstats 4330
Attaching to process ID 4330, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.60-b23
finding class loader instances ..done.
computing per loader stat ..done.
please wait.. computing liveness....................................liveness analysis may be inaccurate ...
class_loader  classes  bytes  parent_loader  alive?  type

<bootstrap>	2123	3609965	  null  	live	<internal>
0x00000000f4b0d730	1	1476	0x00000000f495c890	dead	sun/reflect/DelegatingClassLoader@0x0000000100009df8
0x00000000f5a26120	1	1483	0x00000000f495c890	dead	sun/reflect/DelegatingClassLoader@0x0000000100009df8
0x00000000f52ba3a8	1	1472	  null  	dead	sun/reflect/DelegatingClassLoader@0x0000000100009df8
0x00000000f5a30520	1	880	0x00000000f495c890	dead	sun/reflect/DelegatingClassLoader@0x0000000100009df8
0x00000000f495c890	3972	6362902	0x00000000f495c8f0	dead	org/springframework/boot/loader/LaunchedURLClassLoader@0x0000000100060828
0x00000000f5b639b0	1	1473	0x00000000f495c890	dead	sun/reflect/DelegatingClassLoader@0x0000000100009df8
0x00000000f4b80a30	1	1473	0x00000000f495c890	dead	sun/reflect/DelegatingClassLoader@0x0000000100009df8
...

total = 93	6300	10405986	    N/A    	alive=1, dead=92	    N/A

有很多“已死”条目,但还有一个警告,即存活性信息不准确。手动 GC 无法清除它们。

内核内存工具

您可能会认为 Linux 操作系统会提供大量对正在运行的进程的洞察,它确实提供了,但 Java 进程很难分析。这 受欢迎的 SO 链接 讨论了一些普遍存在的问题。让我们看看一些可用的工具,看看它们告诉我们关于应用程序的信息。

首先是老式的 ps(您用于在命令行上查看进程的工具)。您可以从 top 获取很多相同的信息。这是我们的应用程序进程

$ ps -au
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
dsyer     4330  2.4  2.1 2829092 169948 pts/5  Sl   18:03   0:37 java -Xmx32m -Xss256k -jar target/demo-0.0.1-SNAPSHOT.jar
...

根据 ps,RSS(驻留集大小)值在 150-190MB 范围内。有一个名为 smem 的工具,它应该提供更干净的视图,并准确反映非共享内存,但那里的值(例如 PSS)并没有太大区别。有趣的是,非 JVM 进程的 PSS 值通常比 RSS 低得多,而对于 JVM 而言,它们是可以比拟的。JVM 非常珍惜它的内存。

一个更底层的工具是 pmap,在这里我们可以查看分配给进程的内存分配。来自 pmap 的数字似乎也没有多大意义

$ pmap 4330
0000000000400000      4K r-x-- java
0000000000600000      4K rw--- java
000000000184c000    132K rw---   [ anon ]
00000000fe000000  36736K rw---   [ anon ]
00000001003e0000 1044608K -----   [ anon ]
...
00007ffe2de90000      8K r-x--   [ anon ]
ffffffffff600000      4K r-x--   [ anon ]
 total          3224668K

即对于我们知道仅使用 80MB 的进程,超过 3GB。仅计算 '-----' 条目即可获得近 3GB。至少这与 ps 中的 VSZ 数字一致,但对容量管理没有太大用处。

有人评论说 RSS 值在他的机器上是准确的,这很有趣。它们肯定对我不起作用(Lenovo Thinkpad 上的 Ubuntu 14.04)。此外,这里还有一篇关于 Linux 中 JVM 内存统计信息 的有趣的文章。

扩展进程

一个很好的测试方法是,通过不断启动更多进程,直到操作系统开始崩溃,从而测试进程实际使用了多少内存。例如,要启动 40 个相同的基础进程

$ for f in {8080..8119}; do (java -Xmx32m -Xss256k -jar target/demo-0.0.1-SNAPSHOT.jar --server.port=$f 2>&1 > target/$f.log &); done

它们都在争夺内存资源,因此启动所有进程都需要一段时间,这很正常。一旦它们都启动,它们就会非常有效地服务其主页(在第 99 个百分位数时,通过糟糕的 LAN 的延迟为 51 毫秒)。一旦它们启动并运行,停止和启动其中一个进程相对较快(几秒钟而不是几分钟)。

来自 ps 的 VSZ 数字超出了范围(正如预期的那样)。RSS 数字看起来也很高

$ ps -au
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
dsyer    27429  2.4  2.1 2829092 169948 pts/5  Sl   18:03   0:37 java -Xmx32m -Xss256k -jar target/demo-0.0.1-SNAPSHOT.jar --server.port=8081
dsyer    27431  3.0  2.2 2829092 180956 pts/5  Sl   18:03   0:45 java -Xmx32m -Xss256k -jar target/demo-0.0.1-SNAPSHOT.jar --server.port=8082
...

RSS 值仍在 150-190MB 范围内。如果所有 40 个进程都独立地使用这么多内存,那么这将占 6.8GB,这会将我的 8GB 笔记本电脑耗尽。它运行良好,因此大部分 RSS 值实际上不独立于其他进程。

来自 smem 的比例共享大小 (PSS) 可能是我们对实际内存使用量的更好估计,但实际上它与 RSS 值并没有太大区别

$ smem
  PID User     Command                         Swap      USS      PSS      RSS
...
27435 dsyer    java -Xmx32m -Xss256k -jar         0   142340   142648   155516 
27449 dsyer    java -Xmx32m -Xss256k -jar         0   142452   142758   155568 
...
27441 dsyer    java -Xmx32m -Xss256k -jar         0   175156   175479   188796 
27451 dsyer    java -Xmx32m -Xss256k -jar         0   175256   175579   188900 
27463 dsyer    java -Xmx32m -Xss256k -jar         0   179592   179915   193224 

我们可以假设,也许 PSS 数字仍然受到共享只读内存(例如映射的 jar 文件)的极大夸大。

这 40 个进程几乎填满了我的笔记本电脑上的可用内存(应用程序启动前为 3.6GB),并且发生了一些分页,但不多。我们可以将其转换为进程大小的估计值:3.6GB/40 = 90MB。与 JConsole 的估计值相差不远。

不做任何事情的普通 Java 应用程序

作为一个有用的比较点,让我们创建一个非常基本的 Java 应用程序,当我们运行它时它会保持活动状态,这样我们就可以测量它的内存消耗。

public class Main throws Exception {
  public static void main (String[] args) {
    System.in.read();
  }
}

结果:堆 6MB,非堆 14MB(代码缓存 4MB,压缩类空间 1MB,元空间 9MB),1500 个类。几乎没有加载任何类,所以这真的不足为奇。

不做任何事情的 Spring Boot 应用程序

现在假设我们做同样的事情,但同时也加载一个 Spring 应用程序上下文。

@SpringBootApplication
public class MainApplication implements ApplicationRunner {

  @Override
  public void run(ApplicationArguments args) throws Exception {
    System.in.read();
  }

  public static void main(String[] args) throws Exception {
    SpringApplication.run(MainApplication.class, args);
  }

}

堆 12MB(但在手动 GC 后下降到 6MB),非堆 26MB(代码缓存 7MB,压缩类空间 2MB,元空间 17MB),3200 个类。下图显示了从启动应用程序到结束状态的堆使用情况。中间的大幅下降是手动 GC,您可以看到在此之后应用程序稳定在不同的锯齿状。

Spring Boot 本身(而不是仅仅是 Spring)是否为该应用程序增加了大量开销?首先,我们可以通过删除@SpringBootApplication注释来测试这一点。这样做意味着我们加载上下文但不进行任何自动配置。结果是:堆 11MB(在手动 GC 后下降到 5MB),非堆 22MB(代码缓存 5MB,压缩类空间 2MB,元空间 15MB),2700 个类。通过这种方式测量的 Spring Boot 自动配置溢价约为 1MB 堆和 4MB 非堆。

更进一步,我们可以手动创建一个 Spring 应用程序上下文,而根本不使用任何 Spring Boot 代码。这样做将堆使用量降至 10MB(在手动 GC 后降至 5MB),非堆降至 20MB(代码缓存 5MB,压缩类空间 2MB,元空间 13MB),2400 个类。通过这种方式测量的 Spring Boot 总溢价小于 2MB 堆和大约 6MB 非堆内存。

Ratpack Groovy 应用程序

可以使用lazybones创建简单的 Ratpack groovy 应用程序。

$ lazybones create ratpack .
$ ./gradlew build
$ unzip build/distributions/ratpack.zip
$ JAVA_OPTS='-Xmx32m -Xss256k' ./ratpack/bin/ratpack
$ ls -l build/distributions/ratpack/lib/*.jar | awk '{tot+=$5;} END {print tot}'
16277607

使用的堆一开始非常低(13MB),随着时间的推移增长到 22MB。元空间约为 34MB。JConsole 报告了 43MB 的非堆使用量。有 31 个线程。

Ratpack Java 应用程序

这是一个非常基本的静态应用程序。

import ratpack.server.BaseDir;
import ratpack.server.RatpackServer;

public class DemoApplication {

  public static void main(String[] args) throws Exception {
    RatpackServer.start(s -> s
        .serverConfig(c -> c.baseDir(BaseDir.find()))
        .handlers(chain -> chain
            .all(ctx -> ctx.render("root handler!"))
        )
    );
  }

}

它以大约 16MB 堆、28MB 非堆作为 Spring Boot 胖 JAR 运行。作为常规的 Gradle 应用程序,它在堆上的消耗略轻(不需要缓存的 JAR),但使用相同的非堆内存。有 30 个线程。有趣的是,没有一个对象大于 300KB,而我们带有 Tomcat 的 Spring Boot 应用程序通常有 10 个或更多大于该级别的对象。

普通应用程序的变体

从解压的 JAR 中运行可以减少高达 6MB 的堆(差异是启动器中的缓存 JAR 数据)。还可以使启动速度更快:在内存受胖 JAR 限制的情况下,不到 5 秒,而最多 7 秒。

一个精简版本的应用程序,没有静态资源或 Webjars,作为解压的归档文件运行时,堆为 23MB,非堆为 41MB(在不到 3 秒的时间内启动)。非堆使用情况细分为元空间:35MB,压缩类空间:4MB,代码缓存:4MB。在 Spring 4.2.3 中,Spring 的ReflectionUtils跳到了 YourKit 内存图表的最顶端(仅次于 Tomcat 的NioEndpoint)。ReflectionUtils应该在内存压力下缩小,但实际上它们不会缩小,因此 Spring 4.2.4在上下文启动后清除缓存,从而节省了一些内存(降至约 20MB 堆)。DefaultListableBeanFactory下降到第三位,并且几乎是其资源链(Webjars 定位器)大小的一半,但如果不删除更多功能,它将无法进一步缩小。

事实证明,NioEndpoint有一个 1MB 的“内存溢出降落伞”,它会一直保留到检测到OutOfMemoryError为止。您可以将其自定义为零并放弃降落伞以节省额外的 1MB 堆,例如

@SpringBootApplication
public class SlimApplication implements EmbeddedServletContainerCustomizer {

  @Override
  public void customize(ConfigurableEmbeddedServletContainer container) {
    if (container instanceof TomcatEmbeddedServletContainerFactory) {
      TomcatEmbeddedServletContainerFactory tomcat = (TomcatEmbeddedServletContainerFactory) container;
      tomcat.addConnectorCustomizers(connector -> {
        ProtocolHandler handler = connector.getProtocolHandler();
        if (handler instanceof Http11NioProtocol) {
          Http11NioProtocol http = (Http11NioProtocol) handler;
          http.getEndpoint().setOomParachute(0);
        }
      });
    }
  }

...

}

使用 Jetty 而不是 Tomcat 对整体内存或堆没有任何影响,即使NioEndpoint在 YourKit 的“最大对象”列表中排名很高(占用约 1MB),并且 Jetty 没有相应的突增。它也不会更快地启动。

例如,“真实”的 Spring Boot 应用程序 Zipkin(Java)可以使用-Xmx32m -Xss256k正常运行,至少在短时间内可以。它稳定在约 24MB 堆和约 55MB 非堆。

spring-cloud-stream示例接收器(使用 Redis 传输)也可以使用-Xmx32m -Xss256k正常运行,并且具有类似的内存使用情况配置文件(即总共约 80MB)。执行器端点处于活动状态,但对内存配置文件影响不大。也许启动速度略慢。

Tomcat 容器

如果不使用 Spring Boot 中的嵌入式容器,而是在 Tomcat 容器中部署传统的 WAR 文件会怎样?

容器启动并预热了一点,使用了大约 50MB 堆和 40MB 非堆。然后我们部署了普通 Spring Boot 应用程序的 WAR,堆使用量出现了一个峰值,然后稳定在约 100MB。我们执行了手动 GC,它下降到 50MB 以下,然后添加一些负载,它又跳到大约 140MB。手动 GC 将其降回 50MB 以下。因此,我们没有理由相信该应用程序与容器相比实际上使用了太多(如果有的话)额外的堆。它在负载下会使用一些,但它总能在 GC 压力下回收它。

但是,元空间讲述了一个不同的故事,它在负载下的单个应用程序中从 14MB 上升到 41MB。在最终状态下,报告的总非堆使用量为 59MB。

部署另一个应用程序

如果我们向 Tomcat 容器添加另一个相同应用程序的副本,则总堆消耗会略有上升(超过 50MB),元空间上升到 55MB。在负载下,堆使用量跃升至 250MB 左右,但似乎总是可以回收的。

然后我们添加更多应用程序。部署了六个应用程序后,元空间上升到 115MB,总非堆上升到 161MB。这与我们对单个应用程序的观察结果一致:每个应用程序都会消耗我们大约 20MB 的非堆内存。负载下的堆使用量达到 400MB,因此这不会成比例地上升(但是它正在从上面进行管理,所以这也许并不奇怪)。堆使用量的低谷上升到大约 130MB,因此在堆上添加应用程序的累积效应在那里可见(每个应用程序约 15MB)。

当我们将 Tomcat 限制到我们普通嵌入式启动中六个应用程序将具有的相同堆量(-Xmx192m)时,负载下的堆或多或少处于其限制(190MB),并且手动 GC 后的低谷为 118MB。报告的非堆内存为 154MB。堆低谷和非堆使用情况并不完全相同,但与不受限制的 Tomcat 实例一致(实际上它有 1GB 堆)。与嵌入式容器相比,包括完整堆在内的总内存使用量略小,因为一些非堆内存显然在应用程序之间共享(344MB 与 492MB)。对于需要更多堆本身的更真实的应用程序,差异不会成比例地那么大(8GB 中的 50MB 可以忽略不计)。此外,任何管理自身线程池的应用程序(在现实生活中的 Spring 应用程序中并不少见)都将为其需要的线程产生额外的非堆内存开销。

经验法则进程大小

对实际内存使用情况的一个非常粗略的估计是堆大小加上栈大小的 20 倍(对于 servlet 容器中典型的 20 个线程),再加上一点,所以在我们的普通应用程序中每个进程大约为 40MB。考虑到 JConsole 的数字(50MB 加上堆,或 82MB),这个估计有点低。但是,我们可以观察到,我们应用程序中的非堆使用量与加载的类数大致成正比。一旦您校正了栈大小,相关性就会提高,因此更好的经验法则可能是与加载的类数成正比的经验法则。

memory = heap + non-heap

non-heap = threads x stack + classes x 7/1000 

普通应用程序加载大约 6000 个类,而空操作 Java 主程序加载大约 1500 个类。该估计对于普通应用程序和空操作 Java 应用程序都是准确的。

仅添加 Spring Cloud Eureka 服务发现大约会加载另外 1500 个类,并使用大约 40 个线程,因此它应该使用更多的非堆内存,但不会太多(实际上,它确实使用了大约 70MB,栈大小为 256KB,而经验法则预测为 63MB)。

下面显示了此模型在我们测量的应用程序上的性能。

数据汇总

应用程序 堆 (MB) 非堆 (MB) 线程
普通 22 50 25 6200
纯 Java 6 14 11 1500
Spring Boot 6 26 11 3200
无执行器 5 22 11 2700
仅 Spring 5 20 11 2400
Eureka 客户端 80* 70 40 7600
Ratpack Groovy 22 43 24 5300
Ratpack Java 16 28 22 3000

* 只有 Eureka 客户端具有更大的堆:所有其他客户端都显式设置为 -Xmx32m

结论

Spring Boot 本身对 Java 应用程序的影响是使用更多的堆和非堆内存,这主要是因为它必须加载额外的类。差异可以量化为大约额外的 2MB 堆和 12MB 非堆。在实际应用程序中,为了实际业务目的,它可能会消耗多倍的内存,这微不足道。普通 Spring 和 Spring Boot 之间的差异总共只有几 MB(实际上无关紧要)。Spring Boot 团队才刚刚开始以这种详细程度衡量事物,因此我们可能预计将来会进行优化。当我们将部署在单个 Tomcat 容器中的应用程序的内存使用情况与部署为独立进程的相同应用程序的内存使用情况进行比较时,毫不奇怪,单个容器会更密集地将应用程序打包到内存中。独立进程的开销主要与非堆使用相关,当应用程序数量远大于容器数量时(否则更少),每个应用程序的非堆使用量加起来可能达到 30MB。我们预计随着应用程序使用更多堆,这不会增加,因此在大多数实际应用程序中,它并不重要。我们认为,按照 十二要素 和云原生原则将应用程序部署为独立进程的好处超过了使用更多内存的成本。最后需要注意的是,当您想要检查进程并了解其内存使用情况时,操作系统中的本机工具远不如 JVM 提供的工具好。

获取 Spring 新闻通讯

与 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,以加速您的进步。

了解更多

获取支持

Tanzu Spring 通过一个简单的订阅提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

查看 Spring 社区中所有即将举行的活动。

查看全部