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;
}
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>
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);
}
}
}
Important details:
- Both types are collected into a single list.
- Sorting is done via
AnnotationAwareOrderComparator— by@Order/Ordered. -
@Orderonly affects the order in whichrun()is invoked — not the order in which the beans are created. - If
@Orderisn'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:
-
@PostConstructruns 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:
- The application receives a
SIGTERMsignal (e.g., from Kubernetes). - The JVM shutdown hook initiates
context.close(). - The first phase — stop accepting new requests.
- Existing requests get a grace period to finish.
- Once all requests are done — the beans are destroyed.
Configuration:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
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));
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
}
}
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;
}
}
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
}
}
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);
}
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);
}
}
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)
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()
Or via SpringApplication.exit():
int exitCode = SpringApplication.exit(context, () -> 0);
System.exit(exitCode);
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
-
ApplicationRunnervsCommandLineRunner— the only difference is the argument format (ApplicationArgumentsvsString[]).ApplicationRunneris preferred. - Runner ordering — via
@Order/Ordered. Runners are invoked after the context is fully initialized but beforeApplicationReadyEvent. -
@PostConstruct≠ runner. "At application startup" tasks belong in runners. -
Graceful shutdown —
server.shutdown=graceful+spring.lifecycle.timeout-per-shutdown-phase. Enabled by default in Boot 4. -
SmartLifecycleordering: startup in ascendingphase, shutdown in descending.WebServerGracefulShutdownLifecyclehasphase = MAX_VALUE, so it stops first. - The JVM shutdown hook is registered automatically (
registerShutdownHook = true). It's what triggerscontext.close()onSIGTERM/Ctrl+C. -
The shutdown chain: shutdown hook →
doClose()→LifecycleProcessor.onClose()→destroyBeans()→@PreDestroy→DisposableBean.destroy(). - Boot 4: graceful shutdown by default, Undertow removed, the shutdown-hook deadlock fixed.
Top comments (0)