Spring Boot 启动慢怎么优化?我在真实项目上试了 6 种方案,只有 2 种值得上

🔑 关键词:Spring Boot启动优化, Spring Boot启动慢, BufferingApplicationStartup, GraalVM Native Image, lazy-initialization

📖 摘要:在一个真实项目上实测 6 种 Spring Boot 启动优化手段,附 BufferingApplicationStartup 排查方法、lazy-initialization 的隐藏代价,以及 GraalVM 到底值不值得上的判断标准。

上周 review 一个同事的 PR,他在 application-prod.yml 里加了一行 spring.main.lazy-initialization: true,commit message 写的是「启动提速 40%」。我问他怎么测的,他说本地 run 了一下,「感觉快了不少」。

图片

「感觉」这个词在性能问题上是最贵的。我把他拦下来,花半天时间在一个真实项目上把能想到的启动优化手段全跑了一遍。下面是那半天的记录,每种方案我都标了收益和代价,不吹不黑。

测试环境先说清楚,免得有人说数据不可复现:MacBook Pro 14(M1 Pro / 16GB / macOS 14.4),Temurin JDK 17.0.10,Spring Boot 3.2.5,一个 8 模块的 Maven 项目。业务栈是 MyBatis-Plus 3.5.5 + spring-boot-starter-data-redis + RabbitMQ + Spring Security + Flyway。启动后 getBeanDefinitionCount() 是 348,@Configuration 类 41 个。每次都用 java -jar 冷启动,跑 5 次取中位数,基线用 ApplicationReadyEvent 的时间戳减去 main 第一行的时间,4.21 秒。

六种方案跑出来的数

方案 启动耗时 变化 代价
基线 4.21s
spring.main.lazy-initialization=true 3.08s -27% 首次接口 +180~400ms
AppCDS 动态归档 3.62s -14% CI 加一步,JDK 升级要重建
-XX:TieredStopAtLevel=1 3.85s -9% 长跑吞吐下降,别上生产
spring-context-indexer 4.19s ≈0 无代价,因为没收益
GraalVM Native Image 0.09s -98% 构建 7 分 40 秒 + 反射配置
修 component scan 路径 3.31s -21% 一次性,且无副作用

最后一行不在原始计划里,是查 startup step 的时候顺手发现的,后面细说。

图片

先别动手优化,先看清楚时间花在哪

大多数人的优化顺序是错的:先在网上搜「Spring Boot 启动优化」,然后从第一条开始试。正确的顺序是先测量。Spring Boot 2.4 之后有官方的测量工具,很多人不知道。

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(DemoApplication.class);
        app.setApplicationStartup(new BufferingApplicationStartup(4096));
        app.run(args);
    }
}

配合 actuator:

图片

management:
  endpoint:
    startup:
      enabled: true
  endpoints:
    web:
      exposure:
        include: startup

启动后 GET /actuator/startup 能拿到一棵完整的 step 树。这里有个坑:BufferingApplicationStartup 的 capacity 是环形丢弃的,超过容量就丢最老的 step,不报错、不警告,数据静默不全。我一开始随手写了 256,结果 spring.beans.instantiate 那棵树根本没铺满,看了半天还在纳闷怎么只有 200 个 bean 的记录。老老实实给 4096 就行。

拿到完整数据之后,我的项目里 spring.beans.instantiate 总共占 2.9 秒,但重点从来不是 348 个 bean 的平均值,而是排名前几的那几个:flywayInitializer 1.08 秒(47 个 migration 脚本)、sqlSessionFactory 0.41 秒,还有散落在各个 @PostConstruct 里的 Redis 预热。这些都是真实业务成本,砍不掉。

真正让我意外的是 contextScan 那一步。它的耗时排进前三,我点开看扫到的类,发现有 300 多个类来自一个被 build-helper-maven-plugin 误加进 source root 的 generated-sources 目录。这些东西既不是 bean 也没被引用,但每一次启动都在被 ASM 逐字节读一遍。把路径修掉,4.21s 直接掉到 3.31s。这条优化的 ROI 比后面五个方案加起来都高,而且一点副作用都没有。

lazy-initialization 的 27% 是借来的,不是省出来的

图片

那 1.13 秒是怎么来的?简单说,它没有让任何工作变快,只是把「创建 bean」这件事从启动阶段挪到了第一次被用到的时候。Spring 依然要解析全部 @Configuration、跑全部 @ConditionalOnClass@ConditionalOnMissingBean 的条件评估,因为容器必须知道有哪些 bean 定义——这部分一秒都省不下来。

借来的时间总是要还的,而且往往还得更难看。我的项目里叠加了 lazy-init 之后,读接口的 p99 从 42ms 涨到 218ms,写接口因为串了几个 service 初始化,第一次直接干到 400ms 往上。如果你的服务挂在 K8s 后面,readinessProbe 探活成功之后流量立刻打进来,用户是能感知到这几百毫秒的。启动慢 1 秒是运维的事,第一个请求超时是用户的事,后者严重得多。

还有个更阴的坑:lazy-init 和 @Scheduled 组合。定时任务第一次触发时才会去实例化目标 bean 和它依赖的一整条链。如果链上某个组件初始化失败,你看到的异常堆栈是在 scheduler 线程里抛出来的,长得非常像业务异常,排查起来能把人绕晕。注意 ApplicationRunnerCommandLineRunnerApplicationReadyEvent 的监听器依然是 eager 的,这一点和很多人的直觉相反。

所以那 27% 不是收益,是一张信用卡,账单会在第一个用户请求上结算。

图片

GraalVM 这笔账,得看你到底在解什么问题

Native Image 那 0.09 秒是真的,我 mvn -Pnative native:compile 跑完之后自己都愣了一下。但同一台机器上构建时间是 7 分 40 秒,而且这还只是第一次比较顺的情况。

真正贵的不是构建时间,是反射。Spring 的 AOT 引擎能自动处理框架自身和一部分常见库的反射需求,但你项目里只要有一个用 Class.forName 拼字符串拼出来的类名、一个运行时动态代理、一个把 JSON 反序列化成泛型集合的地方,都要靠 reflect-config.json 手动补。MyBatis-Plus 这类重度依赖反射的库,配置量不小。最要命的是这些问题大多在编译期不报错,跑起来才炸,测试覆盖不到的分支就等着上线。

我的判断标准很粗暴:如果你的启动时间痛点是「Lambda 冷启动必须压到 1 秒以内」或者「要做一个命令行小工具」,那 GraalVM 值得,那些反射配置是必要成本。如果一个跑了三年的单体应用只是想让 K8s 滚动发布快 4 秒——那这笔投入产出比是负的,而且后面每一个新引入的第三方库都会持续向你收这笔税。

中间那几个方案也说两句。AppCDS 的动态归档只需要两条命令,java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar 生成,java -XX:SharedArchiveFile=app.jsa -jar app.jar 使用,用 -Xlog:cds 可以确认归档到底有没有真正加载。它有个静默失效的坑:换了 JDK 小版本之后归档不匹配,JVM 会直接回退到普通模式,只在日志里留一句警告,监控不严的话你根本不知道优化已经没了。-XX:TieredStopAtLevel=1 关掉 C2 编译器能省 9%,但服务跑几分钟之后吞吐会明显下降,这参数本来就是给短命进程设计的,别顺手抄到生产配置里。至于 spring-context-indexer,它在 Spring Boot 2.x 时代还有点用,到了 3.x 的 AOT 体系下基本已经是历史遗迹了,我这边跑出来 4.19s 对 4.21s,在噪声范围内。

图片

我最后留了什么

上线的是两条:修 component scan 路径,加 AppCDS。前者是净收益,后者是零风险,CI 里多加一行脚本的事。lazy-initialization 从生产配置里删掉了,只在本地开发 profile 保留——本地重启频繁,那 180ms 的首请求延迟无所谓。

还有个想法可能不太受欢迎:对绝大多数业务系统来说,「启动时间」本身就是一个伪指标。你的服务在 K8s 里滚动更新,replicas 是 6,maxSurge 是 2,启动从 4 秒变成 8 秒,整个发布过程多花十几秒,没人会注意到。真正该盯的是 HPA 从 2 个 pod 扩到 10 个 pod 要多久——那个数字直接决定了流量高峰时你会不会被打挂,而它和单个实例的启动时间是乘数关系,不是加法关系。

当然,如果团队成员都觉得启动慢,那可能问题不在 Spring,在于本地每次改代码都要重启整个上下文。那个用 devtools 的热重载就够了,一分钟能配好,比折腾一周 GraalVM 划算得多。

🏷️ 标签: