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 启动器中排除类路径

    • Hibernate Validator

    • Jackson(但 Spring Boot 执行器依赖于它)。如果您需要 JSON 渲染,请使用 Gson(仅在开箱即用的 MVC 中有效)。

    • Logback:使用 slf4j-jdk14 代替

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

  • 如果可以避免,请不要使用执行器。

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

  • 如果不需要,请使用 spring.jmx.enabled=false 关闭 JMX(这是 Spring Boot 2.2 中的默认值)

  • 默认情况下使 bean 定义变为延迟加载。Spring Boot 2.2 中有一个新的标志 spring.main.lazy-initialization=true(并且在 此项目 中有一个 LazyInitBeanFactoryPostProcessor 可以复制)。

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

  • 使用 -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(单独的进程)启动,并且具有显式类路径(不是胖 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”示例加上执行器的相同内容

  • Jdbc:“demo”示例加上 JDBC 的相同内容

  • Demo:具有一个端点的普通 Spring Boot MVC 应用程序(没有执行器)

  • 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 策略是一项重要的业务,也是调整长时间运行(尤其是大型)应用程序的重要工具。在启动时,还会发生其他一些事情,但这些事情也可能与垃圾回收相关,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 框架问题 SPR-16918

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

WebFlux 和微型应用程序

我们可能会预期使用 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 的内存中运行(10 堆,36 非堆)。micro jlog 示例在 38MB 中运行(8 堆,30 非堆)。对于这些较小的应用程序,非堆才是真正重要的因素。它们都包含在上面的散点图中,因此它们与启动时间和加载的类之间的一般相关性一致。

类路径排除

您的里程可能会有所不同,但请考虑排除以下内容:

  • Jackson(spring-boot-starter-json):它并不非常昂贵(启动时可能需要 50 毫秒),但 Gson 更快,并且占用空间更小。

  • Logback(spring-boot-starter-logging):仍然是最好、最灵活的日志记录库,但所有这些灵活性都伴随着成本。

  • Hibernate Validator(org.hibernate.validator:hibernate-validator):在启动时会执行大量工作,因此,如果您未使用它,请将其排除。

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

Spring 调整

  • 使用 spring-context-indexer。它可以轻松地放入类路径中,因此非常易于安装。它仅适用于您应用程序自己的 @Component 类,并且实际上仅可能对除最大(数千个 bean)应用程序之外的所有应用程序的启动时间带来非常小的提升。但它是可衡量的。

  • 如果可以避免,请不要使用执行器。

  • 使用 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 即可 - 对于具有数百个实体的大型应用程序,启动时间可提高 10 倍以上。

  • 使用函数式 bean 定义而不是 @Configuration。稍后将详细介绍此内容。

JVM 调整

用于启动时间的实用命令行调整

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

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

  • -Djava.security.egd=file:/dev/./urandom - 现在已经不是什么大问题了,但旧版本的 Tomcat 确实需要它。如果有人使用随机数,则对现代应用程序(无论是否使用 Tomcat)可能会有少量影响。

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

  • 使用显式类路径 - 即展开胖 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 开始,OpenJDK 包含 CDS(但命令行界面不太方便)。这是一个关于加载类数量较少与启动时间关系的散点图,其中红色表示普通的 OpenJDK(无 CDS),蓝色表示 OpenJ9(有 CDS)

pubchart?oid=1689271723&format=image

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

延迟子系统

我们上面提到了延迟 Bean 定义以及 LazyInitBeanFactoryPostProcessor 的普遍意义。其好处显而易见,尤其对于具有大量自动配置 Bean 但从未使用过的 Spring Boot 应用程序,但也存在局限性,因为即使您不使用它们,有时也需要创建它们来满足依赖关系。这些限制可以通过另一个更具研究意义的想法来解决,那就是将应用程序分解成模块,并根据需要分别初始化每个模块。

要做到这一点,您需要能够在源代码中精确识别子系统,并以某种方式对其进行标记。此类子系统的一个示例是 Spring Boot 中的执行器,我们可以主要通过自动配置类的包名称来识别它们。此项目中有一个原型:Lazy 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 核心框架以及 Spring Boot 本身可能仍然存在一些需要优化的方面。请关注 Spring 博客,以获取更多新的研究和新版本,以及对性能热点和您可以避免这些热点的调整的更及时的分析。

获取 Spring 新闻通讯

与 Spring 新闻通讯保持联系

订阅

抢先一步

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

了解更多

获取支持

Tanzu Spring 在一个简单的订阅中提供对 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

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

查看全部