Backend Development 19 min read

Optimizing SpringBoot Startup Time: Reducing Bean Scanning Overhead and Monitoring Bean Initialization

This article explains how to diagnose and dramatically reduce SpringBoot startup latency by analyzing SpringApplicationRunListener and BeanPostProcessor phases, limiting component scanning paths, using JavaConfig for explicit bean registration, monitoring bean initialization times, and handling auto-configuration pitfalls such as cache manager duplication.

Top Architecture Tech Stack
Top Architecture Tech Stack
Top Architecture Tech Stack
Optimizing SpringBoot Startup Time: Reducing Bean Scanning Overhead and Monitoring Bean Initialization

Background: In a large SpringBoot project the service startup time was extremely slow, often taking 6‑7 minutes before the port was exposed, severely hurting developer productivity.

Investigation using the SpringApplicationRunListener and BeanPostProcessor mechanisms revealed that the bean‑scanning and bean‑injection stages were the main performance bottlenecks.

By registering beans via JavaConfig and tweaking SpringBoot’s auto‑configuration, the local startup time was reduced from about 7 minutes to roughly 40 seconds.

Observe the SpringApplication.run method using the SpringApplicationRunListener interface.

Monitor bean injection time using a custom BeanPostProcessor .

Observing SpringBoot run method

The project uses an internal micro‑service component XxBoot whose startup flow mirrors SpringBoot, consisting of ApplicationContext construction and run invocation.

public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

public static ConfigurableApplicationContext run(Class
[] primarySources, String[] args) {
    return new SpringApplication(primarySources).run(args);
}

The run method delegates to SpringApplicationRunListeners , which triggers the various lifecycle callbacks defined in SpringApplicationRunListener (starting, environmentPrepared, contextPrepared, contextLoaded, started, running, failed).

public interface SpringApplicationRunListener {
    void starting();
    void environmentPrepared(ConfigurableEnvironment environment);
    void contextPrepared(ConfigurableApplicationContext context);
    void contextLoaded(ConfigurableApplicationContext context);
    void started(ConfigurableApplicationContext context);
    void running(ConfigurableApplicationContext context);
    void failed(ConfigurableApplicationContext context, Throwable exception);
}

By adding a custom implementation of this interface and registering it in META-INF/spring.factories , timestamps can be logged at each stage to pinpoint the longest phases.

@Slf4j
public class MySpringApplicationRunListener implements SpringApplicationRunListener {
    public MySpringApplicationRunListener(SpringApplication sa, String[] args) {}
    @Override public void starting() { log.info("starting {}", LocalDateTime.now()); }
    @Override public void environmentPrepared(ConfigurableEnvironment env) { log.info("environmentPrepared {}", LocalDateTime.now()); }
    @Override public void contextPrepared(ConfigurableApplicationContext ctx) { log.info("contextPrepared {}", LocalDateTime.now()); }
    @Override public void contextLoaded(ConfigurableApplicationContext ctx) { log.info("contextLoaded {}", LocalDateTime.now()); }
    @Override public void started(ConfigurableApplicationContext ctx) { log.info("started {}", LocalDateTime.now()); }
    @Override public void running(ConfigurableApplicationContext ctx) { log.info("running {}", LocalDateTime.now()); }
    @Override public void failed(ConfigurableApplicationContext ctx, Throwable ex) { log.info("failed {}", LocalDateTime.now()); }
}

Log analysis showed that most of the delay occurred between contextLoaded and started , specifically inside refreshContext and afterRefresh , which ultimately call AbstractApplicationContext#refresh .

Further debugging identified the ConfigurationClassPostProcessor as the major culprit, especially its handling of @ComponentScan on the main configuration class. Scanning large third‑party packages (e.g., UPM services) caused massive classpath traversal, many of which did not produce beans.

Key findings:

Large third‑party dependencies bring huge JARs that are fully scanned.

Scanning every class, even non‑bean classes, adds linear overhead per added dependency.

The @ComponentScan basePackages attribute on @SpringBootApplication drives most of the cost.

Monitoring Bean Initialization Time

To detect slow‑initializing beans, a custom BeanPostProcessor records the start time in postProcessBeforeInitialization and computes the elapsed time in postProcessAfterInitialization .

public class TimeCostBeanPostProcessor implements BeanPostProcessor {
    private Map
costMap = Maps.newConcurrentMap();
    @Override public Object postProcessBeforeInitialization(Object bean, String beanName) {
        costMap.put(beanName, System.currentTimeMillis());
        return bean;
    }
    @Override public Object postProcessAfterInitialization(Object bean, String beanName) {
        if (costMap.containsKey(beanName)) {
            long cost = System.currentTimeMillis() - costMap.get(beanName);
            if (cost > 0) {
                costMap.put(beanName, cost);
                System.out.println("bean: " + beanName + "\ttime: " + cost);
            }
        }
        return bean;
    }
}

Using this processor revealed a bean that took 43 seconds during initialization because it queried a massive amount of configuration data from the database and wrote it to Redis.

Additionally, many beans from the UPM library (services, controllers) were being instantiated even though the project only needed a single client bean.

Optimization Strategies

Reduce scanning paths : Remove unnecessary base packages from @SpringBootApplication and register required beans explicitly via JavaConfig.

@Configuration
public class ThirdPartyBeanConfig {
    @Bean
    public UpmResourceClient upmResourceClient() {
        return new UpmResourceClient();
    }
}

Tips: If a bean has further dependencies, those must also be declared manually.

This approach eliminates redundant beans, lowers memory consumption, and prevents unwanted injection of unrelated services.

Handle cache auto‑configuration : After removing the scan path for the custom Redis cache component, SpringBoot still provided a CacheManager because @EnableCaching triggered the default CacheAutoConfiguration , which creates a RedisCacheManager when Redis is on the classpath.

The custom component’s bean was suppressed by @ConditionalOnMissingBean(CacheManager.class) , which no longer applied after the scan path was removed, leading to the auto‑generated manager being used unintentionally.

Solution: expose the custom cache configuration as a starter by adding an entry to META-INF/spring.factories under EnableAutoConfiguration so that SpringBoot imports it automatically without needing to scan the whole package.

# EnableAutoConfigurations
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.xxx.ad.rediscache.XxxAdCacheConfiguration

With these changes the startup time dropped from ~7 minutes to ~40 seconds, and the cache component behaved as expected.

Author: Debugger – Source: juejin.cn/post/7181342523728592955

performanceSpringBootAutoConfigurationBeanScanningJavaConfig
Top Architecture Tech Stack
Written by

Top Architecture Tech Stack

Sharing Java and Python tech insights, with occasional practical development tool tips.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.