快人一步
VMware 提供培训和认证,助你加速前进。
了解更多性能一直是 Spring 工程团队的首要任务之一,我们持续监控并响应变化和反馈。最近(在过去 2-3 年里)完成了一些相当密集和精准的工作,本文旨在帮助您找到这些工作的结果,并学习如何在自己的应用程序中衡量和改进性能。重点是 Spring Boot 2.1 和 Spring 5.1 在启动时间和堆内存使用方面进行了一些很好的优化。下面是一个衡量堆受限应用程序启动时间的图表
当您压缩可用堆时,应用程序在启动时通常不受影响,直到达到临界点。图表上特有的“曲棍球棒”形状显示了垃圾收集失败,应用程序完全无法启动的点。从图表中我们可以看到,使用 Spring Boot 2.1 在 10MB 堆中运行一个简单的 Netty 应用程序是完全可能的(但使用 2.0 则不能)。此外,2.1 版本也比 2.0 版本稍微快一些。
这里的详细信息主要特指启动时间的衡量和优化,但堆内存消耗也非常重要,因为堆大小的限制通常会导致启动变慢(参见上图)。我们可以(也将会)关注性能的其他方面,特别是例如在使用注解进行 HTTP 请求映射的地方;但这些问题将留待另一篇独立的文章来讨论。
综合考虑所有因素,请记住 Spring 在设计之初就是轻量级的,并且除非您要求它做,否则它实际上很少工作。有很多可选功能,因此您不必使用它们。以下是一些快速摘要要点
打包:一个解压的 jar,使用应用程序自己的 main 方法启动总是更快
服务器:Tomcat、Jetty 和 Undertow 之间没有可衡量的差异
Netty 在启动时稍微快一些 - 在大型应用程序中您不会注意到
您使用的功能越多,加载的类就越多
函数式 Bean 定义提供了增量改进
一个带有 HTTP 端点的最小 Spring Boot 应用程序在 <1 秒内启动,并使用 <10MB 堆内存
一些链接
https://github.com/dsyer/spring-boot-startup-bench - 较旧的基准测试(追溯到 Spring Boot 1.3),包含 fat jar 数据
/static 同一仓库中的静态基准测试 - 更新的,探讨了类加载的相关性
/flux 同一仓库中的 Flux 基准测试 - WebFlux
https://springframework.org.cn/blog/2018/10/22/functional-bean-registrations-in-spring-cloud-function - 关于 Spring Cloud Function 中函数式 Bean 的博客
Spring Fu: https://github.com/spring-projects/spring-fu
https://github.com/dsyer/spring-boot-allocations - 函数式 Bean 和 GC 压力的基准测试
https://github.com/dsyer/spring-boot-micro-apps - 函数式 Bean 和 AOT(代码与“allocations”项目相同,但此处是示例应用程序而非基准测试)
(摘自此处。) 您可能大部分时间都需要放弃一些功能,因此并非所有这些建议都适用于所有应用程序。有些并不那么痛苦,实际上在容器中是很自然的,例如,如果您正在构建 Docker 镜像,无论如何最好解压 jar 文件并将应用程序类放在不同的文件系统层。
从 Spring Boot Web Starter 中排除依赖
Hibernate Validator
Jackson(但 Spring Boot Actuator 依赖它)。如果您需要 JSON 渲染,请使用 Gson(开箱即用只支持 MVC)。
Logback:改用 slf4j-jdk14
使用 spring-context-indexer
。它不会增加太多,但积少成多。
如果可以避免,请不要使用 Actuator。
使用 Spring Boot 2.1 和 Spring 5.1。在 2.2 和 5.2 可用时切换到它们。
使用 spring.config.location
固定 Spring Boot 配置文件的位置(命令行参数或系统属性等)。IDE 中测试示例:spring.config.location=file://./src/main/resources/application.properties
。
如果不需要 JMX,请使用 spring.jmx.enabled=false
关闭它(这是 Spring Boot 2.2 中的默认设置)
默认情况下将 Bean 定义设置为延迟初始化。Spring Boot 中没有直接实现此功能,但在此项目中有一个 LazyInitBeanFactoryPostProcessor
可以复制。它只是一个将所有 Bean 设置为 lazy=true
的 BeanFactoryPostProcessor
。
解压 Fat Jar 并使用明确的 classpath 运行。
使用 -noverify
运行 JVM。也可以考虑 -XX:TieredStopAtLevel=1
(这将以牺牲启动时间为代价,在稍后降低 JIT 的速度)。
一个更极端的选择是使用函数式 Bean 定义重写您的所有应用程序配置。这包括您正在使用的所有 Spring Boot 自动配置,其中大部分可以重用,但识别要使用的类并注册所有 Bean 定义仍然是手动工作。如果您尝试这种方法,您可能会看到启动时间提高 2 倍,但这并非完全归因于函数式 Bean 定义(估计通常功能性 Bean 的启动时间优势在 10% 左右,但可能还有其他好处)。请查看微应用中的 BuncApplication
,了解如何在没有 @Configuration
类处理器的情况下启动 Spring Boot。
排除 netty-transport-native-epoll
也可以将启动时间缩短约 30 毫秒(仅限 Linux)。这是自 Spring Boot 2.0 以来的一个回归问题,因此一旦我们更好地理解它,我们可能会消除它。
这是来自 https://github.com/dsyer/spring-boot-startup-bench 的 静态基准测试 的一个子集。每个应用程序启动时都会启动一个新的 JVM(独立进程),并使用明确的 classpath(而不是 Fat Jar)。应用程序本身始终相同,但具有不同级别的自动(在某些情况下是手动)配置。“Score”表示启动时间(秒),衡量标准是从启动 JVM 到在日志输出中看到标记(此时应用程序已启动并接受 HTTP 连接)的时间。
Benchmark (sample) Mode Cnt Score Error Units Beans Classes
MainBenchmark actr avgt 10 1.316 ± 0.060 s/op 186 5666
MainBenchmark jdbc avgt 10 1.237 ± 0.050 s/op 147 5625
MainBenchmark demo avgt 10 1.056 ± 0.040 s/op 111 5266
MainBenchmark slim avgt 10 1.003 ± 0.011 s/op 105 5208
MainBenchmark thin avgt 10 0.855 ± 0.028 s/op 60 4892
MainBenchmark lite avgt 10 0.694 ± 0.015 s/op 30 4580
MainBenchmark func avgt 10 0.652 ± 0.017 s/op 25 4378
注意
主机是“tower”,i7,3.4GHz,32G RAM,SSD。
Actr:与“demo”示例相同,加上 Actuator
Jdbc:与“demo”示例相同,加上 JDBC
Demo:一个带有单个端点的普通 Spring Boot MVC 应用(无 Actuator)
Slim:相同的功能,但显式地 @Imports
所有配置
Thin:将 @Imports
减少到端点所需的 4 个配置集合
Lite:复制“thin”的导入,并将其转换为硬编码、无条件的配置
Func:从“lite”中提取配置方法,并使用函数式 Bean API 注册其中一部分
一般来说,使用的功能越多,加载的类就越多,并且在 ApplicationContext
中创建的 Bean 也越多。启动时间与加载的类数量之间的相关性实际上非常紧密(比与 Bean 数量的相关性紧密得多)。下面是一个根据这些数据编译并扩展了各种其他内容(如 JPA、部分 Spring Cloud,一直到包含 classpath 上所有东西(包括 Zuul 和 Sleuth)的“大杂烩”)的图表
如果您在静态基准测试中运行“MainBenchmark”和“StripBenchmark”(上表数据是它们在同一类中时的旧数据),可以从基准测试报告中抓取图表数据。README 中有关于如何操作的说明。
虽然加载更多类(即更多功能)与启动时间变慢直接相关是事实且可衡量,但其中存在一些细微之处,其中最重要且最难以分析的是垃圾收集(GC)。垃圾收集对于长时间运行的应用程序可能是一个大问题,我们都听过大型应用程序中长时间 GC 暂停的故事(您的堆越大,您等待的时间可能越长)。定制 GC 策略是一个大生意,也是调整长时间运行、特别是大型应用程序的重要工具。在启动时,还会发生其他一些事情,但这些也可能与垃圾收集有关,Spring 5.1 和 Spring Boot 2.1 中的许多优化就是通过分析这些问题获得的。
需要注意的主要问题是创建和丢弃临时对象的紧密循环。这种模式中的一些代码是不可避免的,一些超出了我们的控制范围(例如,它在 JDK 本身中),在这种情况下我们所能做的就是尽量避免调用它。但是这些大量的临时对象会给垃圾收集带来压力并使堆膨胀,即使它们本身从未真正进入堆。如果您能捕捉到它发生时,通常可以看到额外的 GC 压力表现为堆大小的峰值。async-profiler 的火焰图是一个更好的工具,因为它们允许比大多数分析工具更细粒度的采样,并且它们在视觉上非常引人注目。
下面是我们在进行基准测试的 HTTP 示例应用程序的火焰图示例,分别使用了 Spring Boot 2.0 和 Spring Boot 2.1
Spring Boot 2.0
Spring Boot 2.1
右侧红色/棕色的 GC 火焰在 Spring Boot 2.1 中明显更小。这是 Bean 工厂内部变更导致 GC 压力减小的迹象。其中一个主要变更背后的 Spring Framework 问题在此处:此处,如果您想查看详细信息。
认识到 GC 压力是一个问题是一回事(async-profiler 是我们找到的最好工具),但找到其来源则需要一些技巧。我们为此找到的最好工具是 Flight Recorder(或 Java Mission Control),它是 OpenJDK 发布的一部分,尽管以前只在 Oracle 分发版中提供。Flight Recorder 的问题在于采样率不够高,无法在启动时捕获足够的数据,因此您必须尝试构建执行您感兴趣或怀疑可能导致问题的紧密循环,并在较长时间(几秒钟或更长时间)内分析它们。这会带来额外的见解,但没有关于“真实”应用程序是否会从改变热点中受益的实际数据。spring-boot-allocations 项目中的许多代码都是这种类型:运行紧密循环的主方法,专注于可疑热点,然后可以使用 Flight Controller 进行分析。
我们可能会发现使用 Servlet 容器的应用程序与使用 Spring 5.0 中引入的 Netty 新的响应式运行时的应用程序之间存在一些差异。上面的基准测试数字使用的是 Tomcat。同一仓库的不同子目录中也有一些类似的衡量数据。以下是 Flux 基准测试 的结果
Benchmark (sample) Mode Cnt Score Error Units Classes
MainBenchmark.main demo ss 10 1.081 ± 0.075 s/op 5779
MainBenchmark.main jlog ss 10 0.933 ± 0.065 s/op 4367
MiniBenchmark.boot demo ss 10 0.579 ± 0.041 s/op 4138
MiniBenchmark.boot jlog ss 10 0.486 ± 0.020 s/op 2974
MiniBenchmark.mini demo ss 10 0.538 ± 0.009 s/op 3138
MiniBenchmark.mini jlog ss 10 0.420 ± 0.011 s/op 2351
MiniBenchmark.micro demo ss 10 0.288 ± 0.006 s/op 2112
MiniBenchmark.micro jlog ss 10 0.186 ± 0.006 s/op 1371
所有应用程序都有一个 HTTP 单一端点,就像静态基准测试中的应用程序一样(Tomcat, Servlet)。所有应用程序都比 Tomcat 快一些,但不是很多(可能 10%)。请注意,最快的那个(“micro jlog”)在不到 200 毫秒内就能启动并运行。Spring 在那里实际上没有做太多工作,所有的开销基本上都是为了应用程序所需功能(一个 HTTP 服务器)加载类。
说明
MainBenchmark.main(demo)
是完整的 Boot + Webflux + 自动配置。
boot
示例使用 Spring Boot 但不使用自动配置。
jlog
示例排除了 logback 以及 Hibernate Validator 和 Jackson。
mini
示例不使用 Spring Boot(只使用 @EnableWebFlux
)。
micro
示例也不使用 @EnableWebflux
,只是手动注册路由。
mini jlog 示例运行大约使用 46MB 内存(10MB 堆,36MB 非堆)。micro jlog 示例运行使用 38MB 内存(8MB 堆,30MB 非堆)。对于这些小型应用程序来说,非堆内存才是真正重要的。它们都包含在上面的散点图中,因此它们与启动时间和类加载之间的总体相关性是一致的。
实际效果可能因情况而异,但可以考虑排除以下项
Jackson(spring-boot-starter-json
):它不是非常昂贵(启动时大约 50 毫秒),但 Gson 更快,并且占用空间更小。
Logback(spring-boot-starter-logging
):仍然是最好、最灵活的日志库,但所有这些灵活性都是有代价的。
Hibernate Validator(org.hibernate.validator:hibernate-validator
):在启动时做了很多工作,所以如果您不使用它,请排除它。
Actuator(spring-boot-starter-actuator
):一套非常实用的功能,因此很难推荐完全移除它,但如果您不使用它,请不要将其放在 classpath 中。
使用 spring-context-indexer
。它只需放在 classpath 中即可,非常容易安装。它只作用于您应用程序自己的 @Component
类,并且除了最大的(数千个 Bean)应用程序之外,对启动时间的提升可能非常小。但它是可衡量的。
如果可以避免,请不要使用 Actuator。
使用 Spring Boot 2.1 和 Spring 5.1。两者都有小的但重要的优化,特别是在启动时的垃圾收集压力方面。这使得较新的应用程序能够使用更少的堆内存启动。
使用显式的 spring.config.location
。Spring Boot 会在很多位置查找 application.properties
(或 .yml
)文件,所以如果您确切知道它在哪里,或者运行时可能在哪里,可以节省几个百分点的时间。
关闭 JMX:spring.jmx.enabled=false
。如果您不使用它,就不必支付创建和注册 MBean 的开销。
默认情况下将 Bean 定义设置为延迟初始化。Spring Boot 中没有直接实现此功能,但在此项目中有一个 LazyInitBeanFactoryPostProcessor
可以复制。它只是一个将所有 Bean 设置为 lazy=true
的 BeanFactoryPostProcessor
。
Spring Data 现在有一些延迟初始化功能(在 Lovelace 或 Spring Boot 2.1 中)。在 Spring Boot 中,您只需设置 spring.data.jpa.repositories.bootstrap-mode=lazy
- 对于拥有数百个实体的大型应用程序,这可以将启动时间提高 10 倍以上。
使用函数式 Bean 定义代替 @Configuration
。稍后将详细介绍。
针对启动时间有用的命令行调整
-noverify
- 基本无害,但影响很大。在低信任环境中可能不允许使用。
-XX:TieredStopAtLevel=1
- 可能在启动后降低后期性能,因为它限制了 JVM 在运行时优化自身的能力。实际效果可能因情况而异,但它对启动时间有可衡量的影响。
-Djava.security.egd=file:/dev/./urandom
- 现在已经不太需要了,但较旧版本的 Tomcat 确实需要它。如果有人使用随机数,对使用或不使用 Tomcat 的现代应用程序可能会有一点影响。
-XX:+AlwaysPreTouch
- 对启动时间影响很小,但可能可衡量。
使用明确的 classpath - 即,解压 Fat Jar 并使用 java -cp …
。使用应用程序原生的主类。稍后将详细介绍。
类数据共享(CDS)自版本 7 起一直是 Oracle JDK 的商业版特有功能,但现在也可以在 OpenJ9(IBM JVM 的开源版本)以及自版本 10 起在 OpenJDK 中使用。OpenJ9 长期以来一直支持 CDS,并且在该平台上使用起来非常容易。它最初是为优化内存使用而设计的,而非启动时间,但这两者并非不相关。
您可以像运行普通 OpenJDK JVM 一样运行 OpenJ9,但 CDS 的开启使用不同的命令行标志。在 OpenJ9 中使用它非常方便,因为您只需要 -Xshareclasses
。增加缓存大小(例如 -Xscmx128m
)以及使用 -Xquickstart
提示快速启动可能也是个好主意。如果在基准测试中检测到 OpenJ9 或 IBM JVM,这些标志总是开启的。
使用 OpenJ9 和 CDS 的基准测试结果
Benchmark (sample) Mode Cnt Score Error Units Classes
MainBenchmark.main demo ss 10 0.939 ± 0.027 s/op 5954
MainBenchmark.main jlog ss 10 0.709 ± 0.034 s/op 4536
MiniBenchmark.boot demo ss 10 0.505 ± 0.035 s/op 4314
MiniBenchmark.boot jlog ss 10 0.406 ± 0.085 s/op 3090
MiniBenchmark.mini demo ss 10 0.432 ± 0.019 s/op 3256
MiniBenchmark.mini jlog ss 10 0.340 ± 0.018 s/op 2427
MiniBenchmark.micro demo ss 10 0.204 ± 0.019 s/op 2238
MiniBenchmark.micro jlog ss 10 0.152 ± 0.045 s/op 1436
在某些情况下,这相当令人印象深刻(对于最快的应用程序,比不使用 CDS 快 25%)。OpenJDK 也可以达到类似结果:自 Java 10 起包含了 CDS(命令行界面不太方便)。下面是类加载数量与启动时间关系在较小端部分的散点图,其中常规 OpenJDK(无 CDS)用红色表示,OpenJ9(带 CDS)用蓝色表示
Java 10 和 11 还有一个实验性功能,称为预先编译(AOT),它允许您从 Java 应用程序构建原生镜像。这在启动时可能非常快,并且大多数可以成功转换的应用程序启动确实非常快(对于这里的基准测试中的小型应用程序来说,快了 10 倍)。许多“实际”应用程序尚无法转换。AOT 是使用 Graal VM 实现的,我们稍后会再讨论它。
我们上面提到了延迟 Bean 定义以及 LazyInitBeanFactoryPostProcessor
这个普遍令人感兴趣的想法。其好处是显而易见的,特别是对于包含大量您从不使用的自动配置 Bean 的 Spring Boot 应用程序,但也存在局限性,因为即使您不使用它们,有时它们也需要被创建以满足依赖。这些局限性可能会通过另一个更偏向研究课题的想法来解决,即将应用程序分解为模块并按需单独初始化每个模块。
要做到这一点,您需要能够在源代码中精确识别一个子系统并以某种方式标记它。这种子系统的一个例子是 Spring Boot 中的 Actuator,我们可以主要通过自动配置类的包名来识别它们。此项目有一个原型:Lazy Actuator。您可以将其添加到现有项目中,它会将所有 Actuator 端点转换为延迟 Bean,这些 Bean 只在使用时才会被实例化,在像上面基准测试中经典的单端点 HTTP 示例应用程序这样的微应用中,可以节省大约 40% 的启动时间。例如(对于 Maven)
pom.xml
<dependency>
<groupId>org.springframework.boot.experimental</groupId>
<artifactId>spring-boot-lazy-actuator</artifactId>
<version>1.0.0.BUILD-SNAPSHOT</version>
</dependency>
要使这种模式更加主流,可能需要在核心 Spring 编程模型中进行一些更改,以便在运行时以特殊方式识别和处理子系统。这也会增加应用程序的复杂性,在很多情况下可能不值得这样做——Spring Boot 最好的特性之一是应用程序上下文的简洁性(所有 Bean 都是平等的)。因此,这仍然是一个活跃的研究领域。
函数式 Bean 注册是 Spring 5.0 中新增的功能,通过在 BeanDefinitionBuilder
中添加一些新方法以及在 GenericApplicationContext
中添加一些便捷方法来实现。它允许 Spring 完全非反射地创建组件,通过将 Supplier
附加到 BeanDefinition
,而不是使用 Class
。
编程模型与最流行的 @Configuration
样式有点不同,但它的目标仍然相同:将配置逻辑提取到单独的资源中,并允许逻辑用 Java 实现。如果您有一个像这样的配置类
@Configuration
public class SampleConfiguration {
@Bean
public Foo foo() {
return new Foo();
}
@Bean
public Bar bar(Foo foo) {
return new Bar(foo);
}
}
您可以将其转换为函数式风格,如下所示
public class SampleConfiguration
implements ApplicationContextInitializer<GenericApplicationContext> {
public Foo foo() {
return new Foo();
}
public Bar bar(Foo foo) {
return new Bar(foo);
}
@Override
public void initialize(GenericApplicationContext context) {
context.registerBean(SampleConfiguration.class, () -> this);
context.registerBean(Foo.class,
() -> context.getBean(SampleConfiguration.class).foo());
context.registerBean(Bar.class, () -> context.getBean(SampleConfiguration.class)
.bar(context.getBean(Foo.class)));
}
}
有多种选择可以在何处进行这些 registerBean()
方法调用,但此处我们选择将其包装在 ApplicationContextInitializer
中展示。ApplicationContextInitializer
是一个核心框架接口,但在 Spring Boot 中占有特殊地位,因为 SpringApplication
可以通过其公共 API 或在 META-INF/spring.factories
中声明初始化器来加载。spring.factories
方法可以轻松地让应用程序及其集成测试(使用 @SpringBootTest
)共享相同的配置。
这种编程模型在 Spring Boot 应用中尚未成为主流,但它已在 Spring Cloud Function 中实现,并且也是 Spring Fu 中的一个基本构建块。此外,上面提到的最快的完整 Spring Boot 基准测试应用("bunc")就是以这种方式实现的。其主要原因在于函数式 Bean 注册是 Spring 创建 bean 实例的最快方式——它几乎不需要除了实例化类和本地调用其构造函数之外的任何计算。
注意
其他非函数式类型的 BeanDefinition
将总是比较慢,但这不会阻止我们进一步优化,并且随着 Spring 的发展,差距几乎肯定会缩小。
现有库和应用中的函数式 Bean 实现必须手动复制 Spring Boot 中的相当多代码,并将其转换为函数式风格。对于小型应用这可能是可行的,但你使用的 Spring Boot 功能越多,就越不方便。认识到这一点,我们已经开始致力于开发各种工具,这些工具可用于自动将 @Configuration
转换为 ApplicationContextInitializer
代码。你可以在运行时使用反射来完成,这竟然出奇地快(证明并非所有反射都不好),或者你也可以在编译时完成,这有望在启动时间方面达到最优,但技术上实现起来稍微困难一些。
无论未来如何,我认为我们可以确定 Spring 将尽可能保持轻量级,并在启动时间、内存使用量以及运行时 CPU 使用率方面继续提高性能。目前最有希望的攻坚方向是函数式 Bean 注册,以及可能从 @Configuration
生成这些注册的自动化方式,此外还有我们正在与 Oracle 的 Graal 团队合作,使 GraalVM 更普遍适用于 Spring Boot 应用。核心框架以及 Spring Boot 可能仍有优化空间。请留意 Spring 博客,获取更多新研究和新版本,以及更多关于性能热点和你可以进行调整以避免它们的专题分析。