Spring 的速度如何?

工程 | Dave Syer | 2018 年 12 月 12 日 | ...

性能始终是 Spring 工程团队的首要任务之一,我们一直在持续监控和响应变化与反馈。在最近(过去 2-3 年)进行了一些相当深入和精确的工作,本文旨在帮助您找到这些工作的成果,并学习如何度量和改进您自己应用程序的性能。头条新闻是 Spring Boot 2.1 和 Spring 5.1 在启动时间和堆使用方面进行了一些非常不错的优化。这是一张通过度量堆内存受限应用的启动时间绘制的图表。

heap-size-2.1.x

当您压缩可用堆时,应用程序通常在启动时不受影响,直到达到临界点。图表的典型“曲棍球棒”形状显示了垃圾回收失败并且应用程序完全无法启动的点。从图表中我们可以看出,使用 Spring Boot 2.1(但不是 2.0)在 10MB 堆中运行简单的 Netty 应用是完全可能的。与 2.0 相比,2.1 也稍快一些。

此处的大部分细节特指启动时间的测量和优化,但堆内存消耗也非常重要,因为堆大小的限制通常会导致启动速度变慢(参见上面的图表)。我们还可以(并且将会)关注性能的其他方面,特别是在使用注解进行 HTTP 请求映射时;但这些问题需要留待另一篇单独的文章来讨论。

总而言之,请记住 Spring 从一开始就被设计成轻量级的,并且除非您主动要求,否则它实际上做的很少。有许多可选功能,所以您不必使用它们。这里有一些快速的总结要点:

  • 打包:带有应用程序自己的 main 方法的展开式 jar 包总是更快

  • 服务器:Tomcat、Jetty 和 Undertow 之间没有可衡量的差异

  • Netty 在启动时稍微快一点——在大应用程序中您不会注意到

  • 您使用的功能越多,加载的类就越多

  • 函数式 bean 定义提供了渐进式改进

  • 一个带有 HTTP 端点的最小化 Spring Boot 应用可以在 1 秒内启动,并使用 10MB 以下的堆

一些链接

简而言之,如何让我的应用程序运行得更快?

(复制自此处。) 您主要需要放弃一些功能,因此并非所有这些建议都适用于所有应用程序。有些并不那么痛苦,而且在容器中实际上非常自然,例如,如果您正在构建一个 Docker 镜像,最好还是将 jar 包解压并将应用程序类放入不同的文件系统层。

  • 排除 Spring Boot Web Starter 中的类路径

    • Hibernate Validator

    • Jackson(但 Spring Boot Actuators 依赖它)。如果您需要 JSON 渲染,请使用 Gson(仅开箱即用支持 MVC)。

    • Logback:改用 slf4j-jdk14

  • 使用 spring-context-indexer。它不会增加太多东西,但积少成多。

  • 如果可以不使用 Actuators,请不要使用它们。

  • 使用 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 2.2 中有一个新标志 spring.main.lazy-initialization=true(并且此项目中有一个 LazyInitBeanFactoryPostProcessor,您可以复制)。

  • 解压 fat jar 并使用显式的类路径运行。

  • 使用 -noverify 运行 JVM。还可以考虑 -XX:TieredStopAtLevel=1(这将在稍后减慢 JIT 速度,以换取节省的启动时间)。

更极端的选择是使用函数式 bean 定义重写所有应用程序配置。这包括您使用的所有 Spring Boot 自动配置,其中大部分都可以重用,但识别要使用哪些类和注册所有 bean 定义仍然是手动工作。如果您尝试此方法,可能会看到启动时间提高一倍,但并非所有这些都归功于函数式 bean 定义(估算通常在 10% 的启动时间溢价范围内,但可能还有其他好处)。查看micro apps 中的 BuncApplication,了解如何在没有 @Configuration 类处理器的情况下启动 Spring Boot。

排除 netty-transport-native-epoll 也可以将启动时间提高约 30ms(仅限 Linux)。这是自 Spring Boot 2.0 以来的回归,因此一旦我们对其有了更好的了解,我们就可以消除它。

一些基本基准测试

这是来自 https://github.com/dsyer/spring-boot-startup-benchstatic benchmarks 的一个子集。每个应用程序都使用新的 JVM(独立进程)启动,并且具有显式的类路径(非 fat jar)。应用程序始终相同,但具有不同级别的自动(在某些情况下是手动)配置。“分数”是以秒为单位的启动时间,测量从启动 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 的一部分,一直到“集百味”的配置,其中包含类路径上的所有内容,包括 Zuul 和 Sleuth。

pubchart?oid=976086548&format=image

当运行静态基准测试中的“MainBenchmark”和“StripBenchmark”时(上面的表格是旧数据,当时它们都在同一个类中),可以从基准报告中抓取图表数据。README 中有如何执行此操作的说明。

垃圾回收压力

虽然加载的类越多(即功能越多)与启动时间变慢直接相关是真实且可测量的,但其中存在一些细微之处,而最重要且最难以分析的是垃圾回收(GC)。GC 对长时间运行的应用程序可能非常重要,我们都听说过大型应用程序中长时间的 GC 暂停(堆越大,等待时间越长)。自定义 GC 策略是调整长时间运行、特别是大型应用程序的重要工具。启动时还有其他一些正在发生的事情,但这些也可能与 GC 相关,并且 Spring 5.1 和 Spring Boot 2.1 中的许多优化是通过分析这些得到的。

需要关注的主要问题是紧密循环中临时对象的创建和销毁。某些模式的代码是不可避免的,有些是我们无法控制的(例如,在 JDK 本身中),在这种情况下,我们能做的就是尽量不调用它。但是,这些临时对象的堆积会对垃圾回收造成压力并膨胀堆,即使它们本身从未真正进入堆。如果您能捕捉到它发生的情况,通常可以看到额外的 GC 压力效果,表现为堆大小的尖峰。来自 async-profiler 的火焰图是更好的工具,因为它们比大多数分析工具提供更精细的采样,并且因为它们在视觉上非常醒目。

这是我们在基准测试中使用的 HTTP 示例应用程序在 Spring Boot 2.0 和 Spring Boot 2.1 下的火焰图示例

flame_20

flame_21

Spring Boot 2.0

Spring Boot 2.1

Spring Boot 2.1 中右侧的红色/棕色 GC 火焰明显变小。这是由于bean 工厂内部的变化导致的 GC 压力降低的标志。如果您想查看详细信息,Spring Framework 中导致其中一个主要变化的问题在此处:SPR-16918

认识到 GC 压力是一个问题是一回事(async-profiler 是我们发现的最佳工具),但找到其来源则是一门艺术。我们找到的最佳工具是 Flight Recorder(或 Java Mission Control),它是 OpenJDK 发行版的一部分,尽管以前只在 Oracle 发行版中可用。Flight Recorder 的问题在于采样率不够高,无法在启动时捕获足够的数据,因此您必须尝试构建紧密循环来执行您感兴趣或怀疑可能导致问题的事情,并对这些进行更长时间(几秒或更长)的分析。这提供了额外的见解,但没有关于“真实”应用程序是否会从更改热点中受益的真实数据。spring-boot-allocations 项目中的许多代码都属于此类:主方法运行紧密循环,专注于可疑的热点,然后可以使用 Flight Controller 进行分析。

WebFlux 和微应用

我们可能会期望使用 Servlet 容器的应用程序和使用 Spring 5.0 中引入的 Netty 的较新响应式运行时之间存在一些差异。上面的基准数字使用的是 Tomcat。同一仓库的不同子目录中有一些类似的测量。以下是flux benchmarks 的结果。

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):在启动时做很多工作,所以如果您不使用它,请排除它。

  • Actuators (spring-boot-starter-actuator):一组非常有用的功能,因此很难建议完全删除它,但如果您不使用它,请不要将其放在类路径上。

Spring 调整

  • 使用 spring-context-indexer。它是一个即插即用的类路径库,安装非常方便。它仅适用于应用程序自己的 @Component 类,对于除最大规模(数千个 bean)的应用程序之外的大多数应用程序来说,它对启动时间的提升非常小。但它是可衡量的。

  • 如果可以不使用 Actuators,请不要使用它们。

  • 使用 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=trueBeanFactoryPostProcessor

  • Spring Data 现在具有惰性初始化功能(在 Lovelace 或 Spring Boot 2.1 中)。在 Spring Boot 中,您只需设置 spring.data.jpa.repositories.bootstrap-mode=lazy - 对于拥有数百个实体的较大型应用程序,可将启动时间提高十倍以上。

  • 使用函数式 bean 定义代替 @Configuration。稍后将对此进行更详细的介绍。

JVM 调整

有用的命令行调整以提高启动时间

  • -noverify - 几乎无害,影响很大。在低信任环境中可能不允许。

  • -XX:TieredStopAtLevel=1 - 可能会在启动后进一步降低性能,因为它限制了 JVM 在运行时进行优化的能力。您的体验可能有所不同,但它会对启动时间产生可衡量的影响。

  • -Djava.security.egd=file:/dev/./urandom - 实际上已经过时了,但旧版本的 Tomcat 曾经非常需要它。对于使用随机数的现代应用程序(无论是否使用 Tomcat)都可能产生微小影响。

  • -XX:+AlwaysPreTouch - 对启动时间有微小但可能可衡量的影响。

  • 使用显式类路径 - 即,解压 fat jar 并使用 java -cp …​。使用应用程序的原生 main 类。稍后将对此进行更详细的介绍。

类数据共享

Class Data Sharing (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)为蓝色。

pubchart?oid=1689271723&format=image

Java 10 和 11 还有一个名为“Ahead of Time”(AOT)编译的实验性功能,它允许您从 Java 应用程序构建一个原生镜像。这在启动时可能非常快,并且大多数成功转换的应用程序启动速度确实非常快(对于此处基准测试中的小型应用程序,速度提高十倍)。许多“真实生活”应用程序还无法转换。AOT 是使用 Graal VM 实现的,我们稍后将回到这一点。

惰性子系统

我们上面提到了惰性 bean 定义以及 LazyInitBeanFactoryPostProcessor 的通用兴趣。其好处很明显,特别是对于拥有大量您从未使用过的自动配置 bean 的 Spring Boot 应用程序,但其局限性也存在,因为即使您不使用它们,有时它们也需要被创建以满足依赖性。这些限制可以通过另一个更偏向研究主题的想法来解决,那就是将应用程序分解为模块,并在需要时分别初始化每个模块。

要做到这一点,您需要能够精确地识别源代码中的子系统,并以某种方式标记它。Spring Boot 中的 Actuators 就是这样一个子系统的示例,我们可以主要通过自动配置类的包名来识别它。这个项目有一个原型: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 定义

函数式 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 Blog,了解更多新研究、新版本,以及对性能热点和您可以采取的避免它们的调整的专题分析。

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

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

了解更多

获得支持

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件,只需一份简单的订阅。

了解更多

即将举行的活动

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

查看所有