The Complete Bean Lifecycle: From Instantiation to Destruction

bee2026-10-0847 min read0 views
A bean passes eight phases and five callback types from birth to death. Each phase comes with source order and runnable verification — plus the classic question: at which step is the AOP proxy created?
1 / 122
Section
0. The 30-second version
2 / 122

A Java object exists the moment you new it, but "exists" and "is safe to use" are two different things. When the constructor returns, your @Autowired fields may still be null; once they are filled you may still need to validate config, warm caches or open connections; and before the process exits those resources must be released. Spring standardises the whole road from "born" to "usable" to "retired" into eight phases and leaves a socket at each one for your own code — that is the bean lifecycle.

3 / 122

Five words, one line each (used throughout):

4 / 122
  • Bean: not an object Spring invented, but your plain Java object that you handed to the container to create and manage
  • Container: the "housekeeper" object (ApplicationContext) that news your objects, wires their dependencies and manages birth to death; you ask it for things with getBean
  • Reflection: the technique of "calling a constructor or method by name at runtime" — the container never hard-codes your class, it builds your object through reflection
  • Hook: a slot the framework reserves inside its own flow; put a method in it and the container calls you when it reaches that step
  • @PostConstruct: an annotation meaning "all dependencies are filled — run this method before I am used from outside"
5 / 122
类比|Analogy

think of a bean as a human life. Birth (instantiation, the constructor runs) → growing up (population, absorbing the nutrition your parents hand you) → getting an ID card (Aware callbacks: "what am I called, which container do I live in") → the medical check before school starts (initialization: is my config right, am I fit for duty) → being issued a résumé and a staff badge at graduation (the AOP proxy — what other people actually meet is the badged version of you) → decades of work (ready & pooled, called over and over) → retirement and settling the estate (destroy callbacks, giving back every resource you held).

6 / 122
Diagram
Figure · The three init callbacks and who calls them
Figure · The three init callbacks and who calls them
7 / 122

Read that timeline left to right and you have the real growth path of a singleton: the further left, the fewer injected fields you can safely read. Section 3 turns it into runnable log output.

8 / 122

After this article you should be able to answer three questions:

9 / 122
  • Which runs first, @PostConstruct, afterPropertiesSet or initMethod — and why that order?
  • Why does a prototype bean's @PreDestroy never print? Didn't the container promise to manage it?
  • At which step is the AOP proxy created, and how does that decide whether @Transactional works inside a constructor?
10 / 122
Section
1. The big picture: a bean's eight stages of life
11 / 122

Start with a "why": why do lifecycle callbacks exist at all? Because an object being "created" and an object being "safe to use" are two different things. When the constructor returns, the object only has memory — dependencies are not injected yet. After injection you may still need to validate config, warm caches or open connections; and when done, you must release resources. Spring standardizes these "insertion points" into eight stages of life.

12 / 122
Diagram
Figure 1 · The bean lifecycle ring
Figure 1 · The bean lifecycle ring
13 / 122

The eight stages in order: instantiation → population → Aware callbacks → before-init processing → initialization → after-init processing → ready & pooled → destroy callbacks. Note it is a "ring", not a straight line — a singleton goes round once and then lives in the container until it shuts down and takes the final destroy stage.

14 / 122
Note

this article focuses on the singleton lifecycle. A prototype bean walks only up to "ready" and is then handed to the caller; the container does not manage its destruction — Section 5 covers this.

15 / 122
Section
2. What triggers each stage, and where in the source
16 / 122
Animation
Animation · Lifecycle step by step
Animation · Lifecycle step by step
17 / 122

Here are "who triggers it, where, and what for" in one table:

18 / 122
Table
StageInterface / annotationSource locationTypical use
InstantiationConstructor / factory methodAbstractAutowireCapableBeanFactory#createBeanInstancePick a constructor, reflect with newInstance
Population@Autowired / @Value / XML property#populateBean → AutowiredAnnotationBeanPostProcessorPush dependencies into fields or setters
Aware callbacksBeanNameAware / BeanFactoryAware / ApplicationContextAware#invokeAwareMethods + ApplicationContextAwareProcessorLet the bean learn "who am I, which container"
Before-initBeanPostProcessor#postProcessBeforeInitializationFirst loop inside #initializeBean@PostConstruct fires here via CommonAnnotationBeanPostProcessor
InitializationInitializingBean#afterPropertiesSet / @Bean(initMethod)#invokeInitMethodsValidate config, warm caches, open resources
After-initBeanPostProcessor#postProcessAfterInitializationSecond loop inside #initializeBeanAOP proxy creation (AbstractAutoProxyCreator)
Ready & pooled—DefaultSingletonBeanRegistry#addSingletonStored in singletonObjects for global reuse
Destroy@PreDestroy / DisposableBean#destroy / @Bean(destroyMethod)DisposableBeanAdapter#destroyClose connections, flush, release pools
19 / 122
Key point

@PostConstruct is not a separate stage — it is essentially a BeanPostProcessor callback during the "before-init" stage. Only by grasping this can you explain the classic trap in Section 7.

20 / 122

The table says who triggers each stage; what actually stalls beginners is what is readable at that moment. Can I use an injected field in the constructor? Is the proxy there yet inside @PostConstruct? Click the eight stations one by one — each gives one honest answer:

21 / 122
Diagram
CycleEight stages: what you can read at each one1 / 8
Step through all eight; stations ① and ⑥ are where people get burned
→
→
→
→
→
→
→
↻
a bean's life
① Instantiation
Reflection has called the constructor; the object has memory but every @Autowired field is still null. Reading this.dao here is precisely the NullPointerException from Section 7. One exception: constructor-injected parameters are already in place, because they were the arguments.
All clearThree honest rules: touch no dependencies at ①, mind only yourself at ⑤, and expect a proxy only after ⑥.
22 / 122
Section
2.1 Who actually drives the eight stages
23 / 122

The ring answers which stations exist; the debugger answers who pushes you along them. On the left is the real skeleton of getBean → doGetBean → doCreateBean; the right panel tracks variables and the call stack as you step. Two things to watch: the cache hit at step ②, and where the pool insert happens at step ⑦:

24 / 122
Stepper
StepperdoGetBean and getSingleton: the two roads of one lookup1 / 9
Step all nine: cell 2 decides speed, cell 8 decides whether it stays a singleton
Code under debug
1Object getBean("userService") // all you wrote
2Object cached = getSingleton(beanName); // 1. ask the primary cache: already there?
3if (cached != null) return getObjectForBeanInstance(cached); // 2. hit -> short circuit
4RootBeanDefinition mbd = getMergedBeanDefinition(beanName); // 3. miss: open the file
5Object obj = new UserService(); // 4. doCreateBean: instantiate
6populateBean(obj, mbd); // 5. population: @Autowired applies
7initializeBean(obj, beanName, mbd); // 6. Aware + init + proxying
8addSingleton(beanName, obj); // 7. publish into the primary cache
9return getSingleton(beanName); // 8. hand it to the caller
Variables now
beanNameuserService
singletonObjects{orderService=..., ...}
allowCircularReferencesfalse
Call stack
1getBean
2doGetBean
1This first line is the fork between fast and slow: the container checks the table called singletonObjects, which is the fruit of step ⑦. It is also the coordinate for the whole article — the lifecycle is precisely the road between step ④ and step ⑦.
25 / 122

First call versus second call, drawn once more as an animation — notice how frame ④ (cache hit) and frame ⑤ (prototypes never pool) jointly produce every destroy symptom in Section 5:

26 / 122
Animation
Animation · Eight stations once, a cache read the second time
Animation · Eight stations once, a cache read the second time
27 / 122
Section
3. Runnable verification: watch it live its whole life
28 / 122

Talk is cheap; run it. This bean implements three callback interfaces and layers annotation callbacks on top:

29 / 122
java
package com.example.lifecycle;import org.springframework.beans.factory.BeanNameAware;import org.springframework.beans.factory.DisposableBean;import org.springframework.beans.factory.InitializingBean;import javax.annotation.PostConstruct;import javax.annotation.PreDestroy;public class LifecycleDemoBean implements BeanNameAware, InitializingBean, DisposableBean {    private String beanName;    public LifecycleDemoBean() {        System.out.println("[1] constructor: instantiation");    }    @Override    public void setBeanName(String name) {        this.beanName = name;        System.out.println("[2] BeanNameAware#setBeanName -> " + name);    }    @PostConstruct    public void postConstruct() {        System.out.println("[3] @PostConstruct");    }    @Override    public void afterPropertiesSet() {        System.out.println("[4] InitializingBean#afterPropertiesSet");    }    public void customInit() {        System.out.println("[5] initMethod (customInit)");    }    @PreDestroy    public void preDestroy() {        System.out.println("[6] @PreDestroy");    }    @Override    public void destroy() {        System.out.println("[7] DisposableBean#destroy");    }    public void customDestroy() {        System.out.println("[8] destroyMethod (customDestroy)");    }}
30 / 122

The config class and the bootstrap code:

31 / 122
java
@Configurationpublic class LifecycleConfig {    @Bean(initMethod = "customInit", destroyMethod = "customDestroy")    public LifecycleDemoBean demoBean() {        return new LifecycleDemoBean();    }}public class Main {    public static void main(String[] args) {        // try-with-resources auto-calls close() at block end, triggering destruction        try (AnnotationConfigApplicationContext ctx =                     new AnnotationConfigApplicationContext(LifecycleConfig.class)) {            System.out.println("[*] container ready, safe to fetch beans");        }        System.out.println("[*] container closed");    }}
32 / 122

The console output mapped onto the stages:

33 / 122
Code
Codetext
[1] constructor: instantiation            <- stage 1: instantiation[2] BeanNameAware#setBeanName -> demoBean  <- stage 3: Aware callbacks[3] @PostConstruct                        <- stage 5: init (fired by a BeanPostProcessor)[4] InitializingBean#afterPropertiesSet   <- stage 5: init (after @PostConstruct)[5] initMethod (customInit)               <- stage 5: init (runs last)[*] container ready, safe to fetch beans[6] @PreDestroy                           <- stage 8: destroy (runs first)[7] DisposableBean#destroy                <- stage 8: destroy[8] destroyMethod (customDestroy)         <- stage 8: destroy (runs last)[*] container closed
Notes
  • Init callbacks have a fixed order: @PostConstruct → afterPropertiesSet → initMethod
  • Destroy callbacks have a fixed order: @PreDestroy → destroy → destroyMethod
  • Note [2] precedes [3]: Aware callbacks run before any init callback
34 / 122

Now replay that log against the animation — each frame maps onto one output line: [1] is frame one, [6][7][8] are the final frame. When a second read of the code stalls, use it to locate "which line am I on":

35 / 122
Animation
Animation · The eight stages matched line by line to the log
Animation · The eight stages matched line by line to the log
36 / 122
Tip

if you change the bean's scope to prototype, lines [6][7][8] never print. That is not a bug — it is the destroy symmetry we cover next.

37 / 122
Section
4. At which step is the AOP proxy created?
38 / 122

This is a favorite interview follow-up. The answer is precise: during the "after-init" stage, by AbstractAutoProxyCreator#postProcessAfterInitialization.

39 / 122
Code
Codejava
// AbstractAutoProxyCreator (parent of AbstractAdvisorAutoProxyCreator)@Overridepublic Object postProcessAfterInitialization(@Nullable Object bean, String beanName) {    if (bean != null) {        Object cacheKey = getCacheKey(bean.getClass(), beanName);        if (this.earlyProxyReferences.remove(cacheKey) != bean) {            return wrapIfNecessary(bean, beanName, cacheKey); // wrap into a proxy if needed        }    }    return bean;}
Notes
  • wrapIfNecessary checks whether the bean matches any advice (@Transactional, @Cacheable...), and if so a ProxyFactory builds a JDK or CGLIB proxy
  • The proxy is "pulled over" the original object only after its initialization completes, so the proxy's target points at that fully wired object
  • Consequently: whatever you inject elsewhere, whatever you fetch from the container, is the proxy — not the raw object you new-ed

Trap: this explains a beginner's confusion — "getClass() prints UserService$$EnhancerBySpringCGLIB, but I wrote UserService". Because the proxy is the very thing that got swapped into the singleton pool.

40 / 122
Section
5. Destroy symmetry: why one has it and the other doesn't
41 / 122

The destroy stage is full of symmetry — and traps. Four symptoms, one underlying cause (whether the container still holds a reference). Pair them up first and the three rules below become obvious:

42 / 122
Match
MatchDestroy symptoms matched to their causeMatched 0/6 · Missed 0
Left: what you actually see in the console. Right: the real reason
Pick a card on the left first
43 / 122
  • Why are prototypes not destroyed? Because the container lets go immediately after creating one — it keeps no reference, so there is nothing to destroy. Release resources yourself with try-with-resources
  • Why reverse order for singletons? DefaultSingletonBeanRegistry records order in disposableBeans, and destroySingletons() iterates backwards
  • If @PreDestroy did not run, debug in three steps: "was close() called → is it a singleton → was it ever created"
44 / 122

Reverse ordering is hard to feel from prose alone, so the animation plays "who was built first, who is torn down first" — watch frames ③ and ④ in particular:

45 / 122
Animation
Animation · Why destruction runs in reverse
Animation · Why destruction runs in reverse
46 / 122
Warning

in Spring Boot, @PreDestroy relies on close() being triggered on a normal JVM shutdown. If the process is force-killed with kill -9, destroy callbacks will not run — critical data must rely on try/finally or an external safety net, never on @PreDestroy alone.

47 / 122

Isolate the single most misremembered fork — singleton versus prototype — into one side-by-side picture: on the left the singleton that walks the pipeline once and then sits in the pool; on the right the prototype that re-runs everything per lookup while the container lets go immediately:

48 / 122
Diagram
Figure · Singleton versus prototype
Figure · Singleton versus prototype
49 / 122
类比|Analogy

a singleton is the office shared printer — configured once at installation (steps 1–7), used by everyone afterwards, and the company (the container) maintains it and eventually scraps it. A prototype is a disposable takeaway box — you get a fresh one per order, and no courier comes back to collect it. Whether you rinse the box before binning it (releasing a prototype's resources) is therefore always your own business.

50 / 122
Section
6. In practice: count bean creation time with a BeanPostProcessor
51 / 122

Want to find the number-one suspect for slow startup? One custom BeanPostProcessor suffices — it straddles both ends of the lifecycle:

52 / 122
Code
Codejava
package com.example.lifecycle;import org.springframework.beans.factory.config.InstantiationAwareBeanPostProcessor;import org.springframework.stereotype.Component;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;@Componentpublic class TimingBeanPostProcessor implements InstantiationAwareBeanPostProcessor {    private final Map<String, Long> startTimes = new ConcurrentHashMap<>();    // Lifecycle start: before instantiation    @Override    public Object postProcessBeforeInstantiation(Class<?> beanClass, String beanName) {        startTimes.put(beanName, System.nanoTime());        return null; // returning null = do not take over, continue the standard flow    }    // Lifecycle end: after initialization    @Override    public Object postProcessAfterInitialization(Object bean, String beanName) {        Long start = startTimes.remove(beanName);        if (start != null) {            long costMs = (System.nanoTime() - start) / 1_000_000;            if (costMs > 50) {                System.out.println("[slow] " + beanName + " created in " + costMs + "ms");            }        }        return bean;    }}
Notes
  • Returning null from postProcessBeforeInstantiation means "I won't interfere; create it normally" — the key difference from "return a proxy and short-circuit"
  • postProcessAfterInitialization is the last link of the lifecycle; by then bean is in its final form (possibly already a proxy)
  • Print only beans over 50ms to avoid flooding; run it once and your startup-optimization checklist appears

Note: this kind of "cross-cutting" logic is AOP in embryo — BeanPostProcessor is the official way Spring hands you to insert into every bean's lifecycle.

53 / 122
Section
7. Interview rapid-fire: two frequent comparisons
54 / 122
Table
QuestionAnswer
Which runs first, @PostConstruct or afterPropertiesSet?@PostConstruct. The former fires via CommonAnnotationBeanPostProcessor (a BPP) at before-init; the latter is later, in invokeInitMethods
Can the constructor see injected dependencies?Not for field/setter injection — dependencies are not filled yet. Yes for constructor injection, because they arrive as constructor arguments
55 / 122
  • @PostConstruct precedes afterPropertiesSet because "BPP callbacks" as a whole run before "InitializingBean callbacks"
  • To do dependency-related work in a constructor, the only correct approach is constructor injection: public UserService(UserDao dao) — dao is ready before the constructor runs
  • With @Autowired field injection, accessing the field in the constructor yields null
56 / 122
Section
8. Trap: relying on proxy powers inside @PostConstruct fails
57 / 122
Trap

calling a same-class method annotated with @Transactional inside @PostConstruct (a self-invocation) does not start a transaction. The reason is direct: @PostConstruct runs at "before-init", while the AOP proxy is created only at "after-init" (Section 4). At that moment calls go to the raw object, this.xxx() bypasses the proxy, and the advice chain never gets a chance. The same applies to self-invoked @Async or @Cacheable in @PostConstruct.

58 / 122
Tip

to "run once with a transaction or asynchronously at startup", move it into the step-12 ContextRefreshedEvent listener, or inject your own proxy (ObjectProvider<MyService> / @Lazy) so the call goes through the proxy.

59 / 122
Section
9. Try it: run a bean's whole life yourself
60 / 122

The demo below turns the eight stages into an interactive timeline; switch the "prototype" option to see the destroy difference at a glance:

61 / 122
Kernel lab
TeaVMRun a bean's whole life yourselfidle
Switch the prototype option and watch the destroy stage differ
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
62 / 122

Then switch to the destroy argument to isolate the destroy triple (@PreDestroy → destroy → destroyMethod) and confirm it mirrors the init order exactly.

63 / 122

Knowing the eight stages is not enough — you also need to know who pushes a bean through them. The answer lives in the previous article: step 11 of refresh(), finishBeanFactoryInitialization, walks every non-lazy singleton and drives it down the whole pipeline. This lab spreads the twelve steps out; watch step 11 and see beans pop into existence batch by batch:

64 / 122
Kernel lab
TeaVMWhich refresh step drives the lifecycleidle
Pick 'the four that matter' to see finishBeanFactoryInitialization push every singleton through the eight stages, then 'when one step fails' to watch half-built beans get reclaimed
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
65 / 122

Section 8 says "the AOP proxy is born at after-init". Who does the wrapping? The auto-proxy creator — a BeanPostProcessor whose only job is to decide "should this bean be wrapped in a proxy". The lab below unfolds its checkpoint logic: first the candidate check, then the interception inside post-processing:

66 / 122
Kernel lab
TeaVMThe exact second the proxy gets pulled onidle
Start with 'candidate check' to see which beans get wrapped, then 'interception in BPP' — that is the timing of Section 8
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
67 / 122

Finally, back to the population stage itself. How a dependency is injected decides whether the object is still a half-finished shell after step ① — constructor injection is complete at birth, field injection only becomes complete at step ②:

68 / 122
Kernel lab
TeaVMThree injection styles, three timelinesidle
Cycle through constructor / setter / field and compare against lines [1] and [2] of the log in Section 3
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
69 / 122

The prototype-does-not-destroy rule from Section 5 is really a scope question. This lab puts all four scopes side by side and counts instances: pick "count the instances" to watch the counter move on every lookup, then "who destroys it" to see the destroy log appear exactly once:

70 / 122
Kernel lab
TeaVMFour scopes: how many instances, who cleans upidle
Count first to feel the singleton/prototype difference, then switch to 'who destroys it' to verify Section 5
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
71 / 122

One more special case of "when is it actually born": a FactoryBean definition registers a factory, yet getBean hands you a product. So when does the product exist? Pick "when it really produces" and line it up against getObjectForBeanInstance, the last cell of the debugger in Section 2:

72 / 122
Kernel lab
TeaVMWhen does a FactoryBean actually produce?idle
Production happens on the first product lookup, not during refresh; then switch to the & prefix to see the other spelling
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
73 / 122
Section
9.1 Now from the command line: type the lifecycle out
74 / 122

This console is wired to the real container running in your browser; beans, di and query are answered by the kernel on the spot, so every command below tests one claim of this article:

75 / 122
Console
76 / 122
Tip

run lab beanscope destroy and lab beanscope probe back to back — the first shows a whole block of destroy log missing, the second shows the instance count still climbing. Every answer in the Section 5 pairing game comes out of these two commands.

77 / 122
Section
11. Sandbox: where the lifecycle actually costs you
78 / 122

Beginners ask the same two questions: "why does my app take 40 seconds to boot?" and "why did @PreDestroy stop printing after I added @Scope("prototype")?" Neither answer is in your code — both come from how scope and init timing are chosen. The sandbox below folds "scope + lazy or not" into one three-position switch; each position shows startup cost, lookup cost and the destroy log side by side:

79 / 122
Sandbox
SandboxScope × init timing sandbox
Result
Startup: 38s
# step 11 preInstantiateSingletons news 1240 singletons in one go
[slow] dataSource created in 2100ms <- one of the culprits
getBean(userService): 0.01ms (hit in singletonObjects)
On close: @PreDestroy ✔ all fire
The default trade: pay at startup, get speed at runtime. Make heavy beans @Lazy to cut the bill.
80 / 122
Note

the numbers are illustrative, the conclusion is real — a singleton's creation cost lands entirely on the startup moment, a prototype's cost is spread over every lookup. The two failure modes point in opposite directions: "booting takes forever" versus "memory keeps climbing and not one destroy line appears".

81 / 122
Section
12. Check yourself
82 / 122

A warm-up question, straight from the symmetry table in Section 5:

83 / 122
Quiz
Check yourselfA bean has an @PreDestroy method, yet closing the application prints nothing. Which option can NOT be the reason?
Pick one — you get feedback right away
84 / 122

Now a combined question threading Sections 3, 4, 7 and 8 together:

85 / 122
Quiz
Check yourselfOne bean has a no-arg constructor, an @Autowired field dao, an @PostConstruct init(), an InitializingBean#afterPropertiesSet() and a @Transactional annotation. Which statement is correct?
Pick one — you get feedback right away
86 / 122
Section
10. Which lifecycle callback does this logic belong in?
87 / 122
Decision
Decisionyou have logic that must **read an injected config value and validate it**, throwing an exception to block startup on failure. Where should it go?
88 / 122
Summary

a bean's life has only eight stages and five callback types. Remember three threads — order: instantiation → population → Aware → before/after init → pooled → destroy; sequence: @PostConstruct before afterPropertiesSet before initMethod, and the same on the destroy side; timing: the AOP proxy is born at "after-init", so any proxy-dependent logic must stay out of @PostConstruct. Etch these three in and lifecycle questions stop being memorization and become reasoning.

89 / 122
Section
13. Common errors, searchable by exact wording
90 / 122

Copy every snippet below straight into a search box — do not paraphrase or shorten it. Beginners stall in three places: an annotation that silently does nothing, a null field inside a constructor, and a prototype that never gets destroyed.

91 / 122
Table
Error text (fragment)What really happened30-second fixRead more in
@PostConstruct is written but prints nothing, with no error eitherSince Spring Boot 3 / JDK 9+ the package moved from javax.annotation to jakarta.annotation; you imported the wrong one, so the container simply does not recognise the annotationCheck the import: Boot 3 needs import jakarta.annotation.PostConstruct;, Boot 2 needs javax.annotation.PostConstruct. In IDEA, Alt+Enter to re-importSection 2 · #16 Hello Spring Boot
java.lang.NoClassDefFoundError: javax/annotation/PostConstructA plain Java SE project or a slim Spring 5 + JDK 11 image lacks the annotation class itself — the type is not on the classpathBoot 2: add org.apache.tomcat:annotations-api; Boot 3: add jakarta.annotation:jakarta.annotation-api (usually already pulled in by a starter)Level-2 exercise · #36 Packaging & deploy
NullPointerException: Cannot invoke "com.example.UserDao.findById(Long)" because "this.dao" is nullYou used a field-injected dependency inside the no-arg constructor. The constructor is step 1, population is step 2, so dao is not in yetSwitch to constructor injection: public UserService(UserDao dao) { this.dao = dao; }, or move the logic into @PostConstructSection 7 · #6 IoC and DI
After adding @Scope("prototype"), @PreDestroy / destroy() never print againThe container lets go of a prototype immediately and keeps no reference, so there is nothing to call back — by design, not a bugClose it yourself: make the prototype implement AutoCloseable and fetch it with try-with-resources, or go back to a stateless singletonSection 5 · the sandbox above
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'userService': Invocation of init method failed; nested exception is java.lang.IllegalStateException: ...One of your init callbacks (@PostConstruct / afterPropertiesSet / initMethod) threw, and the container wrapped it as a creation failureRead only what follows nested exception — the culprit is your own validation, not Spring. A startup validation failure should stop the processSection 3 · #8 The twelve steps of refresh()
APPLICATION FAILED TO START plus The dependencies of some of the beans in the application context form a cycleTwo beans wait on each other during creation, and constructor injection cannot offer an early referenceBreak one edge along the cycle printed in the log, or inject one side with @Lazy#10 Circular dependency · #6 IoC and DI
92 / 122
Tip

five of those six lines never appear as a red exception — they show up as silence (no log, no callback). When debugging lifecycle issues, first set logging.level.org.springframework.beans=DEBUG and check whether the container ever reached the relevant stage for that bean. Most "my annotation stopped working" cases are really "it was never registered".

93 / 122

Row five is the only one that shouts, and its culprit sits in a specific cell of the lifecycle. Skip the answer and click the guilty frame:

94 / 122
Triage
Error triageBeanCreationException: Invocation of init method failed

You add a @PostConstruct that validates configuration. It works on your laptop; the test environment dies on startup with a very long message, and your teammate is already blaming the database.

APPLICATION FAILED TO START
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'reportExporter' defined in class path resource [com/example/AppConfig.class]: Invocation of init method failed; nested exception is java.lang.NullPointerException: Cannot invoke "com.example.ConfigSnapshot.storageBucket()" because "this.snapshot" is null
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1786)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:601)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:333)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:322)
at com.example.report.ReportExporter.init(ReportExporter.java:47)
Caused by: java.lang.NullPointerException: Cannot invoke "com.example.ConfigSnapshot.storageBucket()" because "this.snapshot" is null
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
95 / 122
Section
14. Practice in three levels
96 / 122
Section
Level 1 · Follow along
97 / 122

Goal: with a minimal runnable project, print the whole chain "constructor → Aware → @PostConstruct → afterPropertiesSet → initMethod → the destroy triple", then watch a prototype lose the last three lines.

98 / 122

Step one, a Maven pom.xml that needs only spring-context and the jakarta annotations:

99 / 122
xml
<dependencies>    <dependency>        <groupId>org.springframework</groupId>        <artifactId>spring-context</artifactId>        <version>6.1.8</version>    </dependency>    <dependency>        <groupId>jakarta.annotation</groupId>        <artifactId>jakarta.annotation-api</artifactId>        <version>3.0.0</version>    </dependency></dependencies>
100 / 122

Step two, the bean (src/main/java/com/example/demo/LifeBean.java):

101 / 122
java
package com.example.demo;import jakarta.annotation.PostConstruct;import jakarta.annotation.PreDestroy;import org.springframework.beans.factory.BeanNameAware;import org.springframework.beans.factory.DisposableBean;import org.springframework.beans.factory.InitializingBean;import org.springframework.beans.factory.annotation.Value;public class LifeBean implements BeanNameAware, InitializingBean, DisposableBean {    @Value("${app.tag:untagged}")    private String tag;                       // field injection: unreadable in the constructor    public LifeBean() {        System.out.println("1 constructor     | tag = " + tag);    }    @Override    public void setBeanName(String name) {        System.out.println("2 BeanNameAware   | name = " + name);    }    @PostConstruct    public void initAnnotation() {        System.out.println("3 @PostConstruct  | tag = " + tag);    }    @Override    public void afterPropertiesSet() {        System.out.println("4 afterPropertiesSet");    }    public void customInit() {        System.out.println("5 initMethod");    }    @PreDestroy    public void preDestroy() {        System.out.println("6 @PreDestroy");    }    @Override    public void destroy() {        System.out.println("7 DisposableBean#destroy");    }    public void customDestroy() {        System.out.println("8 destroyMethod");    }}
102 / 122

Step three, the config class and launcher (new Main.java in the same package):

103 / 122
java
package com.example.demo;import org.springframework.context.annotation.AnnotationConfigApplicationContext;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.context.annotation.PropertySource;@Configuration@PropertySource(value = "classpath:app.properties", ignoreResourceNotFound = true)public class Main {    @Bean(initMethod = "customInit", destroyMethod = "customDestroy")    public LifeBean singletonLife() {        return new LifeBean();    }    public static void main(String[] args) {        try (AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(Main.class)) {            System.out.println("---- container ready ----");        }        System.out.println("---- container closed ----");    }}
104 / 122

Run main. Expected output — every line must match, otherwise your import came from the wrong package:

105 / 122
text
1 constructor     | tag = null2 BeanNameAware   | name = singletonLife3 @PostConstruct  | tag = untagged4 afterPropertiesSet5 initMethod---- container ready ----6 @PreDestroy7 DisposableBean#destroy8 destroyMethod---- container closed ----
106 / 122

Acceptance checklist: ① you can explain why line 1 prints null while line 3 has a value; ② change the @PostConstruct import to a package that does not exist — the app still starts but one line disappears, and you have reproduced "silent failure" yourself; ③ say who invokes lines 6, 7 and 8 respectively.

107 / 122
Section
Level 2 · Variants
108 / 122

Change exactly one thing per run and the conclusion flips completely:

109 / 122
  1. Add @Scope("prototype") to singletonLife() (remember import org.springframework.context.annotation.Scope;). You will observe lines 1-5 printing as usual and not a single one of 6/7/8; call ctx.getBean(LifeBean.class) twice and 1-5 repeats verbatim. That is the "the container lets go immediately" rule from Section 5 and the comparison figure.
  2. Print System.out.println(this.hashCode()) inside @PostConstruct, then compare it with the identity of what getBean returns for a class carrying @Transactional. You will observe the two differ — the proxy is pulled on after your init callback ran.
  3. Make customInit() throw new IllegalStateException("config validation failed"). You will observe the entire startup aborting with BeanCreationException: Error creating bean with name 'singletonLife': Invocation of init method failed, while lines 6/7/8 still execute — the "leave no half-built context behind" behaviour from the previous article.
110 / 122

Tip: after variant 1, revisit the sandbox in Section 11, flip the switch to prototype, and confirm both tell exactly the same story.

111 / 122
Section
Level 3 · Build one
112 / 122

Write yourself a "lifecycle probe" component so that any future project's slow boot or leaking resource can be measured rather than guessed.

113 / 122

Requirements:

114 / 122
  • A LifecycleProbe implementing InstantiationAwareBeanPostProcessor that records each bean's "instantiated at" and "initialization finished at" timestamps
  • Additionally distinguish three facts: whether @PostConstruct ran (inferable from the presence of CommonAnnotationBeanPostProcessor), and whether the bean got proxied (compare getClass() of the object returned from postProcessAfterInitialization with the one passed in)
  • On shutdown (via SmartLifecycle or a ContextClosedEvent listener) print a summary table: top 10 slowest beans, how many were proxied, and which lazy beans were never created
  • Not one line of business bean code may change
115 / 122

Acceptance checklist: ① run it on an empty Boot project and the top 10 includes auto-configured beans such as dataSource; ② mark one bean prototype, fetch it three times, and the table reports three creations; ③ add @Transactional to a bean and it shows up in the "proxied" count; ④ the probe itself costs under 5% extra startup time (verify against the baseline total).

116 / 122
Section
15. Self-check
117 / 122
Self-check

from memory, name the eight stages in order and say which callback sits between "population" and "before-init".

118 / 122
Self-check

who actually invokes @PostConstruct? Why does it count as "before-init" rather than its own stage?

119 / 122
Self-check

why is a field-injected value always null inside a constructor while constructor injection is not? How does "complete at birth" differ between the two styles?

120 / 122
Self-check

why does a prototype bean's destruction never run? If it genuinely holds resources, what are your two legitimate ways out?

121 / 122
Self-check

at which stage is the AOP proxy created? How does that single moment explain both "@Transactional self-invocation fails inside @PostConstruct" and "getClass() prints a CGLIB name"?

122 / 122

Mantra: **goods in first (instantiation), shelves stocked next (population), a health check before opening (initialization), the badge issued last (proxy) — and prototypes never get a retirement certificate**.