Appearance
Spring Boot @Async Annotation — Part 1 | ThreadPoolExecutor
The Dry Cleaner Pickup Ticket Analogy
Imagine dropping three winter coats off at a professional dry cleaning shop on Monday morning. The shop attendant does not tell you, "Please stand right here at the counter for four hours while we run the washing machines, dry the fabric, and iron the collars. You may not leave until your coats are finished." If the attendant did that, you could not go to work, grocery shop, or drive your children to school.
Instead, the attendant hands you a claim ticket. The attendant says, "We will clean your coats in the background. Here is your receipt ticket; you can return this afternoon to pick up the finished garments." You walk out immediately and continue your day, while specialized industrial machines process the clothes in the back room.
In web development, Asynchronous Execution (@Async) is that claim ticket. In synchronous execution, your web request thread blocks and waits while slow operations execute — such as sending welcome emails, generating multi page PDF invoices, or pushing events to an external analytics provider. By annotating a method with @Async, Spring intercepts the call, hands off the execution to an isolated background thread pool worker, and allows the HTTP request thread to respond to the user immediately.
This lecture covers the fundamental difference between synchronous and asynchronous execution, enabling @Async, return types (void vs CompletableFuture), why the default SimpleAsyncTaskExecutor is dangerous in production, and configuring a custom ThreadPoolTaskExecutor.
Synchronous vs Asynchronous Execution
[ Synchronous Execution (Blocking) ]
User Click -> [ HTTP Thread-1 ] -----------------------------------------------------> [ Response 200 ]
|
+--> Process Order (10ms)
+--> Charge Card (200ms)
+--> Send Confirmation Email (2000ms - BLOCKS CALLER!)
Total Latency: 2,210 ms!
[ Asynchronous Execution (Non-Blocking) ]
User Click -> [ HTTP Thread-1 ] -----------------------------> [ Response 200 (in 210ms) ]
|
+--> Process Order (10ms)
+--> Charge Card (200ms)
+--> Trigger @Async Email Task (Dispatches in 1ms)
|
v
[ Background Thread Pool Worker ]
+--> Send Confirmation Email (2000ms runs in background)By offloading the slow email dispatch to a background worker thread, perceived API latency drops from 2,210 milliseconds to 210 milliseconds — a 90% performance improvement for the user.
Enabling Asynchronous Support: @EnableAsync
Asynchronous processing is disabled by default in Spring Boot. To activate it, place @EnableAsync on a @Configuration class or on your main application entry class:
java
package com.example.orderservice.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
public class AsyncConfiguration {
// Configures asynchronous infrastructure
}Using the @Async Annotation
Apply @Async to any public method in a Spring managed @Service or @Component:
1. Fire and Forget (void return type)
When the calling code does not require any result from the background operation (e.g. audit logging, sending notifications, warming a cache):
java
package com.example.orderservice.service;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
@Async
public void sendWelcomeEmail(String emailAddress, String customerName) {
System.out.println("[ASYNC WORKER: " + Thread.currentThread().getName() + "] Sending email to: " + emailAddress);
try {
// Simulate slow SMTP server communication
Thread.sleep(2500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("[ASYNC WORKER: " + Thread.currentThread().getName() + "] Email delivered successfully");
}
}When another bean calls notificationService.sendWelcomeEmail(...), the call returns instantaneously on the caller thread. The 2.5 second sleep executes entirely on a background thread.
2. Returning Asynchronous Results (CompletableFuture<T>)
When the caller requires data computed asynchronously:
java
package com.example.orderservice.service;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;
@Service
public class CreditScoreService {
@Async
public CompletableFuture<Integer> calculateCreditScore(String ssn) {
System.out.println("[CREDIT WORKER: " + Thread.currentThread().getName() + "] Computing credit score");
// Simulate heavy remote calculation
try {
Thread.sleep(1200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
int score = 760;
return CompletableFuture.completedFuture(score);
}
}The caller can use standard Java CompletableFuture methods (thenApply, thenCombine, join) to chain or combine parallel calculations:
java
CompletableFuture<Integer> scoreFuture = creditScoreService.calculateCreditScore("123-45-6789");
// Caller does not block until scoreFuture.join() or thenAccept() is invokedThe Danger of Spring Boot's Default Executor
If you annotate methods with @Async without configuring a custom executor, what thread runs the code?
Spring Boot falls back to SimpleAsyncTaskExecutor.
Why SimpleAsyncTaskExecutor Is Catastrophic in Production:
- It is not a thread pool. It does not reuse existing threads.
- For every single task, it instantiates a brand new operating system thread (
new Thread(runnable).start()), and destroys the thread when the task finishes. - Creating an operating system thread requires allocating 1 MB of stack memory and making native OS kernel calls.
- If an unexpected traffic spike submits five thousand concurrent requests,
SimpleAsyncTaskExecutorattempts to spawn five thousand threads simultaneously, quickly causingjava.lang.OutOfMemoryError: unable to create native threadand crashing the entire JVM.
Configuring a Production ThreadPoolTaskExecutor
In production, you must define a managed ThreadPoolTaskExecutor bean with bounded thread pools and queue capacities:
java
package com.example.orderservice.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
@EnableAsync
public class ThreadPoolConfig {
@Bean(name = "applicationTaskExecutor")
public Executor applicationTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 1. Core Pool Size: Number of worker threads kept alive permanently
executor.setCorePoolSize(5);
// 2. Queue Capacity: Tasks queue here before pool expands
executor.setQueueCapacity(100);
// 3. Max Pool Size: Maximum threads created when queue is completely full
executor.setMaxPoolSize(15);
// 4. Thread Name Prefix: For clean stack traces and log debugging
executor.setThreadNamePrefix("async-exec-");
// 5. Keep Alive Seconds: Idle threads above core size terminate after this duration
executor.setKeepAliveSeconds(60);
// 6. Rejection Policy: What happens when queue and max threads are both saturated
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 7. Graceful Shutdown: Wait for active tasks to complete before killing JVM
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}Understanding the 4 Step Task Arrival Progression:
- When a task arrives, if active threads are less than
corePoolSize(5), a new thread is spawned to run it. - If all core threads are busy, the task is placed into the
BlockingQueue(up to 100 tasks). - If the queue fills up completely (100 pending tasks), the executor expands its pool above core size up to
maxPoolSize(15). - If the queue is full and 15 threads are busy, subsequent tasks trigger the
RejectedExecutionHandler(in this case,CallerRunsPolicy, which forces the calling thread to execute the task, naturally throttling incoming traffic).
Using Named Executors with @Async
You can declare multiple distinct thread pools for different application domains (e.g. one small pool for emails, and a large pool for heavy batch processing) and reference them by name in @Async:
java
@Async("emailTaskExecutor")
public void sendEmail() { /* ... */ }
@Async("reportGenerationExecutor")
public void generateHeavyReports() { /* ... */ }Interview Questions & Pitfalls
Q1: What happens if @Async is used without configuring a custom ThreadPoolTaskExecutor?
Spring Boot defaults to using SimpleAsyncTaskExecutor. This executor does not reuse threads: it creates a new operating system thread for every single invocation and destroys it upon completion. Under heavy concurrent load, this exhausts OS memory and thread limits, causing OutOfMemoryError: unable to create native thread.
Q2: What return types are permitted on an @Async method?
An @Async method can return void (for fire and forget tasks), Future<T>, or CompletableFuture<T> (using CompletableFuture.completedFuture(result)). Any other return type will cause unexpected behavior or compilation errors because the calling thread cannot wait synchronously for a raw value without blocking.
Q3: In what sequence does ThreadPoolTaskExecutor expand its threads and queue?
First, it creates threads up to corePoolSize. Second, when core threads are busy, incoming tasks queue up in the BlockingQueue up to queueCapacity. Third, only when the queue is completely full does it create additional threads up to maxPoolSize. Fourth, if both queue and max threads are saturated, the rejection policy triggers.
Q4: What is the benefit of using ThreadPoolExecutor.CallerRunsPolicy as a rejection handler?
CallerRunsPolicy provides natural backpressure. When the queue and thread pool are saturated, instead of throwing an exception and dropping the task, the calling thread (e.g. the HTTP request thread) is forced to execute the task itself. This naturally slows down the rate at which the calling thread can accept new requests, preventing system collapse.
Q5: What is the purpose of setWaitForTasksToCompleteOnShutdown(true) on ThreadPoolTaskExecutor?
It enables graceful shutdown. When the application receives a shutdown signal (SIGTERM), Spring waits up to the configured awaitTerminationSeconds duration for in flight asynchronous tasks to finish processing before terminating the JVM process, preventing data corruption.