What Really Happens Before Your Spring Boot Application Starts: The Complete Spring Bean Lifecycle
Follow a single Spring bean from a class file on disk to a proxied, initialized singleton and finally to destruction. Using real output from a running application and the actual call sequence inside AbstractApplicationContext and AbstractAutowireCapableBeanFactory, this article explains bean definitions, BeanFactoryPostProcessors, BeanPostProcessors, dependency injection, initialization callbacks, AOP proxies, the three-level singleton cache, circular dependencies, startup events, and graceful shutdown.
Most Spring Boot code looks like this:
@Service
public class UserService {
private final EmailService emailService;
public UserService(EmailService emailService) {
this.emailService = emailService;
}
}You never call new UserService(...). You never pass it an EmailService. Yet when a controller asks for a UserService, one exists, already wired, possibly wrapped in a transaction proxy, and guaranteed to be the same instance everywhere.
Something did all of that work between main() and the first HTTP request. This article follows one bean through that work, from a .class file on disk to a ready-to-use singleton and finally to its destruction. Every ordering claim below was checked against a running Spring Boot 3.5 / Spring Framework 6.2 application and the Spring source code; the concepts have been stable since Spring Framework 2.x, and where behavior differs between versions, the article says so.
Contents
- The mental model: blueprints first, objects second
- The bean we'll track, and what it prints
- Before refresh(): what SpringApplication.run() does
- refresh(): the twelve steps that build the container
- Phase 1: Building the blueprints
- Registering BeanPostProcessors
- Phase 2: Creating a bean, step by step
- Proxies: why the bean you get is not the object you wrote
- Circular dependencies and the three singleton caches
- After the singletons: from "context refreshed" to "application ready"
- Scopes, lazy beans and FactoryBeans in brief
- Shutdown: how beans are destroyed
- The complete lifecycle on one page
- Common misconceptions
- Debugging the lifecycle in a real application
- Knowledge check
The mental model: blueprints first, objects second
The single most useful idea for understanding Spring startup is that the container works in two distinct phases:
- The blueprint phase. Spring figures out what should exist. It reads your classes, configuration and auto-configuration, and records a
BeanDefinitionfor each bean: which class, which scope, how to construct it, which init and destroy methods to call. No application objects are created. - The construction phase. Spring walks through those blueprints and builds the actual objects: calls constructors, injects dependencies, runs initialization callbacks and, when needed, wraps objects in proxies.
An architect's drawings and a construction crew are a reasonable analogy. Drawings can be revised freely before anyone pours concrete; once a wall is built, changes are much more expensive. Spring gives you a separate hook for each phase:
| Extension point | Operates on | Runs | Typical built-in examples |
|---|---|---|---|
BeanDefinitionRegistryPostProcessor | The registry of definitions (can add definitions) | Before any regular bean exists | ConfigurationClassPostProcessor (component scanning, @Bean, @Import, auto-configuration) |
BeanFactoryPostProcessor (BFPP) | Existing definitions (can modify them) | Before any regular bean exists | PropertySourcesPlaceholderConfigurer |
BeanPostProcessor (BPP) | Each bean instance | Around the initialization of every bean | AutowiredAnnotationBeanPostProcessor, CommonAnnotationBeanPostProcessor, auto-proxy creators |
Where the analogy breaks: a construction crew follows the drawings, but a BeanPostProcessor can hand back a completely different object than the one built from the blueprint. That is exactly how @Transactional, @Async and @Cacheable work, and it's why the object you inject is often not an instance of the class you wrote.
The bean we'll track, and what it prints
To make the lifecycle observable, here is a UserService that implements every lifecycle callback Spring offers. Each one prints a numbered line.
package com.example.lifecycle;
import jakarta.annotation.PostConstruct; // javax.annotation.* on Spring Boot 2.x / Spring 5.x
import jakarta.annotation.PreDestroy;
import org.springframework.beans.factory.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.scheduling.annotation.Async;
public class UserService implements BeanNameAware, BeanFactoryAware, ApplicationContextAware,
InitializingBean, SmartInitializingSingleton, DisposableBean {
private final EmailService emailService; // constructor injection
private AuditLog auditLog; // setter injection
@Value("${app.greeting}") // field injection of a property
private String greeting;
public UserService(EmailService emailService) {
this.emailService = emailService;
log("1. constructor (emailService injected: " + (emailService != null) + ")");
}
@Autowired
public void setAuditLog(AuditLog auditLog) {
this.auditLog = auditLog;
log("2. @Autowired setter (greeting is now: " + greeting + ")");
}
@Override public void setBeanName(String name) { log("3. BeanNameAware.setBeanName(" + name + ")"); }
@Override public void setBeanFactory(BeanFactory bf) { log("4. BeanFactoryAware.setBeanFactory"); }
@Override public void setApplicationContext(ApplicationContext ctx) {
log("5. ApplicationContextAware.setApplicationContext");
}
@PostConstruct public void postConstruct() { log("6. @PostConstruct"); }
@Override public void afterPropertiesSet() { log("7. InitializingBean.afterPropertiesSet"); }
public void customInit() { log("8. @Bean(initMethod) customInit"); }
@Override public void afterSingletonsInstantiated() { log("9. SmartInitializingSingleton.afterSingletonsInstantiated"); }
@Async // makes Spring wrap this bean in a proxy
public void welcome(String user) {
emailService.send(user, greeting);
auditLog.record("welcomed " + user);
}
@PreDestroy public void preDestroy() { log("D1. @PreDestroy"); }
@Override public void destroy() { log("D2. DisposableBean.destroy"); }
public void customDestroy() { log("D3. @Bean(destroyMethod) customDestroy"); }
private static void log(String msg) { System.out.println("[UserService] " + msg); }
}It is registered with a @Bean method so that we can also declare a custom init and destroy method:
@Configuration
@EnableAsync(proxyTargetClass = true)
public class AppConfig {
@Bean(initMethod = "customInit", destroyMethod = "customDestroy")
public UserService userService(EmailService emailService) {
return new UserService(emailService);
}
// A BeanFactoryPostProcessor that peeks at the blueprint before the object exists.
// It is static so it can run before AppConfig itself is instantiated (explained later).
@Bean
public static BeanFactoryPostProcessor definitionInspector() {
return beanFactory -> {
var bd = beanFactory.getBeanDefinition("userService");
System.out.println("[BFPP] userService definition exists, instance exists? "
+ beanFactory.containsSingleton("userService"));
System.out.println("[BFPP] factoryBean=" + bd.getFactoryBeanName()
+ ", factoryMethod=" + bd.getFactoryMethodName()
+ ", scope=" + (bd.getScope().isEmpty() ? "(default singleton)" : bd.getScope())
+ ", lazy=" + bd.isLazyInit());
};
}
}Finally, a BeanPostProcessor that logs every container hook it receives for userService:
@Component
public class LifecycleTracingPostProcessor
implements InstantiationAwareBeanPostProcessor, DestructionAwareBeanPostProcessor {
private static final String TRACKED = "userService";
@Override
public Object postProcessBeforeInstantiation(Class<?> beanClass, String beanName) {
if (TRACKED.equals(beanName)) log("postProcessBeforeInstantiation");
return null; // null = "carry on and let Spring instantiate it normally"
}
@Override
public boolean postProcessAfterInstantiation(Object bean, String beanName) {
if (TRACKED.equals(beanName)) log("postProcessAfterInstantiation");
return true;
}
@Override
public PropertyValues postProcessProperties(PropertyValues pvs, Object bean, String beanName) {
if (TRACKED.equals(beanName)) log("postProcessProperties");
return pvs;
}
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) {
if (TRACKED.equals(beanName)) log("postProcessBeforeInitialization");
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (TRACKED.equals(beanName))
log("postProcessAfterInitialization (received " + bean.getClass().getSimpleName() + ")");
return bean;
}
@Override
public void postProcessBeforeDestruction(Object bean, String beanName) {
if (TRACKED.equals(beanName)) log("postProcessBeforeDestruction");
}
private static void log(String msg) { System.out.println(" [BPP] " + msg); }
}EmailService and AuditLog are plain @Components, and a small listener class prints the startup and shutdown events. The main method starts the application, prints the runtime class of UserService, and closes the context. Running it produces this output, unedited:
[BFPP] userService definition exists, instance exists? false
[BFPP] factoryBean=appConfig, factoryMethod=userService, scope=(default singleton), lazy=false
[EmailService] constructor
[BPP] postProcessBeforeInstantiation
[UserService] 1. constructor (emailService injected: true)
[BPP] postProcessAfterInstantiation
[BPP] postProcessProperties
[UserService] 2. @Autowired setter (greeting is now: Welcome aboard)
[UserService] 3. BeanNameAware.setBeanName(userService)
[UserService] 4. BeanFactoryAware.setBeanFactory
[UserService] 5. ApplicationContextAware.setApplicationContext
[BPP] postProcessBeforeInitialization
[UserService] 6. @PostConstruct
[UserService] 7. InitializingBean.afterPropertiesSet
[UserService] 8. @Bean(initMethod) customInit
[BPP] postProcessAfterInitialization (received UserService$$SpringCGLIB$$0)
[UserService] 9. SmartInitializingSingleton.afterSingletonsInstantiated
[event] ContextRefreshedEvent
[event] ApplicationStartedEvent
[runner] CommandLineRunner.run
[event] ApplicationReadyEvent
[main] Bean class at runtime: com.example.lifecycle.UserService$$SpringCGLIB$$0
[main] Closing context
[event] ContextClosedEvent
[BPP] postProcessBeforeDestruction
[UserService] D1. @PreDestroy
[UserService] D2. DisposableBean.destroy
[UserService] D3. @Bean(destroyMethod) customDestroyEvery line of that output gets explained in the rest of this article. A few already stand out:
- The bean's definition existed, fully described, while the object did not (lines 1–2).
greetingwas already populated when the setter ran (line 2 ofUserService), so field injection happened before method injection.- By the time our post-processor's
postProcessAfterInitializationran, it received a class calledUserService$$SpringCGLIB$$0, notUserService. Someone had already swapped the object.
Before refresh(): what SpringApplication.run() does
The container that holds your beans is an ApplicationContext. It wraps a BeanFactory (the part that actually stores definitions and creates beans) and adds an Environment (properties and profiles), an event system, internationalisation and resource loading.
Spring Boot's job in SpringApplication.run() is mostly to choose, prepare and refresh that context. The heavy lifting happens in refresh(), but several things happen first:
Two details matter for the rest of the story:
- Only one bean definition exists before
refresh(): your main class. Everything else is discovered from it during refresh. - Creating an annotation-based context registers a handful of internal infrastructure definitions (via
AnnotationConfigUtils.registerAnnotationConfigProcessors):ConfigurationClassPostProcessor,AutowiredAnnotationBeanPostProcessor,CommonAnnotationBeanPostProcessorand the event-listener processors. These are what make annotations work at all. If they weren't registered,@Autowiredand@PostConstructwould just be metadata that nothing reads.
refresh(): the twelve steps that build the container
AbstractApplicationContext.refresh() is the orchestrator of the entire lifecycle. In Spring Framework 6.2 it calls these methods, in this order:
| # | Method | What it does | Matters for beans? |
|---|---|---|---|
| 1 | prepareRefresh() | Records start time, validates required properties | — |
| 2 | obtainFreshBeanFactory() | Returns the internal DefaultListableBeanFactory | — |
| 3 | prepareBeanFactory() | Registers the class loader, ApplicationContextAwareProcessor, the Environment as a bean, and resolvable dependencies (so you can inject ApplicationContext) | Yes |
| 4 | postProcessBeanFactory() | Subclass hook; web contexts register request/session scopes here | Somewhat |
| 5 | invokeBeanFactoryPostProcessors() | Discovers and finalizes all bean definitions (component scanning happens here) | Yes — Phase 1 |
| 6 | registerBeanPostProcessors() | Instantiates and orders all BeanPostProcessors | Yes |
| 7 | initMessageSource() | i18n message source | — |
| 8 | initApplicationEventMulticaster() | The component that dispatches events to listeners | — |
| 9 | onRefresh() | Subclass hook; Spring Boot creates the embedded web server (Tomcat/Jetty/Netty) here | Indirectly |
| 10 | registerListeners() | Registers ApplicationListener beans | — |
| 11 | finishBeanFactoryInitialization() | Instantiates every non-lazy singleton | Yes — Phase 2 |
| 12 | finishRefresh() | Starts Lifecycle beans (the web server begins accepting connections), publishes ContextRefreshedEvent | Yes |
If any step throws, refresh() destroys the singletons already created and rethrows. That is why a failure in one bean's @PostConstruct triggers the @PreDestroy methods of beans that were created successfully before it.
The rest of the article walks through steps 5, 6, 11 and 12, and then the shutdown path.
Phase 1: Building the blueprints
What a BeanDefinition actually contains
A BeanDefinition is the container's recipe for one bean. The [BFPP] lines in the output show part of the real definition for userService:
factoryBean=appConfig, factoryMethod=userService, scope=(default singleton), lazy=falseBecause UserService was declared with a @Bean method, its recipe is not "call the UserService constructor" but "call the userService method on the appConfig bean". A class picked up by component scanning would instead have a beanClassName and no factory method.
The most important properties:
| Property | Meaning |
|---|---|
beanClassName | The class to instantiate (for scanned components) |
factoryBeanName / factoryMethodName | Instead of a constructor, call this method on this bean (for @Bean methods) |
scope | singleton (default, empty string), prototype, request, session, … |
lazyInit | If true, don't create at startup; create on first request |
primary | Prefer this bean when several candidates match a type |
dependsOn | Beans that must be initialized first, even though they're not injected. This is not a list of constructor dependencies. |
initMethodNames / destroyMethodNames | Methods from @Bean(initMethod=…, destroyMethod=…) |
constructorArgumentValues / propertyValues | Explicit values (mostly from XML or programmatic registration) |
autowireCandidate | Whether this bean may be injected into others by type |
Misconception: "A BeanDefinition lists the bean's dependencies and
@PostConstructmethod." It lists neither. Constructor and@Autowireddependencies are discovered by reflecting over the class during creation, and@PostConstructmethods are discovered by aBeanPostProcessor. The definition only holds what was declared explicitly.
Where definitions come from: ConfigurationClassPostProcessor
It's tempting to picture component scanning as a separate step that runs before the post-processors. It isn't. Component scanning is itself done by a post-processor: ConfigurationClassPostProcessor, a BeanDefinitionRegistryPostProcessor registered automatically when the context is created. In step 5 of refresh(), it starts from your main class and recursively processes everything it points to:
Some details that explain real-world behavior:
- Auto-configuration is deliberately processed last. It runs as a
DeferredImportSelectorafter all your own configuration has been parsed. That's what lets conditions like@ConditionalOnMissingBeansee your beans and back off. (The candidate list file isMETA-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importssince Boot 2.7; earlier versions usedMETA-INF/spring.factories.) - Conditions are evaluated against definitions, not objects.
@ConditionalOnBeanasks "is there a definition of this type?", because at this point no objects exist. - Scanned components are registered before
@Beanmethods. Scanning registers definitions as soon as it finds them, while@Beanmethods are registered after their configuration class is fully parsed. Definitions are later instantiated roughly in registration order, which is whyEmailService(scanned) was constructed beforeuserService(a@Bean) in our output. @Configurationclasses get a CGLIB subclass (unlessproxyBeanMethods = false), so calling one@Beanmethod from another returns the container's singleton instead of creating a new object.
All definitions end up in DefaultListableBeanFactory, which keeps them in a ConcurrentHashMap<String, BeanDefinition> called beanDefinitionMap, plus a beanDefinitionNames list that preserves registration order.
BeanFactoryPostProcessors: editing the blueprints
After registry post-processors have finished adding definitions, ordinary BeanFactoryPostProcessors run. They can read and modify any definition, but they run before any regular bean exists. Our definitionInspector proved that: instance exists? false.
Spring invokes them in a fixed order, implemented in PostProcessorRegistrationDelegate:
BeanDefinitionRegistryPostProcessors implementingPriorityOrdered(this includesConfigurationClassPostProcessor), thenOrdered, then the rest. This repeats until no new registry post-processors appear, because one can register another.- Plain
BeanFactoryPostProcessors:PriorityOrdered, thenOrdered, then the rest.
The best-known BFPP is PropertySourcesPlaceholderConfigurer. Its role is often described as "replacing @Value placeholders", which isn't quite right. It does two things:
- It resolves
${...}placeholders inside bean definition values (constructor arguments and property values, mostly from XML or programmatic definitions). - It registers an embedded value resolver on the bean factory.
The @Value("${app.greeting}") field in our bean is resolved later, during injection (in phase 2), by AutowiredAnnotationBeanPostProcessor, using that resolver.
Engineering insight: why
definitionInspectorisstatic. BFPPs must be instantiated before regular beans. If a BFPP were declared with a non-static@Beanmethod, Spring would have to instantiate the enclosingAppConfigearly, before BeanPostProcessors exist, soAppConfigwould miss@Autowiredprocessing and other post-processing. Spring logs a warning in this case. Declaring BFPP (and BPP)@Beanmethodsstaticavoids it.
What happens if a BFPP calls
beanFactory.getBean(...)? The bean is created immediately, while no BeanPostProcessors are registered. It gets no@Autowiredinjection, no@PostConstructcall and no proxy. BFPPs should work with definitions, never with instances.
Registering BeanPostProcessors
Step 6, registerBeanPostProcessors(), finds every BeanPostProcessor definition, instantiates them right away and stores them in an ordered list (again PriorityOrdered, then Ordered, then the rest, with Spring's internal processors placed last). From then on, every bean the container creates passes through this list.
The processors that implement most of Spring's "annotation magic":
| BeanPostProcessor | What it implements | Hook it uses |
|---|---|---|
AutowiredAnnotationBeanPostProcessor | @Autowired, @Value, @Inject; constructor selection | determineCandidateConstructors, postProcessProperties |
CommonAnnotationBeanPostProcessor | @Resource, @PostConstruct, @PreDestroy | postProcessProperties, postProcessBeforeInitialization, postProcessBeforeDestruction |
ApplicationContextAwareProcessor | ApplicationContextAware, EnvironmentAware, ResourceLoaderAware, ApplicationEventPublisherAware, … | postProcessBeforeInitialization |
ConfigurationPropertiesBindingPostProcessor (Boot) | @ConfigurationProperties binding | postProcessBeforeInitialization |
AnnotationAwareAspectJAutoProxyCreator / InfrastructureAdvisorAutoProxyCreator | AOP proxies for @Transactional, @Cacheable, @Aspect, method security | getEarlyBeanReference, postProcessAfterInitialization |
AsyncAnnotationBeanPostProcessor | @Async proxies | postProcessAfterInitialization |
Engineering insight: "is not eligible for getting processed by all BeanPostProcessors". BeanPostProcessors are beans, so they're created before the rest of the processor list is complete. Anything a BPP depends on also gets created early, and that dependency skips the processors that don't exist yet. In practice it may not be proxied, so a
@Transactionalrepository injected into a custom BPP can silently lose its transactions. Spring logs an INFO/WARN message with the sentence above when this happens. Keep BPPs free of dependencies on application beans, or inject them lazily (ObjectProvider<T>).
Phase 2: Creating a bean, step by step
Step 11, finishBeanFactoryInitialization(), calls preInstantiateSingletons(). It iterates over beanDefinitionNames and calls getBean(name) for every definition that is singleton, non-lazy and non-abstract. (A FactoryBean is created here too, but its product is normally created on first request; see FactoryBeans.)
getBean is also what runs when one bean needs another. When userService needs emailService, Spring calls getBean("emailService") and recursively creates it if it doesn't exist yet. The dependency graph is therefore built depth-first.
Here is the call path for one singleton, with the method names as they appear in Spring's source:
Before instantiation: the short-circuit
resolveBeforeInstantiation() gives every InstantiationAwareBeanPostProcessor a chance to return an object instead of letting Spring build the bean. Our tracer returned null, which means "proceed normally", and logged postProcessBeforeInstantiation before the constructor ran. Application code almost never uses this hook; AOP infrastructure uses it for beans with custom target sources.
① Instantiation and constructor injection
createBeanInstance() picks one of several strategies:
- An instance supplier registered programmatically
- A factory method: our
@Bean userService(EmailService)method onappConfig - Constructor autowiring: for a scanned class. If the class has exactly one constructor, Spring uses it without needing
@Autowired(since Spring 4.3). With several constructors, you mark one with@Autowired, or Spring falls back to the no-arg constructor. - The default no-arg constructor
Arguments to a constructor or factory method are resolved immediately, through getBean calls. That's why the output shows emailService injected: true on the very first line UserService printed. With constructor injection, instantiation and injection are the same step.
When several beans match a parameter's type, resolution narrows them down in this order: a @Qualifier match, then a @Primary bean, then a @Priority annotation, then a bean whose name matches the parameter name. Anything still ambiguous fails with NoUniqueBeanDefinitionException.
② Merged bean definition post-processing
Right after instantiation, MergedBeanDefinitionPostProcessors inspect the bean's class once and cache what they find. AutowiredAnnotationBeanPostProcessor records the @Autowired / @Value fields and methods; CommonAnnotationBeanPostProcessor records @Resource members and the @PostConstruct / @PreDestroy methods. This is where the lifecycle annotations are discovered, not in the definition.
③ Early exposure: the third-level cache is filled now
Before injecting any fields, Spring places a small factory for this half-built bean into a cache called singletonFactories. This happens only for singletons, and only while circular references are allowed. It's the mechanism that makes field and setter injection cycles resolvable, and it's covered in depth below. Note the timing: the entry is created right after instantiation, not after initialization.
④ populateBean: field and setter injection
populateBean() first asks each InstantiationAwareBeanPostProcessor whether property population should happen at all (postProcessAfterInstantiation; returning false skips it). Then it calls postProcessProperties on each of them, and this is where annotation-driven injection happens:
AutowiredAnnotationBeanPostProcessorinjects@Autowired/@Value/@Injectfields first, then methods. That's whygreetingwas alreadyWelcome aboardwhensetAuditLogran.CommonAnnotationBeanPostProcessorinjects@Resourcemembers.
Finally, any explicit property values from the definition (XML <property> elements, for example) are applied.
Misconception: "
@Autowiredis processed inpostProcessBeforeInitialization." It isn't. Field and setter injection happen inpopulateBean(), throughpostProcessProperties, before initialization starts. By the time any initialization callback runs, every dependency is in place. That's the guarantee@PostConstructrelies on.
⑤ initializeBean: making the bean ready
initializeBean() makes exactly four calls (verified in the 6.2 bytecode: invokeAwareMethods, applyBeanPostProcessorsBeforeInitialization, invokeInitMethods, applyBeanPostProcessorsAfterInitialization).
1. Core Aware callbacks, called directly by the bean factory: BeanNameAware → BeanClassLoaderAware → BeanFactoryAware (output lines 3 and 4).
2. postProcessBeforeInitialization on every BPP, in order. Two of the internal processors do visible work here:
ApplicationContextAwareProcessorcalls the context-level Aware interfaces (EnvironmentAware,ResourceLoaderAware,ApplicationEventPublisherAware,MessageSourceAware,ApplicationContextAware, …). That's line 5. These come later thanBeanFactoryAwarebecause the bean factory itself knows nothing about the application context.CommonAnnotationBeanPostProcessorinvokes@PostConstruct(line 6).
Our tracer's postProcessBeforeInitialization printed between lines 5 and 6. Spring sorts post-processors by Ordered priority, and our unordered processor landed between those two internal ones.
3. invokeInitMethods(): InitializingBean.afterPropertiesSet() (line 7), then the custom init method from @Bean(initMethod = …) (line 8).
4. postProcessAfterInitialization on every BPP. This is where proxies are created. A BPP may return a different object, and whatever the last one returns is what the container stores. By the time our tracer saw the bean it was already UserService$$SpringCGLIB$$0, because AsyncAnnotationBeanPostProcessor had run first and wrapped it.
Choosing an initialization callback
| Mechanism | Order | Couples code to | Use when |
|---|---|---|---|
@PostConstruct | 1st | Jakarta/Javax annotations (a standard) | Default choice for your own classes |
InitializingBean.afterPropertiesSet() | 2nd | Spring API | Framework/library code that must work without annotation processing |
@Bean(initMethod = "…") | 3rd | Nothing (configured externally) | Third-party classes you can't annotate |
All three run on the raw object, before any proxy exists. So a @Transactional method called from @PostConstruct runs without a transaction. For work that needs the fully proxied bean or the whole context, use SmartInitializingSingleton or an ApplicationReadyEvent listener (see below).
⑥ Registering destruction callbacks, then caching
registerDisposableBeanIfNecessary() records the bean for destruction if it has any destroy callback (@PreDestroy, DisposableBean, AutoCloseable, a destroy method, or a DestructionAwareBeanPostProcessor that wants it). Finally addSingleton() stores the finished object (or its proxy) in singletonObjects, the map that every later getBean call reads from.
Proxies: why the bean you get is not the object you wrote
Our main method printed:
[main] Bean class at runtime: com.example.lifecycle.UserService$$SpringCGLIB$$0@Async, @Transactional, @Cacheable, @Retryable, method security and @Aspect advice all work the same way. In postProcessAfterInitialization, a post-processor checks whether any advice applies to the bean. If it does, it wraps the bean in a proxy, and the proxy is what gets stored and injected everywhere.
JDK dynamic proxies vs CGLIB
| JDK dynamic proxy | CGLIB proxy | |
|---|---|---|
| How | Implements the bean's interfaces | Generates a subclass of the bean's class at runtime |
| Injectable as the concrete class? | No, only as its interfaces | Yes |
| Limitations | Only interface methods are advised | final classes can't be proxied; final and private methods aren't intercepted |
| Class name | jdk.proxy2.$Proxy61 | UserService$$SpringCGLIB$$0 |
Which one you get depends on configuration, and this is where Spring Framework and Spring Boot defaults differ:
- Spring Framework's rule: use a JDK proxy if the bean implements at least one interface, otherwise use CGLIB.
- Spring Boot's auto-configured AOP sets
proxyTargetClass = true(propertyspring.aop.proxy-target-class, defaulttrue), so@Transactional,@Cacheableand@Aspectproxies are CGLIB even when interfaces exist.
The catch is that not every proxy goes through auto-configured AOP. @EnableAsync creates its own post-processor with its own proxyTargetClass attribute, which defaults to false. Our UserService implements several interfaces (the Aware and lifecycle interfaces), so with plain @EnableAsync the demo produced a JDK proxy, and this line in main failed:
postProcessAfterInitialization (received $Proxy61)
...
No qualifying bean of type 'com.example.lifecycle.UserService' availableThe bean existed, but as a $Proxy61 that implements BeanNameAware, InitializingBean and the rest. It is not a UserService, so lookup by the concrete class found nothing. That's why the demo uses @EnableAsync(proxyTargetClass = true).
The self-invocation trap
A proxy only intercepts calls that come through the proxy. A call from one method to another inside the same object uses this, which is the raw target:
@Service
public class OrderService {
public void placeOrder(Order order) {
validate(order);
save(order); // this.save(): bypasses the proxy, NO transaction
}
@Transactional
public void save(Order order) { /* ... */ }
}The fix is structural: move save to another bean, or put @Transactional on the public entry point (placeOrder).
Circular dependencies and the three singleton caches
The problem
Suppose ServiceA needs ServiceB and ServiceB needs ServiceA. With constructor injection there is no valid order: neither can be constructed without the other already existing. With field or setter injection there is a way out. Construct A without its dependencies, let B hold a reference to the unfinished A, finish B, then finish A.
That "reference to the unfinished A" is what the three caches in DefaultSingletonBeanRegistry manage.
The three caches
| Level | Field | Holds | Filled when |
|---|---|---|---|
| 1 | singletonObjects | Finished singletons (possibly proxies) | addSingleton(), after initializeBean() completes |
| 2 | earlySingletonObjects | Early references already handed out to another bean during a cycle | The first time someone asks for a bean that is still being created |
| 3 | singletonFactories | An ObjectFactory per in-creation bean that can produce its early reference | addSingletonFactory(), right after instantiation (step ③) |
getSingleton(name) looks them up in order: level 1, then level 2, then (only if the bean is currently in creation) level 3. If it finds a factory in level 3, it calls the factory, stores the result in level 2 and removes the factory.
Step by step
Why three levels instead of one or two?
- Why not just one map? Code that reads
singletonObjectsmust be able to trust that what it finds is complete. Half-built beans need a separate place. - Why a factory (level 3) instead of putting the raw object straight into level 2? Because the early reference might need to be a proxy. If
Ais@Transactional,Bmust receive the proxy ofA, not the raw object, orB's calls would bypass transactions forever. The factory callsgetEarlyBeanReference()on everySmartInstantiationAwareBeanPostProcessor, which lets the AOP auto-proxy creator build the proxy early, and only if a cycle actually needs it. Most beans are never part of a cycle, so their factory is simply discarded and they're proxied at the usual point inpostProcessAfterInitialization. The auto-proxy creator remembers which beans it already proxied early so that it doesn't wrap them twice. - Why keep level 2 at all? If three beans all depend on
Aduring the same cycle, they must all receive the same early reference. Level 2 stores the result of the factory so it's created only once.
There's a consistency check at the end of doCreateBean(). If a post-processor that doesn't participate in early references wraps A after B already received the raw A, then B would hold a different object from everyone else. Spring then fails with a BeanCurrentlyInCreationException saying the bean was "injected into other beans … in its raw version as part of a circular reference, but has eventually been wrapped." How often this triggers depends on the Spring version and on which post-processors are involved. In a Spring 6.2.12 test with an @Async bean in a cycle, the container recovered by re-creating the beans, but older versions are known to fail. Treat it as one more reason not to rely on cycles.
What actually happens today
The cache mechanism exists, but Spring Boot disables circular references by default since version 2.6. Running the classic example:
@Service class ServiceA { @Autowired ServiceB serviceB; }
@Service class ServiceB { @Autowired ServiceA serviceA; }produces (actual output, Spring Boot 3.5.7):
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| serviceA (field com.example.cycle.ServiceB com.example.cycle.ServiceA.serviceB)
↑ ↓
| serviceB (field com.example.cycle.ServiceA com.example.cycle.ServiceB.serviceA)
└─────┘
Action:
Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.With --spring.main.allow-circular-references=true, the cache mechanism kicks in and the app starts. Both a.serviceB == b and b.serviceA == a printed true. Plain Spring Framework (without Boot) still allows circular references by default.
Constructor injection cycles can't be resolved by the caches at all, even with the flag on, because there's no instance to put in level 3 until the constructor returns. Spring reports: "Despite circular references being allowed, the dependency cycle between beans could not be broken." The escape hatch is @Lazy on one constructor parameter:
@Service
class ServiceB {
final ServiceA serviceA;
ServiceB(@Lazy ServiceA serviceA) { this.serviceA = serviceA; }
}Spring injects a lazy-resolution proxy (the demo printed B holds: com.example.ctorcycle.ServiceA$$SpringCGLIB$$0), which looks up the real ServiceA on first method call.
Engineering insight: a cycle almost always signals two classes that share a responsibility. Extracting the shared part into a third bean, or replacing a direct call with an event, usually removes the cycle and makes both classes easier to test. Treat
allow-circular-referencesand@Lazyas temporary measures, not as design.
After the singletons: from "context refreshed" to "application ready"
Once every non-lazy singleton exists, startup has several more steps. They appeared in our output in this order:
[UserService] 9. SmartInitializingSingleton.afterSingletonsInstantiated
[event] ContextRefreshedEvent
[event] ApplicationStartedEvent
[runner] CommandLineRunner.run
[event] ApplicationReadyEventSmartInitializingSingleton.afterSingletonsInstantiated(): called at the end ofpreInstantiateSingletons(), once all singletons have been created. Unlike@PostConstruct, it's safe to look up other beans of any kind here. Spring uses it internally, for example to process@EventListenermethods.finishRefresh(): theLifecycleProcessorstarts allSmartLifecyclebeans in phase order. In a Spring Boot web application, this is when the embedded web server starts accepting connections (it was created earlier inonRefresh()). Message-listener containers (Kafka, JMS, RabbitMQ) and schedulers also start here. ThenContextRefreshedEventis published.- Back in
SpringApplication.run():ApplicationStartedEvent, then allApplicationRunnerandCommandLineRunnerbeans run (ordered by@Order), thenApplicationReadyEvent. With Actuator, the readiness state switches toACCEPTING_TRAFFICat the ready event.
| I want to… | Use |
|---|---|
| Validate this bean's own configuration | @PostConstruct |
| Do something once all beans exist, before traffic | SmartInitializingSingleton or ContextRefreshedEvent |
| Start/stop a background component in sync with the context | SmartLifecycle |
| Run a one-off task at startup (migration, warm-up) | ApplicationRunner / CommandLineRunner |
| React when the app is fully up | @EventListener(ApplicationReadyEvent.class) |
What happens if a
CommandLineRunnerthrows? Startup fails.SpringApplicationreports the failure, closes the context (running destroy callbacks) and rethrows. Note that the web server was already accepting connections at that point, so a slow runner delays readiness, not port binding.
Scopes, lazy beans and FactoryBeans in brief
These follow the same creation path with a few differences. You need them less often, so here is a summary of each.
Prototype and web scopes
prototype: everygetBean(or injection point) runs the full creation path and returns a new instance. Nothing is cached, and Spring never calls destroy callbacks on prototypes: after handing one out, the container keeps no reference to it. Injecting a prototype into a singleton gives you one instance for the singleton's lifetime; useObjectProvider<T>to get a fresh one each time.request/session: instances are stored in the current HTTP request or session and destroyed with it. To inject them into singletons, Spring injects a scoped proxy that looks up the current instance on each call.
Lazy initialization
@Lazy on a bean (or spring.main.lazy-initialization=true globally) skips it in preInstantiateSingletons(). It's created on first getBean or first use. The trade-off is faster startup in exchange for configuration errors surfacing on the first request instead of at boot, and a slower first request.
FactoryBean: a bean that makes beans
FactoryBean<T> is not the same thing as BeanFactory. A BeanFactory is the container. A FactoryBean is an object in the container that produces another object, used when construction logic doesn't fit a constructor or a @Bean method (or predates @Bean). Spring's own LocalContainerEntityManagerFactoryBean and ProxyFactoryBean are examples.
public class PaymentClientFactoryBean implements FactoryBean<PaymentClient> {
private final String mode; // e.g. "sandbox" or "live"
public PaymentClientFactoryBean(@Value("${payments.mode}") String mode) {
this.mode = mode;
}
@Override
public PaymentClient getObject() {
return "live".equals(mode) ? new LivePaymentClient() : new SandboxPaymentClient();
}
@Override
public Class<?> getObjectType() {
return PaymentClient.class; // lets type-based injection work before getObject() runs
}
}If this is registered as paymentClient:
getBean("paymentClient")and injection ofPaymentClientreturn the product fromgetObject().getBean("&paymentClient")returns the factory itself, which is also a bean with a full lifecycle.- The product is created on first request, not at startup (unless the factory implements
SmartFactoryBeanwithisEagerInit() = true). It's cached whenisSingleton()returnstrue(the default), and it goes throughpostProcessAfterInitialization, so it can still be proxied. It doesn't go through the other lifecycle steps.
Shutdown: how beans are destroyed
What triggers it
SpringApplication registers a JVM shutdown hook by default (spring.main.register-shutdown-hook=true). The hook calls context.close() when the JVM is shutting down normally:
| Trigger | Do destroy callbacks run? |
|---|---|
context.close() or SpringApplication.exit(...) | Yes |
Normal end of main with no non-daemon threads left | Yes (shutdown hook) |
SIGTERM (e.g. docker stop, Kubernetes pod termination), SIGINT (Ctrl+C) | Yes (shutdown hook) |
SIGKILL (kill -9, Kubernetes after terminationGracePeriodSeconds), Runtime.halt(), JVM crash | No. Nothing runs |
That last row is why resources that must survive a crash, such as message offsets or transactional outbox entries, can't rely on @PreDestroy alone.
What close() does
Three details worth knowing:
- Graceful shutdown. With
server.shutdown=graceful, the web server stops accepting new requests and waits up tospring.lifecycle.timeout-per-shutdown-phase(default 30s) for in-flight requests before beans are destroyed. It has been the default since Spring Boot 3.4; earlier versions defaulted toimmediate. - Destruction order respects dependencies. Spring records which beans depend on which (
dependentBeanMap). A bean is destroyed only after the beans that depend on it, soUserServiceis destroyed before theEmailServiceit uses, and its@PreDestroycan still callemailService. - Per-bean order matches our output:
DestructionAwareBeanPostProcessors run first (our tracer loggedpostProcessBeforeDestruction, thenCommonAnnotationBeanPostProcessorran@PreDestroy, D1), thenDisposableBean.destroy()(D2), then the custom destroy method (D3).
Gotcha: inferred destroy methods. For
@Beanmethods,destroyMethoddefaults to"(inferred)". Spring automatically calls a public no-argclose()orshutdown()method on the returned object (and anyAutoCloseable). This is convenient for connection pools and clients. It is surprising if the object is shared and shouldn't be closed by your context. Disable it with@Bean(destroyMethod = "").
The complete lifecycle on one page
Common misconceptions
| Belief | Reality |
|---|---|
| "Component scanning happens before BeanFactoryPostProcessors." | Scanning is a BeanFactoryPostProcessor (ConfigurationClassPostProcessor). |
| "The bean is ready once its constructor returns." | Field/setter injection, Aware callbacks, init methods and proxying all come after. |
"@Autowired fields are injected in postProcessBeforeInitialization." | They're injected in populateBean() via postProcessProperties, before initialization. |
"@PostConstruct runs on the proxy." | It runs on the raw object, before the proxy is created. @Transactional methods called from it are not transactional. |
| "Interfaces mean JDK proxy." | That's the Spring Framework default. Spring Boot's AOP defaults to CGLIB, but @EnableAsync and some other @Enable… annotations have their own setting. |
| "Spring resolves circular dependencies automatically." | Only field/setter cycles, only when allowed, and Spring Boot forbids them by default since 2.6. Constructor cycles need @Lazy. |
"@PreDestroy always runs on shutdown." | Not on SIGKILL or halt, and never for prototype beans. |
"PropertySourcesPlaceholderConfigurer rewrites @Value." | It resolves placeholders in definitions and registers the resolver that @Value injection uses later. |
Debugging the lifecycle in a real application
--debugprints the condition evaluation report: every auto-configuration and why it matched or didn't. This is the first thing to check when a bean you expected is missing, or an unexpected one appears.- Actuator
/actuator/beanslists every bean with its scope, type and dependencies./actuator/conditionsis the condition report as JSON. - Startup tracing:
SpringApplication.setApplicationStartup(new BufferingApplicationStartup(2048))records timed steps such asspring.beans.instantiateandspring.context.beans.post-process, which you can read at/actuator/startup. It's the most direct way to find the bean that makes startup slow. - Breakpoints that pay off:
AbstractAutowireCapableBeanFactory.doCreateBean(conditional onbeanName.equals("…")) to watch one bean being built, andAbstractApplicationContext.refreshto see which phase fails. - Read the cause chain. A
BeanCreationExceptionwraps the real cause several levels deep; the bottom-most "Caused by" is usually the actual problem (a missing property, an ambiguous type, an exception inside@PostConstruct).
Knowledge check
1. A @Transactional method is called from a @PostConstruct method in the same bean. Is it transactional? Why?
No, for two independent reasons. @PostConstruct runs before postProcessAfterInitialization, so the proxy doesn't exist yet. And even after the proxy exists, a call through this bypasses it (self-invocation). Use an ApplicationReadyEvent listener on another bean, or call through the proxy.
2. Your custom BeanFactoryPostProcessor calls beanFactory.getBean(MyRepository.class). What goes wrong?
MyRepository is instantiated before any BeanPostProcessor is registered, so it gets no @Autowired injection, no @PostConstruct and no proxy (no transactions, no exception translation). BFPPs should only touch definitions.
3. Why does Spring need a third-level cache of factories instead of storing the raw early object directly?
So that the early reference can be a proxy when one is needed. The factory calls getEarlyBeanReference(), which lets the auto-proxy creator wrap the bean early, and it only does so if a cycle actually requests the bean. Beans that aren't in a cycle never call the factory and get proxied at the normal point.
4. You inject a prototype-scoped bean into a singleton. How many prototype instances are created, and who destroys them?
One, created when the singleton is created, and nobody destroys it: Spring doesn't track prototype instances after creating them. Inject ObjectProvider<T> and call getObject() to get a new instance per use, and clean it up yourself if needed.
5. In Kubernetes, your pod is killed before @PreDestroy finishes flushing a buffer. Which settings interact here?
Kubernetes sends SIGTERM, waits terminationGracePeriodSeconds, then sends SIGKILL. Spring's shutdown first stops the web server (waiting up to spring.lifecycle.timeout-per-shutdown-phase when graceful shutdown is on) and only then runs destroy callbacks. The grace period must cover both, otherwise SIGKILL cuts off the destroy phase.
References
Official documentation
- Spring Framework: Customizing the Nature of a Bean (lifecycle callbacks, Aware interfaces)
- Spring Framework: Container Extension Points (BeanPostProcessor, BeanFactoryPostProcessor, FactoryBean)
- Spring Framework: Bean Overview (BeanDefinition properties)
- Spring Framework: Dependency Injection, including circular dependencies
- Spring Framework: Bean Scopes
- Spring Framework: Proxying Mechanisms (JDK vs CGLIB, self-invocation)
- Spring Boot: SpringApplication (events, runners, shutdown hook, startup tracking)
- Spring Boot: Aspect-Oriented Programming (CGLIB by default)
- Spring Boot: Graceful Shutdown
- Spring Boot: Creating Your Own Auto-configuration
- Spring Boot: Actuator
startupendpoint
Release notes cited for version-specific behavior
- Spring Boot 2.6 Release Notes: circular references prohibited by default
- Spring Boot 3.4 Release Notes: graceful shutdown enabled by default
Source code (the methods named in this article)
AbstractApplicationContext:refresh(),finishBeanFactoryInitialization(),doClose()PostProcessorRegistrationDelegate: BFPP/BPP invocation order,BeanPostProcessorCheckerConfigurationClassPostProcessor: component scanning,@Bean,@ImportDefaultListableBeanFactory:beanDefinitionMap,preInstantiateSingletons(), dependency resolutionAbstractAutowireCapableBeanFactory:createBean(),doCreateBean(),populateBean(),initializeBean()DefaultSingletonBeanRegistry: the three singleton caches,destroySingletons()SpringApplication:run()