搞 Spring Boot 服务的人应该都有过这种体验:代码改完本地一点重启,眼睁睁看着控制台里那个 Spring 的日志一行一行往下刷,5 秒、8 秒甚至十几秒才能起来。如果是本地开发还能忍,放到生产环境的滚动发布里,每一次重启都意味着一段时间不可服务的窗口,尤其多实例同时滚动时,机器越多,窗口越长。我前一阵正好接手一个内部服务,启动时间稳定在 5 秒左右,版本发布后要等很久才敢摘流量,折腾了几轮优化后压到了 1 秒出头。这篇就把从 5s 到 1s 的完整链路拆开讲讲,覆盖测量方式、组件扫描、Bean 初始化、JVM 参数和依赖瘦身这几个层面。如果你刚好也在为 Spring Boot 启动时间发愁,或者想系统了解一下性能优化该从哪里下手,这篇文章应该对你有用。
1. 动手之前的准备工作:把“5 秒”拆成可量化的阶段
很多人一提到启动慢,第一反应就是搜“Spring Boot 启动优化”,然后照着网上的帖子改参数。我能理解这种心情,但有个现实问题:不同应用的启动耗时构成完全不同,有的是 Bean 创建慢,有的是自动配置加载了一大堆用不上的模块,有的是类路径扫描太重,还有的干脆是因为代码里有人在初始化阶段做了网络调用。不先量清楚,改什么都是抓瞎。
1.1 启动时间到底消耗在哪儿
一次 Spring Boot 启动大致可以分成三段:JVM 启动与类加载阶段、Spring 容器刷新阶段、应用就绪阶段。
第一段从java -jar开始到 SpringApplication 真正接管,涉及 JVM 自身初始化、主类加载、读取 jar 包元数据等;第二段是核心,包括配置加载、组件扫描、Bean 定义注册、Bean 实例化与依赖注入;第三段是ApplicationRunner、CommandLineRunner、ApplicationReadyEvent等收尾逻辑。理想情况下后两段也应该是轻量的,但很多项目恰恰是在这里拖了后腿。
我最开始接手那个服务时,凭感觉觉得是 Bean 实例化慢,但实测数据出来后发现,组件扫描阶段的高耗时组件竟然排在前面,这完全推翻了我最初的想法。所以不要凭感觉分配精力,先用数据定位,再动手改,这是整个优化过程中最值得先做的一件事。
1.2 一套能直接落地的测量方案
Spring Boot 2.4 之后内置了启动阶段报告能力,基于ApplicationStartup机制。我比较推荐用一个不太复杂的组合:在启动时设置BufferingApplicationStartup,然后在ApplicationStartedEvent里把记录的步骤信息按耗时排序输出。
先给 SpringApplication 挂上启动追踪:
public static void main(String[] args) { SpringApplication app = new SpringApplication(DemoApplication.class); app.setApplicationStartup(new BufferingApplicationStartup(4096)); app.run(args); }然后再写一个监听器,等容器启动完成之后把数据打出来:
@Component public class StartupReportListener implements ApplicationListener<ApplicationStartedEvent> { @Override public void onApplicationEvent(ApplicationStartedEvent event) { if (!(event.getApplicationContext().getApplicationStartup() instanceof BufferingApplicationStartup startup)) { return; } startup.getSteps() .filter(step -> step.getDuration() > 0) .sorted(Comparator.comparingLong(StartupStep::getDuration).reversed()) .limit(20) .forEach(step -> System.out.printf("%8d ms %s%n", step.getDuration(), step.getName())); } }除了这个,也可以在main方法里手动给几个关键节点打点,记录 JVM 启动、容器刷新、监听器执行等各自耗时。如果还想看类加载情况,-verbose:class直接输出每一类加载来源与耗时,或者用 JFR 录制启动过程,这些都能用。
提示:Buffering 的容量不要开太小,否则步骤记录会被截断。4096 一般够用,如果项目里 Bean 特别多再调大一些。
1.3 把报告数据映射到优化点
拿到启动报告后,我会按几类去看。耗时排在前面的几步名称通常长这样:spring.context.components-scan、spring.beans.instantiate、spring.boot.autoconfigure、spring.jpa.entity-manager-factory。每一类对应不同优化方向:扫描阶段的耗时去查组件扫描范围,Bean 实例化阶段的耗时去看具体是哪些 Bean 创建时做了重活,自动配置相关的耗时则要考虑排除无用配置。
这里有个容易被忽略的经验:不要只看总耗时最高的步骤,还要看步骤之间的依赖关系。比如某个 Bean 实例化耗时特别长,它背后可能只是构造函数里做了一次 DNS 解析或远距离连接超时等待。把这些信息抄下来,和后面每轮优化对照,你就知道每一刀砍在了哪里。
2. 组件扫描和自动配置:先干掉那些根本不需要的类
启动报告里如果你看到spring.context.components-scan这一项耗时很突出,那基本可以断定问题出在组件扫描范围上。这也是很多项目启动慢的第一大元凶,而且它往往是无意识积累下来的。
2.1 默认扫描范围带来的隐患
@SpringBootApplication默认扫描启动类所在包及其子包。看起来人畜无害,可一旦项目结构变成“一个大应用包下面挂着所有业务模块”,启动类被放在最顶层包,那么扫描范围就会蔓延到所有子包。
我见过最夸张的情况是,一个项目把外部依赖的某些类也无意中放进扫描范围,启动阶段扫了上万个候选类,实际注册成 Bean 的不到三分之一。扫描本身有成本,候选类的注解解析、元数据读取都要花时间,扫得越多,启动就越慢。
另一个更隐蔽的问题是,如果某些第三方包里的类加了@Component之类注解,而它们恰好落在扫描路径内,Spring 会尝试把它们也注册成 Bean,轻则多创建几个无用的对象,重则因为依赖缺失直接启动失败。
2.2 精确控制扫描路径
优化手法很简单:不要让启动类躺在顶层包里,把业务代码按模块收拢,然后显式声明扫描范围。
@SpringBootApplication( scanBasePackages = "com.example.business", exclude = { RedisAutoConfiguration.class, RabbitAutoConfiguration.class, ElasticsearchRestClientAutoConfiguration.class } ) public class DemoApplication { // ... }scanBasePackages可以只扫你需要的那几个包,别图省事扫一个巨大的根包。对中小型项目来说,这样做立竿见影,扫描的候选类数量往往能降一个数量级。
还有一个小技巧:如果项目中确实有某些组件不在主包内,比如独立的 SDK 包,可以用@ComponentScan的includeFilters精确引入,而不是靠扩大路径来覆盖。
2.3 排除无用的自动配置
Spring Boot 的自动配置非常聪明,但也很“贪心”。只要 classpath 里存在某个依赖,对应的自动配置就会尝试生效。打个比方,你只是项目中引了一个 Redis 客户端 jar,启动时 RedisAutoConfiguration 可能就会初始化一堆连接工厂和模板对象。如果你压根没打算在这个服务里用 Redis,这些配置就完全是浪费。
通过前面启动报告里spring.boot.autoconfigure相关耗时,我可以快速定位到哪些自动配置在作妖。然后逐一确认:这个服务真的需要 Redis 吗?真的需要 MQ 吗?真的需要完整的 JPA 吗?不需要就排除掉。
排除方式可以在注解里写,也可以在配置文件里集中管理:
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration,org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAutoConfiguration每次排除前要确认功能确实不依赖它,建议小步验证,排除一个就重启一次测试。别一次性排除太多,出了问题很难定位。
2.4 从依赖层面做一次“断舍离”
有一种情况比较特殊:自动配置不是因为扫描路径出问题,而是因为 pom 里引入了大量 starter。一个简单的 HTTP 微服务,可能为了某个工具方法引入了一大堆xxx-spring-boot-starter,每个 starter 都会带入自己的自动配置和元数据扫描。
我处理某项目时用mvn dependency:tree看了一遍依赖树,发现至少四个 starter 只在很少的几个类里用到,对应的功能完全可以自己写几十行代码替代。删掉之后,不光启动干净了,整个 jar 包都瘦了一圈。
依赖瘦身属于长期收益,不是一次性的。每次引入新依赖之前多想一秒:这个库的价值是否值得它带来的启动成本?在生产环境里,一个几十 KB 的工具库和一个几十 MB 的 starter,差的从来不只是磁盘空间。
3. Bean 创建链路:把串行初始化里的等待黑洞挖出来
扫描和自动配置处理完之后,下一步就是看 Bean 实例化本身。Spring 默认创建 Bean 时按依赖关系和注册顺序来,虽然容器内部做了很多优化,但依赖链上只要有一个 Bean 创建得慢,后面跟着的都在干等。
3.1 把耗时 Bean 从启动路径上摘掉
最直接的优化是懒加载:让那些不急着用的 Bean 不要参与启动阶段的初始化。用法就是在类或@Bean方法上标注@Lazy。
@Bean @Lazy public ReportService reportService() { return new ReportService(remoteClient); }@Lazy的原理是用代理对象占位,真正的实例化延迟到第一次被调用时才发生。这样做的好处很明显:启动路径变短了,真正用到的时候才付出成本。代价也很明确:第一个调用方可能需要等待初始化完成,如果那块逻辑本身就重,延迟感会转移到调用侧。
还有一点要特别注意:@Lazy不是万能的。有些基础设施类,比如数据源、连接池、核心缓存客户端,如果只是简单粗暴地加懒加载,可能导致服务刚启动时一切正常,但第一个真实请求进来时直接超时。所以我通常只把“重但非关键”的 Bean 懒加载,比如报表服务、定时任务执行器、一些管理端组件,基础设施类还是要谨慎。
3.2 更彻底的姿势:把初始化动作挪到应用就绪之后
有些 Bean 创建慢,不是因为构建本身复杂,而是有人在构造函数或@PostConstruct里做了耗时操作。典型例子:初始化时远程调用、初始化时检查端口连通性、启动时加载大文件做解析。这些操作放在启动路径上,等于让用户白白等一个与业务无关的前置条件。
我处理过的一个案例:某个配置中心的客户端 Bean 在@PostConstruct中拉取配置并解析,正常网络下要 800ms 左右,一旦网络抖动直接卡到超时。改动很简单,把拉取动作挪到ApplicationReadyEvent触发后再异步执行,启动阶段只注册占位对象。
@Component public class ConfigCenterBootstrap { @EventListener(ApplicationReadyEvent.class) public void onReady() { executor.submit(this::loadRemoteConfig); } }这样改完之后,启动时间立刻少了近一秒钟,而且配置加载在后台完成后,应用在执行真正的业务之前就已经把配置准备好了,影响很小。核心原则是:启动阶段只做“必需且快速”的初始化,其余全部后置。
3.3 数据源与 ORM 里的常见坑
如果你项目里用了数据库和 JPA,数据源初始化往往本身就是重头戏。HikariCP 默认不会一启动就建满连接池,但很多项目会在代码里加一段“预检”逻辑:启动时主动getConnection()验证连通性,没问题再继续。这个动作在网络正常时要跑几十毫秒,网络不好时可能直接卡住启动。
第二个常见坑是 JPA 的ddl-auto=update或create。每次启动都会做实体元数据扫描与表结构比对,实体多的时候这个动作耗时非常可观。生产环境建议直接用 Flyway 或 Liquibase 管理结构变更,把启动时的检查逻辑收走。
还有一个容易被忽略的点:日志级别。DEBUG 日志在启动阶段一旦打开,日志输出本身就会拖慢容器初始化。生产环境务必用 INFO 或 WARN,这不仅能减少启动耗时,也能避免启动过程中的大量 I/O 对后续性能造成波动。
4. JVM 层面的两板斧:堆参数与类预加载
应用层优化做完之后,如果启动时间还没到预期值,就需要往 JVM 层面看。这块经常被忽略,但收益非常稳定。
4.1 堆参数:减少扩容与 GC 抖动
默认情况下 JVM 的堆是动态扩展的,从较小的初始堆逐步扩到最大堆。Spring Boot 应用启动过程中会创建大量临时对象,堆频繁扩容自然就会伴随 GC 开销。最简单的做法是把初始堆和最大堆设成相同大小,避免扩容。
java -Xms512m -Xmx512m -jar my-service.jar具体设多大取决于服务的实际运行负载。如果平时运行稳定在 1G 以内,那就直接-Xms1g -Xmx1g。这样启动阶段不会因为堆扩展产生额外停顿,虽然绝对值不算大,但配合其他优化能明显感受到启动过程更平稳。
GC 算法的选择也要说一句。JDK 17 默认用 G1,通常不需要额外指定。如果你的应用还是 JDK 8,建议显式加上-XX:+UseG1GC,在老版本里默认的 CMS 在启动阶段的表现并不理想。不过 GC 优化要谨慎,强制指定某个算法之前先做压测,别看着网上教程照抄。
4.2 类数据共享(CDS):把类加载的时间提前花掉
AppCDS 是 Java 自带的能力,思想很简单:把应用要用的类提前记录到一个归档文件中,下次启动时 JVM 直接加载归档,省掉类解析和验证的时间。Spring Boot 应用恰好类数量巨大,特别适合这条路子。
JDK 17 下最简单的用法分两步。第一次先把类归档生成出来:
java -XX:ArchiveClassesAtExit=my-app.jsa -jar my-service.jar第二次启动时加载归档:
java -XX:SharedArchiveFile=my-app.jsa -jar my-service.jar实测下来,对于类加载占比高的应用,这一步能稳定省掉几百毫秒到一秒不等。需要注意的问题有两个:一是 JDK 版本和 JVM 参数变化会导致归档失效,需要重新生成;二是容器化部署时,归档文件建议作为镜像的一部分打进去,否则每次启动都要在运行时重新生成,效果就大打折扣了。
Spring Boot 3.x 配合 CDS 更顺滑,因为它原生支持 AOT 产出的类清单。但即使你在用 Spring Boot 2.x,AppCDS 依然可以用,只是需要自己多做一步生成和校验。
4.3 JIT 分层编译的取舍
还有个参数在网上讨论很多:-XX:TieredStopAtLevel=1。作用是让 JVM 只使用 C1 编译器,跳过 C2 的深层优化,从而缩短启动时间。临时环境或短生命周期的一次性任务可以开,但生产环境长期跑的服务不建议开,因为 C2 才是把热点代码优化到极致的关键,关掉后峰值吞吐会掉一截。
如果确实想开,建议在分阶段发布场景中用,比如短时批量任务跑完就退出,启动快比峰值性能更有价值。对长时间稳定运行的服务,我的经验是别碰它,省下的几百毫秒和可能损失的性能相比并不划算。
5. 把各项优化组合起来:一套值得照抄的操作清单
前面的内容讲了很多局部优化,但实际效果要看整体组合。如果你照着启动报告的指引把自己的瓶颈点都修完了,最后还可以按下面这套顺序重新过一遍,确保没有遗漏。
5.1 一份可复制的优化执行清单
我习惯按风险和收益分四轮操作,每轮改完都重启验证:
| 轮次 | 优化项 | 具体操作 | 收益预期 | 风险 |
|---|---|---|---|---|
| 第一轮 | 收敛组件扫描 | 显式指定 scanBasePackages,收窄扫描范围 | 中等 | 低 |
| 第一轮 | 排除自动配置 | 逐个排除不用的 starter 配置 | 小到中等 | 中低 |
| 第二轮 | 懒加载与后置初始化 | 关键非基础 Bean 加 @Lazy,耗时操作移到就绪事件 | 大 | 中 |
| 第三轮 | JVM 堆参数 | 初始堆和最大堆设同值,关闭不必要的 JVM 诊断 | 小 | 低 |
| 第四轮 | 依赖瘦身 | 用依赖树删掉冗余 starter | 中到较大 | 中 |
我那个服务从 5s 压到 1s 出头,不是某一项单独做到的。组件扫描收敛贡献了约 1s,去掉无用自动配置又省了 0.6s 左右,把数据源预检和配置中心拉取后置省了接近 1s,最后 CDS 和堆参数合起来贡献了剩下的时间。每一轮都不算夸张,叠起来就是从 5s 到 1s 的量变。
5.2 复测方法:同一套测量脚本跑到底
优化过程最怕“感觉快了”,所以我坚持用同一套方式测量。每次改动后,都用最开始那段BufferingApplicationStartup方案记录数据,并对比同类步骤的耗时变化。如果一个调整让某个步骤从 500ms 降到 20ms,那说明改对了;如果某个步骤没变化,那继续看下一步。
还有一个细节:测量时尽量用完全一样的启动命令、环境变量和网络条件,否则外部波动会被误认为是优化效果。我一般会在本地连续启动五次取中位数,再和生产环境数据做对照。避免一次启动耗时偶然偏低或偏高那种情况出现。
5.3 关于更极端的方案:AOT 与类镜像
如果有人追求 1s 以内的启动时间,Spring Boot 3.x 的 AOT 处理配合 GraalVM 原生镜像确实能做到。但我要提醒一句:这是一条完全不同的路。AOT 意味着构建期做大量分析与裁剪,反射、动态代理、资源加载等行为都需要显式配置,对很多应用来说,改造成本远高于启动优化本身。如果项目的确需要秒级以下的极端启动,它值得评估;但大多数常规服务把前面几轮做完,启动时间到 1s 上下,已经完全够用了。
我在这次优化中的体会是:启动时间优化更像是一次“清理”,把项目在演进过程中无意识积累的冗余一层层剥掉。真正有价值的不只是最终那 1s,而是借助启动报告把项目结构、依赖关系、初始化行为都重新梳理了一遍。建议你拿到自己的项目后,先做测量再动手,每一步都留好验证数据。这样优化过程可控,效果也可量化,后续再有新人加入时,还能把这些记录作为性能维护的参照。