Understanding the complete shutdown lifecycle from JVM hooks to bean destruction

Understanding the complete shutdown lifecycle from JVM hooks to bean destruction

Most developers spend time optimizing application startup.

Far fewer think about shutdown.

Yet every production deployment, Kubernetes rolling update, container restart or Ctrl+C follows the same lifecycle. During those few seconds, Spring Boot coordinates graceful request termination, stops lifecycle components, publishes events and finally destroys every managed bean.

⚡ TL;DR (Quick Recap)

  • Spring Boot executes shutdown callbacks in an order — but that order is configurable via SmartLifecycle phases, not hardcoded.
  • Graceful HTTP shutdown happens before bean destruction, always. Whether it happens before or after your infrastructure beans stop is up to their phase value.
  • Different shutdown hooks exist for different responsibilities — from JVM cleanup to bean resource release.
  • Graceful shutdown is on by default starting in Spring Boot 3.4. Earlier versions require server.shutdown=graceful.
  • Getting the phase ordering wrong has caused real bugs.

Why Shutdown Matters

Stopping an application isn’t simply killing the JVM. Spring has several responsibilities before the process exits:

  • stop accepting new requests
  • finish requests already in progress
  • notify interested components
  • stop infrastructure beans
  • release resources
  • close the application context
  • terminate the JVM

Each stage has a dedicated extension point. The part that actually causes production bugs isn’t which extension point exists — it’s the order they run in relative to each other.

A Single Bean Demonstrating Every Shutdown Hook

Rather than creating multiple examples, we’ll use one bean implementing every available callback.

class LifecycleShutdownBean implements DisposableBean, SmartLifecycle, CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(LifecycleShutdownBean.class);
private volatile boolean running;

@PreDestroy
void preDestroy() {
log.info("04 @PreDestroy");
}

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

public void cleanup() {
log.info("06 Custom destroyMethod");
}

@EventListener
void onClose(ContextClosedEvent event) {
log.info("02 ContextClosedEvent");
}

@Override
public void stop() {
log.info("03 SmartLifecycle.stop()");
this.running = false;
}

@Override
public boolean isRunning() {
return this.running;
}

@Override
public void start() {
this.running = true;
}

@Override
public void run(String... args) {
Runtime.getRuntime()
.addShutdownHook(new Thread(
() -> log.info("01 Custom JVM shutdown hook (order vs. Spring's own hook is NOT guaranteed)"),
"custom-shutdown-hook"));
}
}

@SpringBootApplication
public class ShutdownApplication {

public static void main(String[] args) {
SpringApplication.run(ShutdownApplication.class, args);
}

@Bean(destroyMethod = "cleanup")
LifecycleShutdownBean lifecycleShutdownBean() {
return new LifecycleShutdownBean();
}
}

This lets us observe the complete shutdown sequence with a single Ctrl+C.

Two things worth calling out before we run it:

  1. @PreDestroy lives in different packages depending on your Spring Boot version. jakarta.annotation.PreDestroy on 3.x, javax.annotation.PreDestroy on 2.x.
  2. The custom JVM shutdown hook races with Spring’s own. Spring Boot registers its own internal SpringApplicationShutdownHook — which is also just a Runtime shutdown hook. The JVM starts all registered hooks concurrently on separate threads and waits for all of them to finish; it does not sequence them. A single log statement usually wins the race by sheer speed, which is why the trace below looks clean — but nothing guarantees it. Don't build timing-dependent logic on a custom hook

The Shutdown Timeline — The Actual Logs

Running the sample and pressing Ctrl+C produces:

01 Custom JVM shutdown hook (order vs. Spring's own hook is NOT guaranteed)
02 ContextClosedEvent
03 SmartLifecycle.stop() [phase = 2147483647]

Commencing graceful shutdown. Waiting for active requests to complete
Graceful shutdown complete

04 @PreDestroy
05 DisposableBean.destroy()
06 Custom destroyMethod

Notice the threads involved.

custom-shutdown-hook      → the JVM hook registered above
SpringApplicationShutdownHook → Spring's internal shutdown sequence (often shown
truncated in logs, e.g. "...ionShutdownHook")
tomcat-shutdown → graceful web server shutdown, runs briefly before
control returns to Spring

The JVM shutdown hook executes first on its own thread.

Spring’s internal shutdown process then continues, while Tomcat temporarily performs graceful shutdown on a dedicated tomcat-shutdown thread before control returns to Spring.

Understanding Each Callback

1. JVM Shutdown Hook — belongs to the JVM, not Spring. Fires on any JVM termination, Spring or not. Use only for cleanup that must happen outside the Spring container (native resources, external processes). Don’t touch Spring beans here — as shown above, this hook races with Spring’s own shutdown sequence, so the application context’s state is not guaranteed at this point.

2. ContextClosedEvent — published immediately after shutdown begins, while the context and all beans are still fully alive. Good place for application-wide notifications: publishing final metrics, notifying cluster members, flushing monitoring data.

3. SmartLifecycle.stop() — the mechanism behind message listeners, Kafka consumers, RabbitMQ listeners, and schedulers. Its position relative to graceful HTTP shutdown is phase-dependent, not fixed. Default phase (Integer.MAX_VALUE) stops before graceful shutdown; anything with an explicitly lower phase can stop after.

4. Graceful Shutdown — Spring Boot’s embedded server stops accepting new requests and waits (up to spring.lifecycle.timeout-per-shutdown-phase, default 30s) for in-flight ones to finish. If that timeout expires, Spring Boot force-terminates the remaining requests and logs a warning — this is the failure mode you'll actually see in production when a request hangs during a rolling deploy. Only after this phase completes does bean destruction begin.

5. @PreDestroy — the standard Jakarta lifecycle annotation, framework-independent. The default choice for releasing resources owned by a single bean: closing clients, releasing files, stopping background threads.

6. DisposableBean.destroy() — Spring's programmatic equivalent of @PreDestroy. Functionally similar, but couples your class to Spring's API. Prefer @PreDestroy unless you're building framework infrastructure.

7. Custom destroyMethod — for third-party classes you don't own and can't annotate. Spring just invokes the configured method name during bean destruction. Common when wrapping external SDKs.

Two Things This Sequence Doesn’t Protect You From

Version defaults. Graceful shutdown only runs automatically if it’s enabled. It became the default in Spring Boot 3.4 (server.shutdown defaults to graceful). On 2.3 through 3.3, the default is immediate — sockets close right away, no draining — and you must opt in:

server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s

Kubernetes timing. Spring’s shutdown timeout and Kubernetes’ terminationGracePeriodSeconds are two separate clocks. If Spring hasn't finished by the time Kubernetes' grace period expires, the pod gets SIGKILLed mid-drain regardless of what your @PreDestroy methods are doing. Set Kubernetes' grace period comfortably higher than Spring's shutdown timeout, not equal to it — endpoint removal from the Service is eventually consistent, so a few requests can still arrive after shutdown begins even with graceful shutdown fully configured.

Which Callback Should You Use?

In practice, each callback has a distinct responsibility:

  • JVM Shutdown Hook → JVM-level cleanup outside Spring
  • ContextClosedEvent → application-wide shutdown notifications
  • SmartLifecycle.stop() → infrastructure components requiring coordinated shutdown
  • Graceful Shutdown → handled automatically by Spring Boot for web servers
  • @PreDestroy → preferred cleanup mechanism for most beans
  • DisposableBean.destroy() → Spring-specific alternative when interface callbacks are preferred
  • Custom destroyMethod → third-party classes you cannot modify

Choosing the right callback keeps shutdown predictable and avoids resource leaks or race conditions.

Final Takeaways

Application shutdown isn’t a single callback — it’s a carefully orchestrated lifecycle.

Spring Boot first begins JVM termination, notifies the application context, stops lifecycle-managed components, waits for active HTTP requests to finish and only then destroys managed beans.

Knowing this order makes it much easier to decide where cleanup belongs and ensures your applications shut down gracefully in production, whether triggered by Ctrl+C, Kubernetes, Docker or rolling deployments.

You can find an example on GitHub.

Originally posted on marconak-matej.medium.com.