领先一步
VMware 提供培训和认证,助您加速前进。
了解更多有时有人认为 Spring 和 Spring Boot 是“重量级”的,也许只是因为它们允许应用程序发挥超出其自身重量级的功能,用不多的用户代码提供了许多功能。在本文中,我们将重点关注内存使用情况,并探讨我们是否可以量化使用 Spring 的影响。具体来说,我们想了解更多关于使用 Spring 相较于其他 JVM 应用程序的实际开销。我们首先创建一个基本的 Spring Boot 应用程序,并研究几种不同的测量其运行时内存的方法。然后,我们将比较一些参照点:普通的 Java 应用程序、使用 Spring 但不使用 Spring Boot 的应用程序、使用 Spring Boot 但没有自动配置的应用程序以及一些 Ratpack 示例应用程序。
作为基线,我们构建了一个静态应用程序,包含一些 webjars 并设置了 spring.resources.enabled=true
。这非常适合提供美观的静态内容,可能还会带有一两个 REST 端点。我们用于测试的应用程序源代码在 github 上。如果您安装了 JDK 1.8 且已将其添加到 PATH 中,可以使用 mvnw
wrapper 脚本构建它(mvnw package
)。可以这样启动它:
$ java -Xmx32m -Xss256k -jar target/demo-0.0.1-SNAPSHOT.jar
然后我们添加一些负载,只是为了预热线程池并强制执行所有代码路径
$ ab -n 2000 -c 4 http://localhost:8080/
我们可以尝试在 application.properties
中稍微限制线程数
server.tomcat.max-threads: 4
但最终对数据影响不大。根据下面的分析,我们得出结论,以我们使用的栈大小来看,最多可以节省 1MB。我们分析的所有 Spring Boot web 应用程序都具有相同的配置。
为了估计内存会发生什么,我们可能不得不担心 classpath 的大小。尽管互联网上有些说法称 JVM 会将 classpath 中的所有 jar 文件内存映射,但我们实际上没有发现任何证据表明 classpath 的大小对正在运行的应用程序有任何影响。供参考,原版示例中依赖 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,以及用于静态资源和 webjar locator 的 3 或 4 个 webjars。一个完全最小化的 Spring Boot 应用程序,只包括 Spring 和一些日志功能但没有 web 服务器,其 jar 文件大小约为 5MB。
为了测量内存使用情况,JVM 提供了一些工具。您可以从 JConsole 或 JVisualVM(带有 JConsole 插件,以便您可以检查 MBeans)获取大量有用的信息。
我们原版应用程序的堆使用量呈锯齿状,上限是堆大小,下限是静止状态下使用的内存量。在负载下,应用程序的平均堆使用量约为 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 个线程,其中包括我们用于测量它们的监控工具添加的一些线程。
下图显示了一个在负载下处于静止状态的应用程序的堆使用情况,随后是手动垃圾回收(中间的双重下降),以及达到较低堆使用量的新平衡状态。
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 会为您进行聚合并呈现一个列表(尽管其具体实现细节尚不清楚)。
Classloader 统计信息也可能很有启发性,jmap
提供了一种检查应用程序中 classloader 的方法。它需要以 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 (Resident Set Size) 值在 150-190MB 范围内。有一个名为 smem
的工具,据说可以提供更清晰的视图,并准确反映非共享内存,但那里的值(例如 PSS)差异不大。有趣的是,非 JVM 进程的 PSS 值通常显著低于 RSS,而 JVM 进程的 PSS 值与 RSS 相当。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 内存的进程,pmap
报告超过 3GB。仅仅计算 '-----' 条目就占了将近 3GB。这至少与 ps
的 VSZ 值一致,但对于容量管理来说用处不大。
有人评论说 RSS 值在他的机器上是准确的,这很有趣。但在我的机器(联想 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 个百分位延迟为 51ms)。一旦它们运行起来,停止和启动其中一个进程相对较快(几秒钟而不是几分钟)。
`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` 中的 Proportional Shared Size (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 应用程序,以便我们可以测量其内存消耗
public class Main throws Exception {
public static void main (String[] args) {
System.in.read();
}
}
结果:堆 6MB,非堆 14MB(代码缓存 4MB,压缩类空间 1MB,Metaspace 9MB),1500 个类。几乎没有类加载,这并不奇怪。
现在假设我们做同样的事情,但同时加载一个 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,Metaspace 17MB),3200 个类。下图显示了从启动应用程序到结束状态的堆使用情况。中间的大幅下降是手动 GC,您可以看到此后应用程序稳定在不同的锯齿状模式。
Spring Boot 本身(相对于仅使用 Spring)是否为这个应用程序增加了大量开销?首先,我们可以通过移除 @SpringBootApplication
注解来测试这一点。这样做意味着我们加载了一个上下文,但没有进行任何自动配置。结果是:堆 11MB(手动 GC 后降至 5MB),非堆 22MB(代码缓存 5MB,压缩类空间 2MB,Metaspace 15MB),2700 个类。以这种方式衡量,Spring Boot 自动配置的额外开销约为 1MB 堆和 4MB 非堆内存。
更进一步,我们可以完全手动创建 Spring 应用程序上下文,而不使用任何 Spring Boot 代码。这样做会将堆使用量降至 10MB(手动 GC 后降至 5MB),非堆降至 20MB(代码缓存 5MB,压缩类空间 2MB,Metaspace 13MB),2400 个类。以这种方式衡量,Spring Boot 的总额外开销低于 2MB 堆和约 6MB 非堆内存。
一个简单的 Ratpack groovy 应用程序可以使用 lazybones 创建
$ 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 个线程。
这是一个非常基础的静态应用程序
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 个或更多对象超过这个大小。
从解压后的 jar 运行可以节省高达 6MB 的堆内存(差异在于启动器中缓存的 jar 数据)。启动速度也快一些:在内存受限的情况下,fat jar 启动需要多达 7 秒,而解压后的 jar 启动不到 5 秒。
一个没有静态资源或 webjars 的精简版应用程序,以解压存档的形式运行时,占用 23MB 堆内存和 41MB 非堆内存(启动时间不到 3 秒)。非堆使用量细分为:Metaspace: 35MB,压缩类空间: 4MB,代码缓存: 4MB。在 YourKit 中,使用 Spring 4.2.3 时,Spring 的 ReflectionUtils
在内存图表中几乎排在前列(仅次于 Tomcat 的 NioEndpoint
)。ReflectionUtils
在内存压力下应该会缩小,但实际上并没有,因此 Spring 4.2.4 在上下文启动后清除了缓存,从而节省了一些内存(堆内存降至约 20MB)。DefaultListableBeanFactory
降至第三位,并且大小几乎是带资源链 (webjars locator) 时的一半,但如果不移除更多功能,它不会进一步缩小。
结果发现,NioEndpoint
有一个 1MB 的“oom 保护伞”,它会一直保留着,直到检测到 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 对整体内存或堆内存没有影响,尽管在 YourKit 的“最大对象”列表中 NioEndpoint
排名很高(占用约 1MB),而 Jetty 没有相应的表现。它的启动速度也没有更快。
作为一个“真实”Spring Boot 应用程序的例子,Zipkin (Java) 在使用 -Xmx32m -Xss256k
的情况下运行良好,至少在短时间内如此。它稳定后,堆内存约为 24MB,非堆内存约为 55MB。
`spring-cloud-stream` 示例 sink(使用 Redis 传输)在使用 -Xmx32m -Xss256k
的情况下也运行良好,并且内存使用情况相似(即总计约 80MB)。Actuator 端点处于活动状态,但对内存配置文件影响不大。启动速度可能稍微慢一些。
如果不使用 Spring Boot 内嵌容器,而是将传统的 war 文件部署到 Tomcat 容器会怎样?
容器启动并预热后,使用约 50MB 堆内存和 40MB 非堆内存。然后我们部署原版 Spring Boot 应用程序的 war 文件,堆使用量会出现一个高峰,稳定在约 100MB。我们进行一次手动 GC,它会降至 50MB 以下,然后添加一些负载,它会跃升至约 140MB。手动 GC 又将其降回 50MB 以下。因此,我们没有理由认为与容器相比,这个应用程序实际使用了额外的堆内存(如果有的话)。在负载下它会使用一些,但在 GC 压力下总是可以回收。
然而,Metaspace 的情况不同,在负载下的单个应用程序中,它从 14MB 增加到 41MB。最终状态下,报告的总非堆使用量为 59MB。
如果我们在 Tomcat 容器中再部署一个相同的应用程序副本,堆内存的低谷消耗会稍微增加(超过 50MB),Metaspace 会达到 55MB。在负载下,堆使用量会跃升至 250MB 左右,但似乎总是可以回收的。
然后我们添加更多应用程序。部署六个应用程序后,Metaspace 达到 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 main 方法加载了大约 1500 个类。这个估算值对原版应用程序和什么都不做的 Java 应用程序是准确的。
添加 Spring Cloud Eureka 服务发现只加载了大约另外 1500 个类,并使用了大约 40 个线程,因此它应该会使用更多的非堆内存,但不会多很多(实际上,在使用 256KB 栈时它使用了大约 70MB,而经验法则会预测 63MB)。
我们测量的应用程序使用该模型的性能如下所示
应用 | 堆内存 (MB) | 非堆内存 (MB) | 线程数 | 类数量 |
---|---|---|---|---|
原版 | 22 | 50 | 25 | 6200 |
纯 Java | 6 | 14 | 11 | 1500 |
Spring Boot | 6 | 26 | 11 | 3200 |
无 Actuator | 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 提供的工具好用。