Command Palette

Search for a command to run...

[Spring Boot Basics] Spring Bean Scopes and the Bean Lifecycle: singleton, prototype, @PostConstruct and @PreDestroy

A bean scope answers one question — how many instances of this class the container keeps, and for how long. Get it wrong and nothing fails at startup: the application runs, the tests pass, and one user sees another user's data. The lifecycle answers the companion question — when your initialisation and cleanup code runs relative to injection, to the post-processors, and to shutdown.

Both are places where tutorials are confidently wrong, so nothing below is written from memory. Every ordering, every instance count and every hash code in this article came out of a traced run on Spring Boot 4.1.1, and the outputs are pasted from the terminal.

A bean on a lifeline entering through @PostConstruct and leaving through @PreDestroy

The toolchain is Spring Boot 4.1.1 (Spring Framework 7.0.9, embedded Tomcat 11.0.24) on OpenJDK 21.0.6, built with the Gradle wrapper. @PostConstruct and @PreDestroy come from jakarta.annotation, not the old javax.annotation — the artifact is jakarta.annotation-api:3.0.0 and spring-boot-starter pulls it in for you.

Singleton is the default, and it does not mean one per JVM

Declare a bean with no @Scope at all and you get singleton:

src/main/java/com/example/lab/twocontexts/Registry.java
package com.example.lab.twocontexts;
 
import org.springframework.stereotype.Component;
 
@Component
public class Registry {
    public String id() {
        return getClass().getSimpleName() + "@" + Integer.toHexString(System.identityHashCode(this));
    }
}

"Singleton" here is a container-scoped word, not a JVM-scoped one. The container keeps exactly one instance per ApplicationContext, and nothing stops you having two contexts. Build both in one JVM and ask each for the same bean:

Java
var a = new AnnotationConfigApplicationContext(Registry.class);
var b = new AnnotationConfigApplicationContext(Registry.class);
 
Registry a1 = a.getBean(Registry.class);
Registry a2 = a.getBean(Registry.class);
Registry b1 = b.getBean(Registry.class);
Text
context A, 1st lookup : Registry@5aebe890
context A, 2nd lookup : Registry@5aebe890
context B, 1st lookup : Registry@65d09a04
a1 == a2 : true
a1 == b1 : false

Two containers, two instances, one JVM. That is not an edge case you have to go looking for — @SpringBootTest with different configurations, a parent/child web context, and any test class that changes a property all build separate contexts, and Boot's context cache in tests keeps several of them alive at once.

So a Spring singleton is not the Gang of Four singleton. The GoF pattern enforces one instance per class loader through a private constructor and a static accessor; the class itself makes the guarantee, and the guarantee is global. A Spring singleton is an ordinary class with an ordinary public constructor — new Registry() still works and still gives you an unmanaged object — and the "only one" promise is a bookkeeping decision made by one bean factory about one bean name. Two names for the same class in the same context give you two singletons as well.

The practical consequence is the one worth carrying: a singleton bean is shared by every thread in that container, so its fields are shared mutable state unless you make them otherwise. Keep singletons stateless, or make the state thread-safe on purpose.

The six scopes, and how long each one lives

Two scopes work everywhere. Three more exist only in a web application, and one more only when WebSocket support is on the classpath.

ScopeHow many instancesWho creates one, and whenWhen it is destroyed
singletonone per ApplicationContextthe container, eagerly during refresh()context.close()
prototypeone for every injection point and every getBean() callthe container, on demandnever — the container does not keep a reference
requestone per HTTP requestthe container, on first access inside the requestwhen the response is finished
sessionone per HTTP sessionthe container, on first access inside the sessionwhen the session is invalidated or times out
applicationone per ServletContextthe container, on first accesswhen the ServletContext is destroyed
websocketone per WebSocket sessionthe container, on first accesswhen the WebSocket session closes

application looks like singleton and is not the same thing. An application-scoped bean is stored as a ServletContext attribute, so every context inside that servlet container sees it; a singleton lives in one bean factory. In a plain Boot application with one context the two coincide, which is exactly why the distinction only bites in the setups where it matters.

Four scopes laid out over one container run, showing how many instances exist and how long each lives

The names are constants, not magic strings — ConfigurableBeanFactory.SCOPE_SINGLETON and SCOPE_PROTOTYPE, WebApplicationContext.SCOPE_REQUEST, SCOPE_SESSION and SCOPE_APPLICATION — and @Scope("prototype") is just the literal spelled out.

singleton and prototype, counted with getBean()

The cleanest way to count instances is to make the bean announce itself. Two beans of the same class, one per scope, both with lifecycle hooks:

Resource.java
public class Resource {
    private final String kind;
 
    public Resource(String kind) { this.kind = kind; }
 
    public String id() { return kind + "@" + Integer.toHexString(System.identityHashCode(this)); }
 
    @PostConstruct
    public void open() { System.out.println("  @PostConstruct  " + id()); }
 
    @PreDestroy
    public void close() { System.out.println("  @PreDestroy     " + id()); }
}
ScopeConfig.java
@Configuration
public class ScopeConfig {
 
    @Bean
    public Resource singletonResource() { return new Resource("singleton"); }
 
    @Bean
    @Scope("prototype")
    public Resource prototypeResource() { return new Resource("prototype"); }
}

Three getBean() calls for each, then close the context:

Text
refresh:
  @PostConstruct  singleton@273e7444
three getBean() calls for each scope:
  singleton -> singleton@273e7444
  singleton -> singleton@273e7444
  singleton -> singleton@273e7444
  @PostConstruct  prototype@3ba987b8
  prototype -> prototype@3ba987b8
  @PostConstruct  prototype@3f191845
  prototype -> prototype@3f191845
  @PostConstruct  prototype@5f049ea1
  prototype -> prototype@5f049ea1
close:
  @PreDestroy     singleton@273e7444
done — count the @PreDestroy lines above

Four facts fall out of those fourteen lines. The singleton was built before the first getBean() — during refresh(), eagerly, which is why a broken singleton fails your startup rather than your first request. The prototype was built during each getBean(), three times, three hash codes. @PostConstruct ran on every prototype. And exactly one @PreDestroy line exists in the whole run, for the singleton. Hold on to that last one; it gets its own section.

request, session and application, counted with curl

The web scopes need a real HTTP request to exist, so the only honest way to demonstrate them is to run the application and call it. Five beans, one per scope, injected into one controller:

ScopeController.java
@RestController
public class ScopeController {
 
    private final AppSingleton singleton;
    private final ObjectProvider<PerPrototype> prototypes;
    private final PerRequest perRequest;
    private final PerSession perSession;
    private final PerApplication perApplication;
 
    // constructor omitted
 
    @GetMapping("/scopes")
    public String scopes() {
        return singleton.id() + "\n"
             + prototypes.getObject().id() + "\n"
             + perRequest.id() + "\n"
             + perSession.id() + "\n"
             + perApplication.id() + "\n";
    }
}

PerRequest, PerSession and PerApplication carry @RequestScope, @SessionScope and @ApplicationScope; the session bean also increments a counter on every read. Run the jar and make three calls through one cookie jar, then a fourth without one:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8089
 
for i in 1 2 3; do curl -s -b jar.txt -c jar.txt http://localhost:8089/scopes; done
curl -s http://localhost:8089/scopes
Text
--- call 1
singleton  @54574977
prototype  @6536eb57
request    @6bf11981
session    @6fce8027 hits=1
application@668220eb
--- call 2
singleton  @54574977
prototype  @32bc6a3e
request    @7306df4f
session    @6fce8027 hits=2
application@668220eb
--- call 3
singleton  @54574977
prototype  @2e0dc9a
request    @c2722b6
session    @6fce8027 hits=3
application@668220eb
--- fourth call, no cookie jar
singleton  @54574977
prototype  @9f6a3e8
request    @7315ca6
session    @30ec898 hits=1
application@668220eb

Read it column by column. The singleton and the application bean never change across four calls. The prototype changes on every call, because the controller asks for a fresh one each time. The request bean changes on every call including the fourth. The session bean is identical for calls 1 to 3 and hits climbs 1, 2, 3 — those three requests carried the same JSESSIONID:

Text
#HttpOnly_localhost	FALSE	/	FALSE	0	JSESSIONID	0A6A7989C5DC95834FFFE903071B2761

Drop the cookie and call 4 gets a new session and a new session bean with hits=1. That is the entire mechanism: the request scope is keyed on the servlet request, the session scope on the session id in a cookie.

Within one request the request-scoped bean is genuinely one object, not a new one per read:

Text
1st read in this request : request    @30808d08
2nd read in this request : request    @30808d08

The server-side log for two calls in one session, ending with an explicit session.invalidate(), shows the destruction side:

Text
  request scope   @PostConstruct request    @7af7b378
  session scope   @PostConstruct session    @7ec828fe
  request scope   @PreDestroy    request    @7af7b378
  request scope   @PostConstruct request    @2c8f6553
  request scope   @PreDestroy    request    @2c8f6553
  session scope   @PreDestroy    session    @7ec828fe

Every request bean is created and destroyed inside its own request. The session bean is created once and destroyed only when the session ends — on invalidate(), on timeout, or when the context shuts down.

Why @PreDestroy never fires on a prototype

Go back to the count in the getBean() run: three prototypes were created, three @PostConstruct calls ran, and zero @PreDestroy calls did. This is not a bug and not a configuration you can switch on. It is stated in the Spring reference documentation and it follows from how the container works.

For every other scope the container keeps a registry of the instances it created so that it can call the destruction callbacks later. For prototype it deliberately does not. The container instantiates the object, configures it, runs the initialisation callbacks, hands it to you — and forgets it. The only reference is now yours, which is also why a prototype is garbage-collected the moment you stop holding it, exactly like an object from new.

So @PreDestroy, DisposableBean.destroy() and @Bean(destroyMethod = ...) are all silently dead on a prototype-scoped bean. If a prototype holds something that must be released — a socket, a file handle, a connection — you have three options:

  • Do not make it a prototype. Most of the time the thing wanting a short life is a plain object your code creates with new, not a bean.
  • Release it yourself. Make the class AutoCloseable and use try-with-resources, which is clearer than any lifecycle hook.
  • Ask the container to do it explicitly with beanFactory.destroyBean(instance), which runs the destruction callbacks on an object the container is not tracking. This requires you to know when "later" is, and if you know that you probably did not need a bean.

⚠️ A prototype injected into a singleton is destroyed even less than that: it never even becomes garbage, because the singleton holds the reference for the whole life of the application. That is the next section, and it is the single most common Spring scoping bug.

The singleton that holds a prototype

Here is a prototype-scoped bean and an ordinary singleton service that takes it in its constructor:

src/main/java/com/example/lab/trap/Worker.java
package com.example.lab.trap;
 
@Component
@Scope("prototype")
public class Worker {
    public String id() { return "Worker@" + Integer.toHexString(System.identityHashCode(this)); }
}
BrokenService.java
@Service
public class BrokenService {
    private final Worker worker;
 
    public BrokenService(Worker worker) { this.worker = worker; }
 
    public String handle() { return worker.id(); }
}

This compiles, starts and looks right. It is wrong, and the reason is one sentence: injection happens once, when the singleton is created. The container resolves Worker exactly once — it creates a fresh prototype at that moment, because that is what prototype means — and then stores the result in a final field that lives as long as the application. Every later call to handle() reads that same field.

Calling handle() three times, alongside the three fixes, gives this:

Text
call  injected prototype      ObjectProvider          @Lookup                 scoped proxy
1     Worker@19976a65         Worker@a1f72f5          Worker@4bc222e          ProxiedWorker@13bc8645
2     Worker@19976a65         Worker@15a04efb         Worker@16c069df         ProxiedWorker@2bec854f
3     Worker@19976a65         Worker@31edaa7d         Worker@26adfd2d         ProxiedWorker@3336e6b6

Column one never changes. The scope annotation is not ignored — one prototype really was created — it simply did its job once and then had no further say. Columns two, three and four change on every call, which is what the annotation led you to expect.

A singleton holding one injected prototype forever, against ObjectProvider fetching a fresh one per call

The bug this produces in real code is not "the object is stale". It is that the prototype was presumably prototype-scoped because it holds per-call state — a builder, a request context, an accumulating list — and now that state is shared across every thread in the application.

ObjectProvider, the default fix

Inject a provider instead of the bean. ObjectProvider<T> is a handle on the container's lookup for that type; calling getObject() runs the lookup, and for a prototype a lookup means a new instance.

ProviderService.java
@Service
public class ProviderService {
    private final ObjectProvider<Worker> workers;
 
    public ProviderService(ObjectProvider<Worker> workers) { this.workers = workers; }
 
    public String handle() { return workers.getObject().id(); }
}

Column two of the run above — three calls, three instances:

Text
Worker@a1f72f5
Worker@15a04efb
Worker@31edaa7d

This is the one to reach for by default. There is no subclass, no proxy, no @Bean plumbing, and the call site says out loud that a new object is being fetched — which is the honest description of what is happening. Note that in this article ObjectProvider is being used purely to control when the lookup happens; its other job, expressing an optional dependency, is a different subject.

@Lookup method injection

The older mechanism. Declare an abstract method that returns the prototype and annotate it; Spring subclasses your class with CGLIB and implements the method as a container lookup.

LookupService.java
@Service
public abstract class LookupService {
 
    @Lookup
    protected abstract Worker newWorker();
 
    public String handle() { return newWorker().id(); }
}

Column three of the same run:

Text
Worker@4bc222e
Worker@16c069df
Worker@26adfd2d

It works, and it keeps the prototype's type at the call site with no Spring type in the field. The costs are that your class must be non-final and subclassable, that it is usually abstract, and that the method is silently rewritten by the framework — which is a surprise to the next reader. Use it when you are adapting a class you cannot restructure; otherwise prefer ObjectProvider.

A scoped proxy on the bean itself

The other two fixes change the consumer. A scoped proxy changes the bean, which means every injection point is fixed at once:

ProxiedWorker.java
@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ProxiedWorker {
    public String id() { return "ProxiedWorker@" + Integer.toHexString(System.identityHashCode(this)); }
}

Now ProxiedWorker can be injected as an ordinary field and it still behaves like a prototype — column four:

Text
ProxiedWorker@13bc8645
ProxiedWorker@2bec854f
ProxiedWorker@3336e6b6

What is actually injected is not a ProxiedWorker:

Text
the injected field's runtime class : com.example.lab.trap.ProxiedWorker$$SpringCGLIB$$0

Spring registers a proxy under the bean name and puts the real bean behind it. The proxy is a CGLIB subclass of your class — it passes the type check at the injection point — and every method call on it goes to the container first to obtain the current target for the scope, then to that target. TARGET_CLASS is the mode you want for a class; ScopedProxyMode.INTERFACES produces a JDK dynamic proxy instead and only works when the bean is injected by interface type.

The trade-off is that the indirection is invisible. Someone reading private final ProxiedWorker worker; has no way to see that each call reaches a different object, and final methods cannot be intercepted by a CGLIB subclass, so a final method on the target silently escapes the proxy. Use a scoped proxy when the bean is injected in many places and you want the scope honoured everywhere by construction. Use ObjectProvider when it is injected in one or two places and you would rather the call site be explicit.

Why the web scopes need a proxy too

A @Controller or @Service is a singleton, so injecting a request-scoped bean into one is exactly the same problem — except that here it does not silently misbehave, it refuses to start. Take a request-scoped bean with no proxy mode:

RawRequestBean.java
@Component
@Scope("request")   // no proxyMode
public class RawRequestBean { /* ... */ }

and inject it into a singleton. Startup dies:

Text
org.springframework.beans.factory.support.ScopeNotActiveException: Error creating bean with name
'rawRequestBean': Scope 'request' is not active for the current thread; consider defining a scoped
proxy for this bean if you intend to refer to it from a singleton
 
Caused by: java.lang.IllegalStateException: No thread-bound request found: Are you referring to
request attributes outside of an actual web request, or processing a request outside of the
originally receiving thread?

That is a precise description of the situation: the container is building singletons during refresh(), on the main thread, where no HTTP request exists, so the request scope has nothing to resolve against.

This is why @RequestScope, @SessionScope and @ApplicationScope exist as separate annotations rather than as spellings of @Scope("request"). Each one is @Scope with proxyMode already defaulted to TARGET_CLASS:

Java
@Scope(WebApplicationContext.SCOPE_REQUEST)
public @interface RequestScope {
    @AliasFor(annotation = Scope.class)
    ScopedProxyMode proxyMode() default ScopedProxyMode.TARGET_CLASS;
}

Use those three annotations and the proxy is there by default, which is why the controller in the earlier section could take PerRequest as a plain constructor parameter and still see a different instance per call. ObjectProvider<PerRequest> works too, and is the alternative when you want no proxy in the picture at all.

The bean lifecycle in the order it really runs

The order is worth establishing by experiment rather than by recollection. One bean implementing every hook there is — BeanNameAware, BeanFactoryAware, ApplicationContextAware, InitializingBean, DisposableBean, plus @PostConstruct and @PreDestroy — declared with @Bean(initMethod = ..., destroyMethod = ...), with a BeanPostProcessor watching it, and every callback printing its own name in order:

Gadget.java
public class Gadget implements BeanNameAware, BeanFactoryAware, ApplicationContextAware,
                               InitializingBean, DisposableBean {
 
    private final Engine engine;
    private Tank tank;
 
    public Gadget(Engine engine) {
        this.engine = engine;
        Trace.step("constructor");
    }
 
    @Autowired
    public void setTank(Tank tank) { this.tank = tank; Trace.step("populate properties"); }
 
    @Override public void setBeanName(String name)              { Trace.step("BeanNameAware"); }
    @Override public void setBeanFactory(BeanFactory bf)         { Trace.step("BeanFactoryAware"); }
    @Override public void setApplicationContext(ApplicationContext c) { Trace.step("ApplicationContextAware"); }
 
    @PostConstruct public void postConstruct()    { Trace.step("@PostConstruct"); }
    @Override public void afterPropertiesSet()    { Trace.step("InitializingBean.afterPropertiesSet"); }
    public void customInit()                      { Trace.step("@Bean(initMethod)"); }
 
    public void work()                            { Trace.step("IN SERVICE"); }
 
    @PreDestroy public void preDestroy()          { Trace.step("@PreDestroy"); }
    @Override public void destroy()               { Trace.step("DisposableBean.destroy"); }
    public void customDestroy()                   { Trace.step("@Bean(destroyMethod)"); }
}
LifecycleConfig.java
@Configuration
public class LifecycleConfig {
 
    @Bean
    public static TraceBeanPostProcessor traceBpp() { return new TraceBeanPostProcessor(); }
 
    @Bean public Engine engine() { return new Engine(); }
    @Bean public Tank tank()     { return new Tank(); }
 
    @Bean(initMethod = "customInit", destroyMethod = "customDestroy")
    public Gadget gadget(Engine engine) { return new Gadget(engine); }
}

Running that against Boot 4.1.1 prints this, and the numbering is the program's own:

Text
    ---- context.refresh() ----
 1  constructor            (dependencies passed in: Engine)
 2  populate properties    (@Autowired setter: Tank)
 3  BeanNameAware          (bean name = gadget)
 4  BeanFactoryAware
 5  ApplicationContextAware
 6  BPP.postProcessBeforeInitialization
 7  @PostConstruct
 8  InitializingBean.afterPropertiesSet
 9  @Bean(initMethod)      customInit()
10  BPP.postProcessAfterInitialization
    ---- application running ----
11  IN SERVICE             work() called by the application
    ---- context.close() ----
12  @PreDestroy
13  DisposableBean.destroy
14  @Bean(destroyMethod)   customDestroy()
    ---- JVM about to exit ----

The fourteen ordered lifecycle steps, annotated with the hook that fires at each one

Five things in that trace repay attention.

Step 1 and step 2 are separate only because this bean has both. The constructor receives Engine; the @Autowired setter receives Tank afterwards, on an object that already exists. A bean with constructor injection alone has no step 2 at all — its dependencies arrive inside step 1, which is exactly why constructor injection can make fields final and setter injection cannot.

The three *Aware callbacks come after the object is fully populated and before any initialisation hook, in the fixed order name, factory, context. You will rarely implement them; knowing where they sit is what lets you read a stack trace that goes through one.

Three initialisation hooks run, in a fixed order, and they are not alternatives. If a bean declares all three they all fire: @PostConstruct, then afterPropertiesSet(), then the initMethod. The same holds in reverse at shutdown.

Step 10 is where proxies appear. postProcessAfterInitialization is allowed to return a different object, and that is the hook AOP uses: the bean the container stores is the proxy this step returned, not the object your constructor built. It is also why an @Transactional or @Async method called from inside the same class does not go through the proxy — this is the target, not the wrapper.

Steps 12 to 14 are conditional. They ran here because the program called ctx.close(). Leave the context open and none of them run, ever.

Does a BeanPostProcessor run before or after @PostConstruct?

The trace says before — step 6 against step 7 — and it is worth knowing that this is not an accident of ordering that you could flip.

@PostConstruct is not handled by the container directly. It is implemented by CommonAnnotationBeanPostProcessor, which is itself a BeanPostProcessor doing its work in postProcessBeforeInitialization. So the question "which runs first" is really "which post-processor is earlier in the chain", and the answer is fixed by PostProcessorRegistrationDelegate:

Java
// Finally, re-register all internal BeanPostProcessors.
sortPostProcessors(internalPostProcessors, beanFactory);
registerBeanPostProcessors(beanFactory, internalPostProcessors);

Spring's own annotation processors are pulled out of the sort and re-appended to the end of the chain. Running the same trace with a BeanPostProcessor declared PriorityOrdered at highest precedence and again with one carrying no ordering at all gives the same relative result both times:

Text
 5  ApplicationContextAware
 6  UNORDERED BPP.postProcessBeforeInitialization
 7  @PostConstruct
 8  InitializingBean.afterPropertiesSet

So a BeanPostProcessor you write sees the bean before its @PostConstruct has run, whatever order you give it. If you need to see a fully initialised bean, use postProcessAfterInitialization.

One other thing that second run shows: there is no customInit() and no customDestroy() line, because that configuration declared @Bean without initMethod or destroyMethod. Those two steps exist only when you ask for them.

Which lifecycle hook should you use?

For nearly all application code the answer is: the constructor, and @PostConstruct only when the constructor cannot do the job.

HookUse it whenWhy not
constructorrequired dependencies and any state you can set from them
@PostConstructwork that needs the fully injected object: validating config, warming a cache, opening a connectionruns after the object is already in a half-built state, so it cannot make fields final
InitializingBeanyou are writing framework codea Spring interface in your domain class, for zero benefit over @PostConstruct
@Bean(initMethod = ...)a third-party class you cannot annotateinvisible from the class itself
@PreDestroyreleasing what you acquired: flushing, closing, unsubscribingonly runs on a graceful shutdown, never on a prototype
DisposableBeanframework code againsame coupling problem as InitializingBean
@Bean(destroyMethod = ...)a third-party class with a close()/shutdown()usually unnecessary, because Spring infers it

The last row is the one people do not know about. destroyMethod on @Bean defaults to the constant AbstractBeanDefinition.INFER_METHOD, which means: if the bean class is AutoCloseable/Closeable, or simply has a public no-argument close() or shutdown() method, Spring calls it at shutdown without being told to. Setting destroyMethod = "" switches that off.

InferConfig.java
@Configuration
public class InferConfig {
 
    @Bean                                   // destroyMethod defaults to "(inferred)"
    public Pool inferredPool() { return new Pool("inferredPool"); }
 
    @Bean(destroyMethod = "")               // inference switched off
    public Pool optedOutPool() { return new Pool("optedOutPool"); }
 
    @Bean
    public Cache cache() { return new Cache(); }   // not Closeable, just has shutdown()
}
Text
closing the context:
  cache.shutdown() ran
  inferredPool.close() ran
done

Two beans of the same class, and only the one that did not opt out was closed. Cache implements nothing at all and was still shut down, purely because the method is named shutdown(). That inference is convenient right up until you have a close() that means something else — a close() on a domain object that finalises an invoice, say — at which point destroyMethod = "" is the fix.

Two hooks that are not in the table, because they are container-level rather than bean-level: ApplicationRunner and CommandLineRunner run after the whole context is ready, which is the right place for work that needs other beans to be live, and @EventListener(ApplicationReadyEvent.class) does the same job as an event.

Destruction only happens on a graceful shutdown

@PreDestroy is not a guarantee. It is a callback the container makes on its way through close(), and the container only gets there if the JVM lets it.

Spring Boot registers a JVM shutdown hook by default, so Ctrl+C — that is, SIGINT — and SIGTERM both end with the context closing properly. Sending SIGTERM to a running application:

Bash
kill -TERM $(pgrep -f demo-0.0.1-SNAPSHOT.jar)
Text
o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
>>> Bookkeeper.@PreDestroy — flushing before exit
  session scope   @PreDestroy    session    @31dacf93

Tomcat stops accepting new connections and drains the ones in flight, then the context closes and the destruction callbacks run — including for the session-scoped beans still alive at that moment.

SIGKILL does not offer the process that opportunity:

Bash
kill -9 $(pgrep -f demo-0.0.1-SNAPSHOT.jar)
Text
  request scope   @PostConstruct request    @6e5b477b
  request scope   @PreDestroy    request    @6e5b477b

The log simply stops after the last request. No graceful-shutdown lines, no Bookkeeper, nothing. kill -9 is not the only way to get here: a hard container stop past its grace period, an OOM kill by the kernel, a power failure and System.exit() from inside a shutdown hook all end the same way.

So there are three separate ways a @PreDestroy never runs, and they are worth stating together:

  • the bean is prototype-scoped, so the container never tracked it;
  • the process was killed rather than signalled, so close() was never reached;
  • the context was never closed — which is what happens in a plain new AnnotationConfigApplicationContext(...) without close(), and is why the try-with-resources form is worth using in a main method.

The design rule that follows: treat @PreDestroy as a best-effort tidy-up, not as the thing that keeps your data correct. Anything that must survive a kill -9 has to be durable before the process dies — committed, flushed, acknowledged — not queued up for a callback that may never come.

Lazy initialisation, and what it costs

Singletons are built eagerly during refresh(). @Lazy defers one of them until it is first needed:

Heavy.java
@Lazy
@Component
public class Heavy {
    public Heavy() { System.out.println("  Heavy constructed"); }
}
Text
refresh():
  Eager constructed
refresh() finished — Heavy has not been built yet
first getBean(Heavy.class):
  Heavy constructed
  Heavy@5fa07e12

spring.main.lazy-initialization=true applies the same treatment to every bean in the application.

What it buys is startup time, and on a small application that is close to nothing. Measured here over three runs each, on the one-controller demo:

Text
lazy=false   0.555 s   0.582 s   0.609 s
lazy=true    0.537 s   0.551 s   0.554 s

Indicative numbers on one machine, and the difference is inside the noise. The win scales with the number of beans you do not use in a given run, so it is worth something on a large application during development and worth measuring before you believe it anywhere else.

What it costs is the thing to weigh. A bean that fails to initialise no longer fails at startup — it fails at first use. Here is a bean whose @PostConstruct throws, in an application that would otherwise start:

Broken.java
@Component
public class Broken {
    @PostConstruct
    void validate() { throw new IllegalStateException("config key 'billing.url' is missing"); }
}

Eagerly, the application never starts:

Text
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'broken':
Invocation of init method failed
Caused by: java.lang.IllegalStateException: config key 'billing.url' is missing

With spring.main.lazy-initialization=true it starts perfectly:

Text
Tomcat started on port 8089 (http) with context path '/'
Started LazyFailApplication in 0.464 seconds (process running for 0.584)

and then the first request that touches it returns a 500:

Text
HTTP/1.1 500
{"timestamp":"2026-09-11T04:05:09.055Z","status":500,"error":"Internal Server Error","path":"/ping"}
Text
Servlet.service() for servlet [dispatcherServlet] threw exception [Request processing failed:
UnsatisfiedDependencyException: Error creating bean with name 'lazyFailController': Unsatisfied
dependency expressed through constructor parameter 0: Error creating bean with name 'broken':
Invocation of init method failed] with root cause
java.lang.IllegalStateException: config key 'billing.url' is missing

A misconfiguration that a health check would have caught before the instance took traffic has become a runtime error for a user. That is why global lazy initialisation is a development convenience and a production liability, and why @Lazy on one genuinely expensive bean is a much easier trade to defend.

FAQ

Is a Spring singleton thread-safe?

The bean is not; the container is. Spring guarantees you get one shared instance and does nothing about concurrent access to its fields. One instance serving every request means every mutable field is shared state, so either keep singletons stateless — dependencies in final fields, everything else in method parameters and local variables — or make the state explicitly thread-safe with an AtomicLong, a ConcurrentHashMap or proper synchronisation.

Why does my prototype bean only get created once?

Because it was injected into a singleton, and injection happens once. The scope annotation is being honoured — one prototype really was created at injection time — it just never gets consulted again. Fix it with ObjectProvider<T> at the injection point, @Lookup on a method, or @Scope(proxyMode = ScopedProxyMode.TARGET_CLASS) on the bean itself.

Should I use @PostConstruct or the constructor?

The constructor, whenever it can do the work. It runs first, it can assign final fields, it makes the object valid the moment it exists, and it is testable with plain new. Use @PostConstruct for the things a constructor genuinely cannot do: work that needs dependencies injected through setters or fields, work that depends on the bean being the container-managed instance, or anything slow enough that you would not want it inside a constructor.

What is the difference between singleton scope and application scope?

singleton means one instance per ApplicationContext; application means one instance per ServletContext, stored as a servlet-context attribute. A plain Spring Boot application has one context inside one servlet context, so they look identical. They diverge when there is more than one context in the same servlet container — several DispatcherServlets, or a parent/child arrangement — where the application-scoped bean is shared and the singletons are not.

Do I need @PreDestroy to close my DataSource?

No, and adding one is usually a mistake. HikariCP's HikariDataSource is AutoCloseable, so Spring's destroyMethod inference closes it at shutdown by itself, and that is true of most pooled resources declared through @Bean. Write a @PreDestroy only for something you acquired yourself that Spring cannot see — a background thread you started, a subscription you registered with an external system.

Can I define my own scope?

Yes. Implement org.springframework.beans.factory.config.Scope — the interesting methods are get(String, ObjectFactory) and registerDestructionCallback — and register it with a CustomScopeConfigurer bean or beanFactory.registerScope("tenant", new TenantScope()). Then @Scope("tenant") works like any built-in name. It is the right tool for a genuinely per-something lifetime, per tenant or per job, and the wrong tool for anything a Map in a singleton would handle.

Conclusion

A scope is a decision about how many instances exist and how long each one lives, and singleton — the default — means one per container, not one per JVM, which two contexts in one process will demonstrate in three lines of output. prototype is created on every request for it and then abandoned: the container never tracks it, so @PreDestroy never fires on one. Inject a prototype into a singleton and the scope quietly stops applying, because injection happened once; ObjectProvider is the default fix, @Lookup the legacy one, and a scoped proxy the one that fixes every injection point at once — which is also why @RequestScope and @SessionScope ship with proxyMode = TARGET_CLASS already set.

The lifecycle order is not folklore. Traced on Boot 4.1.1 it runs constructor, property population, the three *Aware callbacks, postProcessBeforeInitialization, @PostConstruct, afterPropertiesSet(), the initMethod, postProcessAfterInitialization, service, and then @PreDestroy, destroy() and the destroyMethod — with your own BeanPostProcessor always landing before @PostConstruct, because Spring re-registers its own processors at the end of the chain. Prefer the constructor, use @PostConstruct for what the constructor cannot do, and remember that everything after step 11 depends on the process being shut down politely.

The next article opens up the part of Boot that has been doing the work in the background all series: how auto-configuration works — the conditional mechanism behind it, and the --debug report that tells you exactly which conditions matched and which did not.

Related Posts

[Spring Boot Basics] Spring Beans and the ApplicationContext: @Component, Stereotypes and Component Scanning

What a Spring bean actually is and which of your objects should never be one, @Component proved to be the meta-annotation behind @Service, @Repository and @Controller, what each stereotype really adds at run time, how component scanning turns class files into BeanDefinitions, the bean-naming rule including the two-capitals case, the ApplicationContext API with the real bean count of a Spring Boot 4.1.1 app, and three registration failures reproduced with their actual messages.

[Spring Boot Basics] Constructor, Setter and Field Injection in Spring, with @Qualifier and @Primary

The three Spring injection points compared by when the container writes the value: why @Autowired is optional on a single constructor, why final fields and plain JUnit 5 tests are only possible with constructor injection, @Autowired(required=false) versus Optional and ObjectProvider, the real NoUniqueBeanDefinitionException fixed four ways with @Primary, @Qualifier, parameter names and a custom qualifier annotation, List and Map injection with @Order, and why a constructor cycle fails at startup while a field cycle does not.

[Spring Boot Basics] Calling External APIs with RestClient in Spring Boot: GET, POST, Error Handling and Timeouts

Calling external HTTP APIs from Spring Boot 4.1.1 with RestClient, checked against a local stub: RestClient vs RestTemplate, WebClient and @HttpExchange, spring-boot-starter-restclient and the auto-configured RestClient.Builder, GET into records and lists, toEntity, query parameter encoding, POST, PUT and DELETE, the real HttpClientErrorException messages, onStatus, defaultStatusHandler and exchange, measured default and configured connect and read timeouts with spring.http.clients, a logging ClientHttpRequestInterceptor, and turning upstream failures into 502, 503 and 504.

[Spring Boot Basics] Spring Boot Profiles and Configuration Precedence: Environment Variables and Command-Line Arguments

Spring Boot 4.1.1 profiles and configuration precedence, measured on the packaged jar: application-dev.yml merged over application.yml key by key, every way to set spring.profiles.active and which of two active profiles wins, spring.profiles.default, multi-document files, profile groups and @Profile expressions, the real property source order from command-line arguments down to @PropertySource, where config files are searched, environment variables, spring.config.import and the startup error each mistake produces.