从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

thirdpartyintegration 包里引用了一堆第三方库,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 扫描8s2s
Bean 创建6s4s
数据库初始化15s5s
缓存预热20s8s
其他11s9s
总计60s28s

热部署时间(devtools)从 30s 降到了 12s。

经验总结

启动优化的核心思路:

  1. 定位瓶颈:用 startup 端点或日志找出时间都花在哪
  2. 减少扫描:只扫描必要的包,排除第三方库
  3. 延迟加载:用 @Lazy@EventListener(ApplicationReadyEvent)
  4. 异步初始化@PostConstruct 里的耗时操作异步化
  5. 精简配置:关闭不需要的自动配置
  6. JVM 调优:C1 编译加速启动

不过要注意,有些优化会牺牲运行时性能。比如 @Lazy 会把初始化成本延迟到第一次调用,可能影响首次请求的响应时间。需要根据业务场景权衡。

最后更新 4/20/2026, 6:02:32 AM