Spring Bean Lifecycle: @PostConstruct, @PreDestroy and More

Introduction

This tutorial follows a Spring bean through its lifecycle - from the moment the container creates it, through the callbacks that prepare it for use, to the callbacks that clean it up when the container shuts down. We will start with an overview of the lifecycle phases. We will then use the most common callbacks, @PostConstruct and @PreDestroy, and see why initialization belongs there rather than in the constructor. Next we will put every kind of callback into one bean - the Aware interfaces, InitializingBean and DisposableBean, and the initMethod and destroyMethod attributes of @Bean - and print the exact order in which Spring calls them. We will then write a BeanPostProcessor, the hook that lets Spring add behaviour to every bean, see how a destroy method can be inferred automatically, and lastly see why prototype beans are never destroyed by the container.

(This tutorial assumes you know singleton and prototype scopesClick to read tutorial: Spring Bean Scopes: Singleton, Prototype, Request, Session; the lifecycle differs between them.)

The phases of a bean's life

For a singleton bean, the container goes through these phases, in this order -
  1. Instantiation: the container calls the constructor (with constructor injection, the dependencies arrive here).
  2. Populating properties: setter and field injection.
  3. Aware callbacks: if the bean implements interfaces such as BeanNameAware or ApplicationContextAware, the container hands it its own name or the context.
  4. BeanPostProcessor before initialization: every registered post-processor sees the bean.
  5. Initialization: @PostConstruct methods, then InitializingBean.afterPropertiesSet(), then a custom initMethod.
  6. BeanPostProcessor after initialization: post-processors can now replace the bean, for example with a proxy that adds transactions.
  7. In use: the bean is injected into others and serves calls until the context closes.
  8. Destruction, when the context closes: @PreDestroy methods, then DisposableBean.destroy(), then a custom destroyMethod.
Most applications need only phases 1, 5 and 8 - a constructor, a @PostConstruct method and a @PreDestroy method. The rest is used mainly by frameworks, including Spring itself.

@PostConstruct and @PreDestroy

@PostConstruct marks a method Spring calls once the bean is fully created and all its dependencies are injected. @PreDestroy marks a method Spring calls just before it discards the bean. Both annotations come from the Jakarta Annotations API (package jakarta.annotation), which Spring Boot starters include. The example is a connection pool that opens connections at startup and closes them at shutdown -
Opening resources in @PostConstruct and releasing them in @PreDestroy
//PostConstructApp.java
package com.javabrahman.spring.lifecycle.basic;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;

@Component
class PoolSettings {
  int size() {
    return 3;
  }
}

@Component
class ConnectionPool {
  private final List<String> connections = new ArrayList<>();
  private PoolSettings settings;

  ConnectionPool() {
    System.out.println("1. Constructor - settings injected yet? " + (settings != null));
  }

  @Autowired
  void setSettings(PoolSettings settings) {
    this.settings = settings;
    System.out.println("2. Setter injection - settings injected");
  }

  @PostConstruct
  void open() {
    for (int i = 1; i <= settings.size(); i++) {
      connections.add("connection-" + i);
    }
    System.out.println("3. @PostConstruct - opened " + connections);
  }

  String borrow() {
    return connections.get(0);
  }

  @PreDestroy
  void close() {
    System.out.println("5. @PreDestroy - closing " + connections.size() + " connections");
    connections.clear();
  }
}

@Configuration
@ComponentScan
class AppConfig {
}

public class PostConstructApp {
  public static void main(String[] args) {
    try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
      System.out.println("4. In use - borrowed " + context.getBean(ConnectionPool.class).borrow());
      System.out.println("   Closing the context");
    }
    System.out.println("Context closed");
  }
}
 OUTPUT of the above code
1. Constructor - settings injected yet? false
2. Setter injection - settings injected
3. @PostConstruct - opened [connection-1, connection-2, connection-3]
4. In use - borrowed connection-1
   Closing the context
5. @PreDestroy - closing 3 connections
Context closed

Explanation of the code

  • In the constructor, the setter-injected settings was still null. Code that needs injected dependencies cannot run there - and with field or setter injection that is the usual case.
  • @PostConstruct ran after injection, so it could read settings.size() and open three connections. It runs once, before any other bean can use the pool.
  • Closing the context - here at the end of the try-with-resources block - called @PreDestroy, which released the connections.
  • The annotated methods can have any name and any access level, take no parameters, and return void.
  • With constructor injection, the dependencies are already available in the constructor, but @PostConstruct is still the better place for work such as opening connections, starting threads or loading a cache: the constructor stays cheap, and the object is fully built (including any subclass that Spring generates) before the work starts.

Every callback in one bean, in order

Spring has three equivalent ways to declare an initialization method and three to declare a destroy method, plus the Aware interfaces. The bean below uses all of them so that the output shows the order in which Spring calls them -
All lifecycle callbacks on one bean, printed in the order Spring calls them
//CallbackOrderApp.java
package com.javabrahman.spring.lifecycle.order;

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.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

class ReportCache implements BeanNameAware, ApplicationContextAware, InitializingBean, DisposableBean {
  private int step = 0;

  ReportCache() {
    log("constructor");
  }

  @Override
  public void setBeanName(String name) {
    log("BeanNameAware.setBeanName(\"" + name + "\")");
  }

  @Override
  public void setApplicationContext(ApplicationContext context) {
    log("ApplicationContextAware.setApplicationContext()");
  }

  @PostConstruct
  void postConstruct() {
    log("@PostConstruct");
  }

  @Override
  public void afterPropertiesSet() {
    log("InitializingBean.afterPropertiesSet()");
  }

  void customInit() {
    log("@Bean(initMethod) customInit()");
  }

  @PreDestroy
  void preDestroy() {
    log("@PreDestroy");
  }

  @Override
  public void destroy() {
    log("DisposableBean.destroy()");
  }

  void customDestroy() {
    log("@Bean(destroyMethod) customDestroy()");
  }

  private void log(String callback) {
    System.out.println(++step + ". " + callback);
  }
}

@Configuration
class AppConfig {
  @Bean(initMethod = "customInit", destroyMethod = "customDestroy")
  ReportCache reportCache() {
    return new ReportCache();
  }
}

public class CallbackOrderApp {
  public static void main(String[] args) {
    try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
      System.out.println("-- context started, closing it --");
    }
  }
}
 OUTPUT of the above code
1. constructor
2. BeanNameAware.setBeanName("reportCache")
3. ApplicationContextAware.setApplicationContext()
4. @PostConstruct
5. InitializingBean.afterPropertiesSet()
6. @Bean(initMethod) customInit()
-- context started, closing it --
7. @PreDestroy
8. DisposableBean.destroy()
9. @Bean(destroyMethod) customDestroy()

Explanation of the code

  • After the constructor, the Aware callbacks gave the bean its name (reportCache, the @Bean method name) and the application context.
  • Initialization ran in a fixed order: @PostConstruct, then afterPropertiesSet(), then the initMethod.
  • Destruction mirrored it: @PreDestroy, then destroy(), then the destroyMethod.
  • Use one mechanism per bean, not three. For your own classes, use @PostConstruct and @PreDestroy: they are standard Java annotations and keep Spring interfaces out of the class. InitializingBean and DisposableBean tie the class to Spring; they are mostly found inside Spring and other frameworks. initMethod and destroyMethod are for library classes you cannot annotate, such as a connection pool from a third-party jar.
  • The Aware interfaces are rarely needed in application code. Instead of ApplicationContextAware, inject what you need - including ApplicationContext itself, which can be injected like any bean.

BeanPostProcessor: a hook into every bean

A BeanPostProcessor is a special bean that the container calls for every other bean, once before and once after its initialization callbacks. It can inspect a bean, change it, or return a different object in its place. This is how Spring implements much of its own magic: @PostConstruct and @Autowired are processed by built-in post-processors, and @Transactional, @Async and @Cacheable work because a post-processor replaces the bean with a proxy after initialization. The example writes a post-processor that times how long each bean takes to initialize -
A BeanPostProcessor that measures each bean's initialization
//PostProcessorApp.java
package com.javabrahman.spring.lifecycle.postprocessor;

import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;

class InitTimer implements BeanPostProcessor {
  private final Map<String, Long> startTimes = new HashMap<>();

  @Override
  public Object postProcessBeforeInitialization(Object bean, String beanName) {
    if (bean.getClass().getPackageName().startsWith("com.javabrahman")) {
      startTimes.put(beanName, System.nanoTime());
      System.out.println("before init: " + beanName);
    }
    return bean;   // return the bean itself, or a replacement
  }

  @Override
  public Object postProcessAfterInitialization(Object bean, String beanName) {
    Long start = startTimes.remove(beanName);
    if (start != null) {
      long millis = (System.nanoTime() - start) / 1_000_000;
      System.out.println("after init:  " + beanName + (millis >= 100 ? " - slow, took at least 100 ms" : " - fast"));
    }
    return bean;
  }
}

@Component
class SpellChecker {
  @PostConstruct
  void loadDictionary() throws InterruptedException {
    System.out.println("  SpellChecker @PostConstruct: loading the dictionary");
    Thread.sleep(150);   // stands in for slow start-up work
  }
}

@Component
class WordCounter {
  @PostConstruct
  void init() {
    System.out.println("  WordCounter @PostConstruct");
  }
}

@Configuration
@ComponentScan
class AppConfig {
  // static: post-processors must exist before the other beans are created
  @Bean
  static InitTimer initTimer() {
    return new InitTimer();
  }
}

public class PostProcessorApp {
  public static void main(String[] args) {
    try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
      System.out.println("Context ready");
    }
  }
}
 OUTPUT of the above code
before init: appConfig
after init:  appConfig - fast
before init: spellChecker
  SpellChecker @PostConstruct: loading the dictionary
after init:  spellChecker - slow, took at least 100 ms
before init: wordCounter
  WordCounter @PostConstruct
after init:  wordCounter - fast
Context ready

Explanation of the code

  • The container detected InitTimer as a BeanPostProcessor and called it for every bean it created afterwards; the example limits the output to our own beans by package.
  • For each bean, postProcessBeforeInitialization() ran first, then the bean's @PostConstruct method, then postProcessAfterInitialization() - so the timer measured exactly the initialization phase and caught the slow SpellChecker. (The output says "at least 100 ms" rather than printing the exact time, which varies from run to run.)
  • The @Bean method is static. Post-processors are created very early, before ordinary beans, and a static method lets Spring call it without first creating AppConfig and processing it.
  • A post-processor that returned a different object from postProcessAfterInitialization() - for example a proxy that logs every method call - would replace the bean everywhere it is injected. That is the mechanism behind Spring AOP, covered in module 7 of this series.

Destroy methods inferred for @Bean

For beans declared with @Bean, you often do not need to name a destroy method at all. By default, Spring infers one: if the returned object has a public close() or shutdown() method - which includes every AutoCloseable - Spring calls it when the context closes -
Spring calls close() on an AutoCloseable @Bean automatically
//InferredDestroyApp.java
package com.javabrahman.spring.lifecycle.inferred;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

// Stands in for a third-party client class with no Spring annotations
class HttpClientWrapper implements AutoCloseable {
  HttpClientWrapper() {
    System.out.println("HttpClientWrapper opened");
  }

  @Override
  public void close() {
    System.out.println("HttpClientWrapper.close() called by Spring");
  }
}

class KeepOpenClient implements AutoCloseable {
  @Override
  public void close() {
    System.out.println("This line never prints");
  }
}

@Configuration
class AppConfig {
  @Bean   // destroyMethod is inferred: close()
  HttpClientWrapper httpClient() {
    return new HttpClientWrapper();
  }

  @Bean(destroyMethod = "")   // an empty name switches inference off
  KeepOpenClient sharedClient() {
    return new KeepOpenClient();
  }
}

public class InferredDestroyApp {
  public static void main(String[] args) {
    try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
      System.out.println("Closing the context");
    }
  }
}
 OUTPUT of the above code
HttpClientWrapper opened
Closing the context
HttpClientWrapper.close() called by Spring

Explanation of the code

  • HttpClientWrapper has no annotations and the @Bean names no destroy method, yet Spring called its close() at shutdown.
  • destroyMethod = "" switched inference off for sharedClient, for an object whose lifetime something else controls, so its close() was not called.
  • Inference applies to @Bean methods. A @Component class gets the same behaviour if it implements AutoCloseable or DisposableBean, or has a @PreDestroy method.

Prototype beans are not destroyed

The container initializes a prototype bean like any other - its @PostConstruct runs - but then hands it over and keeps no reference to it. So when the context closes, it has nothing to destroy -
Initialization callbacks run for prototypes, destroy callbacks do not
//PrototypeLifecycleApp.java
package com.javabrahman.spring.lifecycle.prototype;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

@Component
@Scope("prototype")
class ExportJob {
  private static int instances = 0;
  private final int number = ++instances;

  @PostConstruct
  void init() {
    System.out.println("ExportJob #" + number + " @PostConstruct");
  }

  @PreDestroy
  void cleanUp() {
    System.out.println("ExportJob #" + number + " @PreDestroy");
  }
}

@Component
class Settings {
  @PreDestroy
  void cleanUp() {
    System.out.println("Settings (singleton) @PreDestroy");
  }
}

@Configuration
@ComponentScan
class AppConfig {
}

public class PrototypeLifecycleApp {
  public static void main(String[] args) {
    try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
      context.getBean(ExportJob.class);
      context.getBean(ExportJob.class);
      System.out.println("Closing the context");
    }
  }
}
 OUTPUT of the above code
ExportJob #1 @PostConstruct
ExportJob #2 @PostConstruct
Closing the context
Settings (singleton) @PreDestroy

Explanation of the code

  • Both ExportJob instances ran @PostConstruct when they were created.
  • On close, only the singleton Settings was destroyed. Neither ExportJob ran @PreDestroy, because the container no longer knew about them.
  • If a prototype holds resources - a file, a connection, a thread - the code that requested it must release them, for example by making the prototype AutoCloseable and using it in a try-with-resources block. Memory is not a problem: once the caller drops its references, the garbage collectorClick to read: garbage collector reclaims the object as usual.

Lifecycle best practices

  • Keep constructors cheap: assign dependencies and validate arguments. Do start-up work - opening connections, loading data, starting threads - in @PostConstruct.
  • Release everything you acquired in @PreDestroy (or close()), and make sure the context is actually closed: use try-with-resources or registerShutdownHook() in plain Spring; Spring Boot does it for you.
  • Prefer @PostConstruct and @PreDestroy for your own classes, and initMethod/destroyMethod (or inference) for library classes.
  • Keep @PostConstruct methods fast and free of calls through the bean's own proxy. A slow initializer delays application start-up; for long jobs, start them in the background or listen for the context-ready event instead.
  • Write a BeanPostProcessor only for cross-cutting framework-style behaviour. For behaviour around method calls, Spring AOP is the higher-level tool.

Summary

In this tutorial we first went through the phases of a bean's life, from instantiation to destruction. We used @PostConstruct to open resources once the dependencies were injected, and @PreDestroy to release them when the context closed. We then put every callback - the Aware interfaces, InitializingBean and DisposableBean, and initMethod and destroyMethod - into one bean and printed the order Spring calls them in. Next we wrote a BeanPostProcessor that timed every bean's initialization, and saw that Spring infers close() as the destroy method of @Bean objects. Lastly, we saw that prototype beans are initialized but never destroyed by the container, and listed best practices. The next tutorial deals with choosing between several beans of the same type: @Qualifier, @Primary and conditional beansClick to read tutorial: Spring @Qualifier, @Primary and Conditional Beans.