Spring Boot Memory Performance

想看看Spring Boot的内存性能?这里会看到Vanilla Spring Boot,JVM工具和其它一些东西。

有些时候Spring和Spring Boot被认为是“重量级”的,可能就是因为他们允许应用程序超水平发挥,提供很多功能但不需用户写代码。这篇文章关注内存使用以及如何量化Spring的影响。特别是相对于其它JVM应用,我们想知道更多关于使用Spring的实际开销。我们先创建一个基本的Spring Boot应用,看一些不同的度量方式。然后看一些对比点:普通Java应用,只使用Spring的应用,使用Spring Boot但不使用自动配置的应用,和一些RatPack示例应用。

Vanilla Spring Boot 应用

作为一个基准,我们使用一些webjar和spring.resource.enabled=true创建一个静态应用。它能提供一个很好看的静态内容,同时提供一两个REST端点。应用源码位于github上。如果安装了JDK1.8并给maven设置了path,可以使用mvnw脚本(mvnw package)来构建它。启动命令:

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

然后我们增加一些负载,让线程池热热身并强制所有的代码路径被执行:

$ ab -n 200 -c 4 http://localhost:8080/

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

server.tomcat.max-threads: 4

但最终在数据上也没有太大差异。从下面的分析推断,对于我们当前使用的栈大小,它最多会节省1MB。我们分析的所有Spring Boot webapp使用相同的配置。

为了判断内存中发生了什么,我们可能要注意下classpath的大小。尽管网络上有一些说法,JVM内存会映射所有classpath中的jar,实际上我们并没有找到任何证据证明classpath的大小会影响运行的应用。比如,vanilla 示例依赖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 starters,再加3-4个静态资源jar和webjar定位器。一个完整的最小Spring Boot应用包含Spring和一些日志,不包含web server,大概在5MB左右。

JVM工具

在JVM中有一些工具用来度量内存使用。你可以从JConsole或JVisualVM(使用JConsole插件可以检查MBeans)中获得很多有用的信息。

vinilla 应用的堆使用是一个锯齿状,最大是堆大小设置,最小是静止状态的使用量。负载下平均使用大约25MB(手动GC后是22MB)。JConsole还报告使用了50MB非堆内存(与你从java.lang:type=Memory的MBean中看到的一样)。非堆内存分为,Metaspace:32MB,Compressed Class Space:4MB,Code Cache:13MB(你可以从java.lang:type=MemoryPool,name=*MBeans中获取这些数据)。这里有6200个类和25个线程,包括监控工具加入的一些线程。

这里有一个负载下的静态app的堆内存使用图,手动GC后(双击中间)达到了一个低内存使用的平衡。

boost memory boost memory performance_boost memory

除了JConsole,JVM中其他一些工具也很有意思。如果你想用其他工具来检查应用,可以用jps来获得相应的进程id:

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

jmap柱状图:

$ jmap -histo 4330 | head -10

 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聚合并展示一个列表(虽然不是很清楚它具体是如何做到的)。

还可以展示classloader的统计数据,jmap有一种方式可以查看应用中的classloader。以root身份运行:

$ sudo ~/Programs/jdk1.8.0/bin/jmap -clstats 4300

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>21233609965  null  live<internal>
0x00000000f4b0d730114760x00000000f495c890deadsun/reflect/DelegatingClassLoader@0x0000000100009df8
0x00000000f5a26120114830x00000000f495c890deadsun/reflect/DelegatingClassLoader@0x0000000100009df8
0x00000000f52ba3a811472  null  deadsun/reflect/DelegatingClassLoader@0x0000000100009df8
0x00000000f5a3052018800x00000000f495c890deadsun/reflect/DelegatingClassLoader@0x0000000100009df8
0x00000000f495c890397263629020x00000000f495c8f0deadorg/springframework/boot/loader/LaunchedURLClassLoader@0x0000000100060828
0x00000000f5b639b0114730x00000000f495c890deadsun/reflect/DelegatingClassLoader@0x0000000100009df8
0x00000000f4b80a30114730x00000000f495c890deadsun/reflect/DelegatingClassLoader@0x0000000100009df8
...
total = 93630010405986    N/A    alive=1, dead=92    N/A

这里有大量的"死"条目,同时有一个警告说活跃的信息不准确。手动GC并不能清除它们。

内核内存工具

Linux操作系统为运行中的进程提供足够的监测能力,但众所周知,Java进程非常难以分析。这里谈了一些常见的问题。让我们看看这些可用的工具能告诉我们关于应用的哪些信息。

首先是古老的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
...

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

比如,超过3GB的一个进程,但我们知道只使用了80MB,连续的'--'占用了将近3GB。它与ps的VSZ值一致,但对于容量管理没有用处。

一些人评论说RSS值在它的机器上是准确的,这非常有趣。但对我不起作用(Ubuntu 14.04 Lenovo Thinkpad)。还有一篇有趣的文章JVM memory stats in Linux

增加进程

要测试一个进程实际使用了多少内存,可以启动多个进程直到操作系统崩溃。比如,启动40个vanilla进程:

$ 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

它们都会竞争内存资源,所以花了一段时间才启动。启动后,它们就会有效的提供服务。一旦它们启动并运行着,停止和启动其中一个进程就相对很快(几秒钟而不是几分钟)。

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

我们可以假设也许是共享的只读内存(比如映射的jar文件)使得PSS值变大了。

这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(Code Cache 4MB, Compressed Class Space 1MB, Metaspace 9MB),1500个类。几乎没有任何类加载所以没有惊喜。

普通的Spring Boot应用

现在,假设我们做同样的事,但要加载一个Spring application context:

@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(Code Cache 7MB, Compressed Class Space 2MB, Metaspace 17MB),3200个类。下图展示了从应用启动到当前状态的内存使用,中间的一个大的降幅是手动GC,在这之后应用的内存使用稳定在一个新的锯齿形状。

boost memory boost memory performance_awk_02

Spring Boot自身(相对于只用Spring)为应用增加了大量的开销吗?首先,我们可以通过移除@SpringBootApplication注解来进行测试。这样做意味着我们加载一个context,但不做任何自动配置。结果是:堆11MB(手动GC后降为5MB),非堆22MB(Code Cache 5MB, Compressed Class Space 2MB, Metaspace 15MB),2700个类。这样看来,Spring Boot的自动配置额外消耗了大约1MB堆内存,4MB非堆内存。

更进一步,我们可以手动创建一个Spring appplication context而不使用任何Spring Boot代码。这样做使得堆内存使用10MB(手动GC后降为5MB),非堆20MB(Code Cache 5MB, Compressed Class Space 2MB, Metaspace 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。Metaspace大约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!"))
        )
    );
  }
}

作为一个Spring Boot fat jar它在运行时占用16MB堆内存,28MB非堆内存。作为一个常规的gradle应用,它的堆内存占用少一点(缓存的jar不再需要),但使用同样的非堆内存,有30个线程。有趣的是,没有超过300KB的对象,而我们在Tomcat中运行的Spring Boot应用通常有10多个这种级别的大对象。

Vanilla应用变种

从一个导出的jar运行,减少了6MB堆内存(不同的就是在启动器中缓存了jar数据)。同样启动也更快了一点:当限制了fat jar的内存时小于5s,而不是7s。

一个不包含静态资源或webjar的瘦身版应用在导出成jar后运行时(启动少于3s),使用23MB堆内存和41MB非堆内存。非堆内存下降为Metaspace:35MB,Compressed Class Space: 4MB, Code Cache: 4MB。Spring ReflectionUtils跳到YourKit内存图表(使用Spring 4.2.3)的顶部(Tomcat的NioEmdpoint位居第2位)。ReflectionUtils应该在内存有压力时回收,但实际上并没有这样,所以Spring 4.2.4中一旦context启动了就清理缓存以节省一些内存(堆内存下降到大概20MB)。DefaultListableBeanFactory降为第3位,几乎是原来大小的一半(包含资源链[webjars定位器]),但如果不移除更多的特性,它不会再减少了。

结果表明NioEndpoint会一直持有1MB的空间,直到检测到OutOfMemoryError。你可以自定义为0,并放弃这个来节省额外的堆内存。比如:

@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);
        }
      });
    }
  }
...
}

虽然NioEndpoint在YourKit的大对象列表中处于很高的位置(占用大约1MB),Jetty中没有相当的大对象,但使用Jetty代替Tomcat对于整体内存或堆内存没有任何不同,Jetty也不会启动的更快。

作为一个真实的Sping Boot应用,Zipkin(Java)使用-Xmx32m -Xss256k运行的很好(至少在短暂的时间内)。它最终占用24MB堆内存和55MB非堆内存。

spring-cloud-stream示例(包含Redis)使用-Xmx32m -Xss256k也运行的很好,有类似的内存使用(总共大概80MB)。激活了actuator端点,但没占用太多内存。可能会启动的慢点。

Tomcat容器

相对于使用Spring Boot内嵌的容器,我们部署一个传统的war到Tomcat容器中

boost memory boost memory performance_jar_03

容器启动并热身一段时间后,使用了50MB堆和40MB非堆。然后我们部署一个vanilla Spring Boot应用的war,堆使用中出现了一个钉子形状,然后稳定在100MB。手动GC后降到了50MB,然后增加一些负载,它上涨到大约140MB,再次手动GC降到50MB。所以我们没理由相信相对容器来说,这个应用使用了很多额外堆内存。当有负载时 ,它会使用一些,但在GC时总会回收它。

Metaspace表现的有些不同,在单个应用负载时它从14MB长到41MB。最终总的非堆内存使用为59MB。

部署另一个应用

如果我们拷贝相同的应用到Tomcat容器中,低谷的堆消耗上涨了些(超过50MB),metaspace上涨到55MB。负载时,堆使用上涨到大概250MB,但似乎总是可回收的。

然后我增加更多的应用,部署6个应用时metaspace上涨到115MB,总的非堆到161MB。这与之前一个应用时看到的一致:每个应用占用大约20MB非堆内存。负载时堆内存涨到400MB,它不是按比例增长的。堆使用的低谷上涨到130MB,所以在堆上添加应用的累积效果是可以见证的(大约每个应用15MB)。

当我们限制Tomcat容器的内存为6个应用在内嵌容器中使用的内存(-Xmx192m)时,负载下堆内存差不多达到限制值(190MB),手动GC后低俗为118MB。非堆达到154MB。堆的低谷和非堆不完全相同,但与没有限制的Tomcat实例一致(实际上占1GB堆)。相比内嵌容器的内存总占用(包含整个堆)稍微小点,因为一些非堆内存在应用间共享(344MB而不是492MB)。对于现实中的应用需要更多的堆内存,差异不会成比例放大(对于8G来说,50MB可以忽略不计)。一些管理自己线程池的应用(Spring应用中很常见)还会占用更多非堆内存提供给它的线程们。

估算进程大小

对实际内存使用非常粗略的估计是堆大小加上20倍(servlet容器一般有20个线程)的栈大小,再加一点儿,所以在vanilla应用中每个进程40MB。根据JConsole给出的数字(50MB加上堆,也就是82MB),这样估算的有点小。根据观察,非堆的使用大致与加载的类的数量成比例。一旦你纠正了栈大小,相关性就会提高,所以一个更好的估算规则是与加载的类数量成比例:

memory = heap + non-heap

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

vanilla应用加载了6000个类,普通的Java main加载了大约1500个,估算对这两个应用都非常准确。

添加Spring Cloud Eureka discovery只另外加载了1500个类,使用大约40个线程,所以它会使用多一点非堆内存,但也不太多(实际上它使用了大约70MB,栈大小是256KB,根据估算规则预计是63MB)。

在这种模型下我们测量的性能如下:

boost memory boost memory performance_boost memory_04

汇总数据:

APPLICATION

HEAP(MB)

NON HEAP(MB)

THREADS

CLASSES

Vanilla

22

50

25

6200

Plain Java

6

14

11

1500

Spring Boot

6

26

11

3200

No Actuator

5

22

11

2700

Spring Only

5

20

11

2400

Eureka Client

80*

70

40

7600

Ratpack Groovy

22

43

24

5300

Ratpack Java

16

28

22

3000

*只有Eureka client使用一个大的堆,其它都设置为-Xmx32m

总结

Spring Boot对Java应用自身的影响是会使用多一点堆和非堆内存,几乎都是因为要加载额外的类。差异可以被量化成大约一个额外的2MB堆和12MB非堆。一个真正的应用程序,为了实际业务目的可能会消耗很多倍的内存,这是相当微不足道。vanilla Spring 和 Spring Boot只是有几MB的区别。Spring Boot团队刚刚开始在这个细节层次进行测量,所以让我们期待未来的优化吧。当我们比较多个应用部署在单一Tomcat容器和使用单独的进程的内存使用时,毫无意外地单个容器使得应用在内存中更紧密,一个独立进程的害处主要是非堆的使用,然而当应用数量比容器数量大的多的时候每个应用可能会上涨到30MB。我们不会期望它像使用更多堆内存那样增长,所以在现实应用中这并不显著。把应用部署为独立的进程遵循了 twelve-factor,并且支持云的原生特性带来的好处远大于使用更多内存的成本。最后,我们看到当你想检查进程并找出其内存使用情况时,操作系统中的工具没有JVM所提供的那么好。