Bean Lifecycle Callbacks
Run code after a bean is ready and before it is destroyed with @PostConstruct and @PreDestroy, and know when startup work belongs elsewhere.
@PostConstruct void init() { ... }
@PreDestroy void close() { ... }
@Bean(initMethod = "start", destroyMethod = "stop")Spring creates a bean, injects its dependencies, gives post-processors a chance to wrap it (this is where transaction and caching proxies come from), and puts it into service. When the context closes, it destroys singletons in the reverse order of their dependencies. You can hook into the start and the end of that life.
The order of events
- Constructor runs and dependencies are injected.
- Initialisation callbacks:
@PostConstruct, thenInitializingBean.afterPropertiesSet(), then a@Bean(initMethod = ...). - Post-processors may replace the bean with a proxy.
- The bean is used.
- On shutdown:
@PreDestroy, thenDisposableBean.destroy(), then a@Bean(destroyMethod = ...).
@PostConstruct and @PreDestroy come from jakarta.annotation, not javax.annotation. Prefer them over the Spring interfaces, which tie your class to Spring for no benefit. For library classes you cannot annotate, use the @Bean attributes. A @Bean whose type has a public close() or shutdown() method has it called automatically on shutdown.
What belongs in @PostConstruct
Keep it quick and local: validate settings, build an in-memory lookup table, open a resource the bean owns. Do not call slow remote services there. Every singleton is initialised before the application accepts traffic, so a slow call delays startup and a failing one stops the whole application booting. Kubernetes may then restart it in a loop.
A frequent bug is calling a @Transactional method from @PostConstruct on the same bean. The call goes to this, not to the proxy, so there is no transaction. Do that work in an ApplicationRunner or a listener for ApplicationReadyEvent, which run once the whole context is ready and call through proxies.
Shutting down cleanly
Use @PreDestroy to stop executors you created, flush buffers and close connections the bean opened itself. It runs only on a normal shutdown, such as SIGTERM from a container platform, not after kill -9 or a JVM crash, so it must never be the only place important data is saved. Spring does not call destroy callbacks on prototype beans.
For components that start and stop work, such as message consumers or pollers, implement SmartLifecycle. It gives you start() and stop() with a phase that orders it against other lifecycle beans. All lifecycle beans start after every singleton is created and stop before any singleton is destroyed, so a consumer stops while the beans it depends on, such as the connection pool, are still open.
Example
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Component;
public class CurrencyTable {
private final Map<String, BigDecimal> ratesToGbp = new ConcurrentHashMap<>();
private ScheduledExecutorService refresher;
void start() {
// Quick, local work only: seed defaults and start our own background thread.
ratesToGbp.put("GBP", BigDecimal.ONE);
ratesToGbp.put("EUR", new BigDecimal("0.85"));
refresher = Executors.newSingleThreadScheduledExecutor(Thread.ofVirtual().factory());
refresher.scheduleAtFixedRate(this::refresh, 1, 15, TimeUnit.MINUTES);
}
void stop() throws InterruptedException {
// We created the executor, so we must stop it, or its thread outlives the context.
refresher.shutdown();
if (!refresher.awaitTermination(5, TimeUnit.SECONDS)) {
refresher.shutdownNow();
}
}
public BigDecimal toGbp(String currency, BigDecimal amount) {
BigDecimal rate = ratesToGbp.get(currency);
if (rate == null) {
throw new IllegalArgumentException("No rate for " + currency);
}
return amount.multiply(rate);
}
private void refresh() {
// Fetch the latest rates from the provider here and put them into ratesToGbp.
}
}
When to use it
- Shut down an executor a bean created so its threads do not keep the JVM alive after shutdown.
- Preload popular products into a cache once the application has fully started.
- Stop polling a queue before the connection pool closes during a rolling deployment.
More examples
Init and destroy methods for a class you cannot annotate
The @Bean attributes give library classes the same hooks as @PostConstruct and @PreDestroy. Spring already infers a public close() or shutdown() method as the destroy method; set destroyMethod = "" if a bean's close() must not be called by Spring, for example a shared resource owned elsewhere.
import java.io.Closeable;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class SearchConfig {
// "connect" runs after construction; "close" would also be inferred, but naming it is clearer.
@Bean(initMethod = "connect", destroyMethod = "close")
SearchIndexClient searchIndexClient() {
return new SearchIndexClient("http://search.internal:9200");
}
}
// Imagine this comes from a vendor jar.
class SearchIndexClient implements Closeable {
private final String url;
private boolean connected;
SearchIndexClient(String url) {
this.url = url;
}
public void connect() {
connected = true;
}
@Override
public void close() {
connected = false;
}
boolean isConnected() {
return connected;
}
String url() {
return url;
}
}Warming a cache after startup instead of in @PostConstruct
ApplicationReadyEvent is published after every bean is initialised and runners have run, so proxies, transactions and other beans are all available. An exception thrown from this listener still fails startup, so catch and log errors if warming is optional. Listeners also run on the main thread, so a slow warm-up delays readiness; hand long work to an executor.
import java.util.List;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
interface ProductCatalogue {
List<String> topSellers(int limit);
}
@Component
public class CacheWarmer {
private final ProductCatalogue catalogue;
public CacheWarmer(ProductCatalogue catalogue) {
this.catalogue = catalogue;
}
// Runs once the context is fully started, through the proxy.
@EventListener(ApplicationReadyEvent.class)
@Transactional(readOnly = true)
public void warm() {
List<String> skus = catalogue.topSellers(200);
System.out.println("Warmed " + skus.size() + " products");
}
}A consumer that starts and stops with the application
SmartLifecycle beans start after all singletons are created and stop before any are destroyed. That makes them the right home for background loops: no work starts against a half-built context, and stop() waits for the current batch, so work ends before the database pool closes. The default phase is usually right; override getPhase() only to order this bean against other lifecycle beans, which start in ascending phase order and stop in descending order. Spring Boot's own web server and message listener containers use the same mechanism.
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Component;
@Component
public class OrderEventPoller implements SmartLifecycle {
private final AtomicBoolean running = new AtomicBoolean();
private Thread worker;
@Override
public void start() {
running.set(true);
worker = Thread.ofVirtual().name("order-poller").start(this::loop);
}
@Override
public void stop() {
running.set(false);
if (worker != null) {
worker.interrupt();
try {
// Wait for the current batch to finish before shutdown continues.
worker.join(Duration.ofSeconds(10));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
@Override
public boolean isRunning() {
return running.get();
}
private void loop() {
while (running.get()) {
try {
Thread.sleep(1_000); // fetch and handle a batch of events here
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
}
Discussion