DEV Community

Silver_dev
Silver_dev

Posted on

Spring Boot Under the Hood. Part 8: Graceful Exit — How Spring Boot Starts and Stops

In Part 1, we left off where SpringApplication.run() calls callRunners() and publishes ApplicationReadyEvent. Now let's look at those steps in detail — plus the complete application shutdown cycle, from the JVM shutdown hook down to @PreDestroy.

8.1. ApplicationRunner and CommandLineRunner — the Entry Point into the "Ready" Application

Once the context is fully up (all beans created, Tomcat running), Spring Boot invokes the beans that implement ApplicationRunner or CommandLineRunner.

@FunctionalInterface
public interface ApplicationRunner {
    void run(ApplicationArguments args) throws Exception;
}

@FunctionalInterface
public interface CommandLineRunner {
    void run(String... args) throws Exception;
}
Enter fullscreen mode Exit fullscreen mode

The key difference is the argument format:

Interface Parameter Example access
CommandLineRunner String[] (raw) args[0] → --server.port=9090
ApplicationRunner ApplicationArguments args.getOptionNames() → [server.port]

ApplicationArguments is an already-parsed object that Spring Boot builds from the String[]:

DefaultApplicationArguments args = new DefaultApplicationArguments(sourceArgs);
// args.getOptionNames() → Set<String>
// args.getOptionValues("server.port") → List<String>
// args.getSourceArgs() → String[]
// args.getNonOptionArgs() → List<String>
Enter fullscreen mode Exit fullscreen mode

8.2. The Order in Which Runners Are Invoked

Inside SpringApplication.callRunners():

private void callRunners(ApplicationContext context, ApplicationArguments args) {
    List<Object> runners = new ArrayList<>();
    runners.addAll(context.getBeansOfType(ApplicationRunner.class).values());
    runners.addAll(context.getBeansOfType(CommandLineRunner.class).values());
    AnnotationAwareOrderComparator.sort(runners);
    for (Object runner : new LinkedHashSet<>(runners)) {
        if (runner instanceof ApplicationRunner applicationRunner) {
            callRunner(applicationRunner, args);
        }
        if (runner instanceof CommandLineRunner commandLineRunner) {
            callRunner(commandLineRunner, args);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Important details:

  1. Both types are collected into a single list.
  2. Sorting is done via AnnotationAwareOrderComparator — by @Order / Ordered.
  3. @Order only affects the order in which run() is invoked — not the order in which the beans are created.
  4. If @Order isn't specified, the order is undefined.

8.3. The Rule: Runners Instead of @PostConstruct

The key Spring Boot rule: tasks that should run at application startup (after the context is fully initialized) should be implemented via ApplicationRunner or CommandLineRunner — not via @PostConstruct.

Why:

  • @PostConstruct runs during bean initialization, when not all beans are ready yet.
  • Runners are invoked after the entire context has been refreshed, all beans are created, and the web server is running.
  • Runners get access to ApplicationArguments.

8.4. Graceful Shutdown

Since Spring Boot 2.3, graceful shutdown is built in.

What happens:

  1. The application receives a SIGTERM signal (e.g., from Kubernetes).
  2. The JVM shutdown hook initiates context.close().
  3. The first phase — stop accepting new requests.
  4. Existing requests get a grace period to finish.
  5. Once all requests are done — the beans are destroyed.

Configuration:

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s
Enter fullscreen mode Exit fullscreen mode

server.shutdown can be graceful or immediate (the default). timeout-per-shutdown-phase is the maximum time to wait for requests to complete (30 seconds by default).

Behavior by server:

Server How it rejects new requests
Tomcat At the network level (stops accepting connections)
Jetty At the network level
Reactor Netty At the network level
Undertow Accepts the connection but responds with 503 Service Unavailable

Important: graceful shutdown only works when a proper SIGTERM is received. If you stop the application from your IDE (the Stop button), the IDE may send SIGKILL, and graceful shutdown won't kick in.

8.5. WebServerGracefulShutdownLifecycle — the Implementation

As we saw in Part 7, when the WebServer is created, two lifecycle beans are registered:

getBeanFactory().registerSingleton("webServerGracefulShutdown", 
    new WebServerGracefulShutdownLifecycle(this.webServer));
getBeanFactory().registerSingleton("webServerStartStop", 
    new WebServerStartStopLifecycle(this, this.webServer));
Enter fullscreen mode Exit fullscreen mode

WebServerGracefulShutdownLifecycle implements SmartLifecycle

class WebServerGracefulShutdownLifecycle implements SmartLifecycle {
    private final WebServer webServer;
    private volatile boolean running;

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

    @Override
    public void stop(Runnable callback) {
        this.running = false;
        this.webServer.shutDownGracefully((result) -> callback.run());
    }

    @Override
    public int getPhase() {
        return Integer.MAX_VALUE;  // ← the highest phase
    }
}
Enter fullscreen mode Exit fullscreen mode

Key point: getPhase() = Integer.MAX_VALUE. During shutdown, SmartLifecycle beans are stopped in descending phase order, so the web server's graceful shutdown happens first — before the beans that may still be processing requests (DataSource, services, etc.) are destroyed.

8.6. SmartLifecycle and LifecycleProcessor — the Start/Stop Order

SmartLifecycle extends Lifecycle and Phased:

public interface SmartLifecycle extends Lifecycle, Phased {
    boolean isAutoStartup();
    void stop(Runnable callback);

    @Override
    default int getPhase() {
        return 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

The ordering rules:

Phase Startup Shutdown
Integer.MIN_VALUE First Last
0 (default) Middle Middle
Integer.MAX_VALUE Last First

At startup (onRefresh()): phases are sorted in ascending order — MIN_VALUE starts first, MAX_VALUE last.

At shutdown (onClose()): phases are sorted in descending order — MAX_VALUE stops first, MIN_VALUE last.

This is driven by the DefaultLifecycleProcessor:

public class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactoryAware {
    private long timeoutPerShutdownPhase = 30000;  // 30 seconds

    @Override
    public void onRefresh() {
        startBeans(true);  // all SmartLifecycle beans with isAutoStartup() == true
    }

    @Override
    public void onClose() {
        stopBeans();       // all running Lifecycle beans
    }
}
Enter fullscreen mode Exit fullscreen mode

timeoutPerShutdownPhase is the maximum wait time for a single phase. If a bean hasn't finished its stop() within that time — Spring moves on without waiting for it.

8.7. The JVM Shutdown Hook — How the JVM Knows It's Time to Stop

SpringApplication.refreshContext() automatically registers a shutdown hook:

private void refreshContext(ConfigurableApplicationContext context) {
    if (this.registerShutdownHook) {
        try {
            context.registerShutdownHook();
        } catch (AccessControlException ex) {
            // Not allowed in some environments
        }
    }
    refresh(context);
}
Enter fullscreen mode Exit fullscreen mode

registerShutdownHook is true by default. It can be disabled via SpringApplication.setRegisterShutdownHook(false).

Inside AbstractApplicationContext:

public void registerShutdownHook() {
    if (this.shutdownHook == null) {
        this.shutdownHook = new Thread(SHUTDOWN_HOOK_THREAD_NAME) {
            @Override
            public void run() {
                synchronized (startupShutdownMonitor) {
                    doClose();
                }
            }
        };
        Runtime.getRuntime().addShutdownHook(this.shutdownHook);
    }
}
Enter fullscreen mode Exit fullscreen mode

The shutdown chain:

SIGTERM from the OS / Ctrl+C / kill <pid>
    │
    ▼
JVM Shutdown Hook (Thread "SpringContextShutdownHook")
    │
    ▼
AbstractApplicationContext.doClose()
    │
    ├─ 1. LifecycleProcessor.onClose()
    │       └─ SmartLifecycle.stop() — descending phase order
    │             ├─ WebServerGracefulShutdownLifecycle (MAX_VALUE) — graceful shutdown
    │             ├─ WebServerStartStopLifecycle (MAX-1) — tomcat.stop()
    │             └─ ... custom SmartLifecycle beans
    │
    ├─ 2. destroyBeans()
    │       └─ for each singleton:
    │             ├─ @PreDestroy (DestructionAwareBeanPostProcessor)
    │             ├─ DisposableBean.destroy()
    │             └─ @Bean(destroyMethod) / AutoCloseable.close()
    │
    ├─ 3. closeBeanFactory()
    │
    └─ 4. active.set(false)
Enter fullscreen mode Exit fullscreen mode

8.8. Closing the Context Manually

If you need to close the context programmatically (e.g., in tests or a CLI application):

ConfigurableApplicationContext context = SpringApplication.run(MyApp.class, args);
// ... work
context.close();  // → doClose()
Enter fullscreen mode Exit fullscreen mode

Or via SpringApplication.exit():

int exitCode = SpringApplication.exit(context, () -> 0);
System.exit(exitCode);
Enter fullscreen mode Exit fullscreen mode

8.9. Spring Boot 3 vs Spring Boot 4 — the Differences

Aspect Spring Boot 3.x Spring Boot 4.x
Graceful shutdown Built in, server.shutdown=graceful Built in, enabled by default for all embedded servers
Undertow Supported Removed (incompatible with Servlet 6.1)
Jetty graceful shutdown StatisticsHandler GracefulHandler (changed in 4.2)
WebServerGracefulShutdownLifecycle org.springframework.boot.web.server Modular structure, packages relocated
ApplicationRunner / CommandLineRunner Unchanged Unchanged
SmartLifecycle / LifecycleProcessor Unchanged Unchanged
JVM shutdown hook true by default true by default
Deadlock fix — Spring Framework 7.0.4: fixed a deadlock with concurrent shutdown hooks (issue #36260)

The key change in Boot 4: graceful shutdown is enabled by default for all embedded servers. This means that on SIGTERM, the application automatically stops accepting new requests and waits for the existing ones to complete — with zero configuration.

8.10. Key Takeaways

  1. ApplicationRunner vs CommandLineRunner — the only difference is the argument format (ApplicationArguments vs String[]). ApplicationRunner is preferred.
  2. Runner ordering — via @Order / Ordered. Runners are invoked after the context is fully initialized but before ApplicationReadyEvent.
  3. @PostConstruct ≠ runner. "At application startup" tasks belong in runners.
  4. Graceful shutdown — server.shutdown=graceful + spring.lifecycle.timeout-per-shutdown-phase. Enabled by default in Boot 4.
  5. SmartLifecycle ordering: startup in ascending phase, shutdown in descending. WebServerGracefulShutdownLifecycle has phase = MAX_VALUE, so it stops first.
  6. The JVM shutdown hook is registered automatically (registerShutdownHook = true). It's what triggers context.close() on SIGTERM / Ctrl+C.
  7. The shutdown chain: shutdown hook → doClose() → LifecycleProcessor.onClose() → destroyBeans() → @PreDestroy → DisposableBean.destroy().
  8. Boot 4: graceful shutdown by default, Undertow removed, the shutdown-hook deadlock fixed.

Top comments (0)