从SPI机制到Spring自动装配,我的项目启动速度翻了一倍
从SPI机制到Spring自动装配,我的项目启动速度翻了一倍
项目大了之后,启动时间从 10 秒变成 60 秒。开发模式下改一行代码要等 30 秒才能热部署,团队怨声载道。JVM 预热、字节码编译、Spring 上下文初始化,每一步都拖慢了速度。
经过一轮优化,把启动时间从 60 秒压到了 28 秒。这里记录一下排查和解决的过程。
启动时间都花在哪了
先用 Spring Boot 的 ApplicationRunner 打印各阶段耗时:
@Component
public class StartupTimeline implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(StartupTimeline.class);
@Override
public void run(ApplicationArguments args) {
SpringApplication application = context.getBean(SpringApplication.class);
// Spring Boot 2.4+ 可以用这个方式获取启动时间
log.info("应用启动完成,总耗时: {} ms",
System.currentTimeMillis() - application.getNow());
}
}
但更准确的是用 Spring Boot Actuator 的 startup 端点:
management:
endpoints:
web:
exposure:
include: startup
endpoint:
startup:
enabled: true
调用 /actuator/startup 会返回一个 JSON,包含各步骤的耗时:
{
"springApplication": {
"phase": "Starting",
"startTime": "2026-02-06T12:31:00.000Z",
"mainApplicationClass": "com.example.Application",
"totalTime": 28000
},
"steps": [
{"name": "Spring Container Initialisation", "duration": 12000},
{"name": "Bean Definition Loading", "duration": 8000},
{"name": "Bean Creation", "duration": 6000},
{"name": "AfterWarmup", "duration": 2000}
]
}
当时的数据显示 Bean Definition Loading 占了 12 秒,Bean 创建 6 秒,这是大头。
问题一:Bean 扫描范围过大
Spring Boot 默认会扫描启动类所在包及其子包下的所有 @Component、@Service、@Repository、@Controller。
项目结构是这样的:
com.example
├── Application.java
├── controller
│ └── UserController.java
├── service
│ └── UserService.java
├── repository
│ └── UserDao.java
└── config
└── RedisConfig.java
这个结构没问题,但如果项目是这种:
com.example
├── Application.java
├── controller
├── service
├── repository
├── model
├── util
├── common
├── thirdparty
└── integration
而 thirdparty 和 integration 包里引用了一堆第三方库,Spring 会尝试扫描并实例化这些 Bean。
检查方式:在启动日志里搜索 "Identified candidate component class",看有多少行。
解决方案是缩小扫描范围:
@SpringBootApplication
@ComponentScan(
basePackages = "com.example",
excludeFilters = @ComponentScan.Filter(
type = FilterType.REGEX,
pattern = "com\\.example\\.(thirdparty|integration|util).*"
)
)
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
或者用更精确的包路径:
@SpringBootApplication
@ComponentScan(
basePackages = {
"com.example.controller",
"com.example.service",
"com.example.config",
"com.example.repository"
},
excludeFilters = @ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = ThirdPartyService.class
)
)
public class Application {}
问题二:Bean 懒加载没利用好
Spring 的 @Lazy 注解可以让 Bean 在第一次使用时才创建,而不是启动时就创建:
@Configuration
public class RedisConfig {
@Bean
@Lazy
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
return template;
}
}
但 @Lazy 有个问题:如果这个 Bean 是另一个非懒加载 Bean 的依赖,那它还是会提前初始化。
更好的方式是 @Lazy(true) 加在 @Autowired 字段上:
@Service
public class UserService {
@Autowired
@Lazy
private ThirdPartyService thirdPartyService;
public void someMethod() {
// 第一次调用时才创建 thirdPartyService
thirdPartyService.call();
}
}
对于那些启动时不用、只有运行时才用的 Bean,都可以加 @Lazy:
- 报表生成器
- 邮件发送服务
- 大数据处理组件
- 不常用的工具类
问题三:SPI 机制加载了不需要的实现
Java SPI(Service Provider Interface)允许在 META-INF/services 目录下配置接口实现。Spring Boot 的自动装配也依赖这个机制。
问题在于,有些 Starter 的 SPI 配置会加载不需要的 Bean。比如 spring-boot-starter-data-redis 会自动加载 RedisAutoConfiguration,但如果你用自研的 Redis 客户端,这个配置就不需要了。
排查方式:看启动日志里的 "Auto-configuration of 30 items" 或类似信息。
关闭不需要的自动配置:
spring:
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
- org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
但要注意,关闭了自动配置后,需要手动引入需要的 Bean。
问题四:大量的 @PostConstruct 初始化
@PostConstruct 在 Bean 创建后立即执行,如果里面有耗时操作,会拖慢启动:
@Component
public class CacheWarmup {
@PostConstruct
public void warmup() {
// 启动时预热缓存,加载所有商品数据
List<Product> products = productDao.findAll();
products.forEach(p -> cache.put(p.getId(), p));
}
}
对于这类初始化任务,应该异步化或延迟化:
@Component
public class CacheWarmup {
@Autowired
private ApplicationContext context;
@PostConstruct
public void warmup() {
// 异步执行,不阻塞启动
CompletableFuture.runAsync(() -> {
List<Product> products = productDao.findAll();
products.forEach(p -> cache.put(p.getId(), p));
});
}
}
或者用 @EventListener(ApplicationReadyEvent.class) 替代 @PostConstruct:
@Component
public class CacheWarmup {
@EventListener(ApplicationReadyEvent.class)
public void warmup() {
// 应用完全启动后再执行
List<Product> products = productDao.findAll();
products.forEach(p -> cache.put(p.getId(), p));
}
}
问题五:数据库连接池初始化
HikariCP 连接池在应用启动时会预先创建 minimumIdle 个连接:
spring:
datasource:
hikari:
minimum-idle: 10 # 减少预创建连接数
maximum-pool-size: 20
connection-test-query: SELECT 1
如果数据库在启动时还没就绪,HikariCP 会重试多次,增加启动时间。可以设置启动时跳过连接测试:
spring:
datasource:
hikari:
initialization-fail-timeout: 0 # 0 表示无限重试
不过这可能影响首次请求的响应时间,需要权衡。
JVM 层面的优化
代码之外,JVM 参数也能加速启动:
JAVA_OPTS="-server \
-Xms2g -Xmx2g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:+UseStringDeduplication \
-XX:+TieredCompilation \
-XX:TieredStopAtLevel=1 \
-XX:ReservedCodeCacheSize=128m \
-XX:+UseCompressedClassPointers"
关键参数:
TieredStopAtLevel=1:只编译到 C1(客户端级别编译),跳过 C2 的深度优化,启动更快ReservedCodeCacheSize=128m:给 JIT 编译留足够空间UseStringDeduplication:减少字符串内存占用
如果项目用 GraalVM Native Image,启动时间可以从几十秒降到几百毫秒。但 Native Image 也有自己的问题:镜像构建时间长,动态特性支持不完整。
启动速度提升效果
优化前后对比:
| 阶段 | 优化前 | 优化后 |
|---|---|---|
| Bean 扫描 | 8s | 2s |
| Bean 创建 | 6s | 4s |
| 数据库初始化 | 15s | 5s |
| 缓存预热 | 20s | 8s |
| 其他 | 11s | 9s |
| 总计 | 60s | 28s |
热部署时间(devtools)从 30s 降到了 12s。
经验总结
启动优化的核心思路:
- 定位瓶颈:用 startup 端点或日志找出时间都花在哪
- 减少扫描:只扫描必要的包,排除第三方库
- 延迟加载:用
@Lazy和@EventListener(ApplicationReadyEvent) - 异步初始化:
@PostConstruct里的耗时操作异步化 - 精简配置:关闭不需要的自动配置
- JVM 调优:C1 编译加速启动
不过要注意,有些优化会牺牲运行时性能。比如 @Lazy 会把初始化成本延迟到第一次调用,可能影响首次请求的响应时间。需要根据业务场景权衡。
