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后(双击中间)达到了一个低内存使用的平衡。
除了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,在这之后应用的内存使用稳定在一个新的锯齿形状。
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容器中
容器启动并热身一段时间后,使用了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)。
在这种模型下我们测量的性能如下:
汇总数据:
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所提供的那么好。