Appearance
@Async Part 1: Thread Pools and @Async
Every request so far ran start to finish on one thread, and the client waited until all the work was done. @Async moves a method onto another thread, so the caller does not wait.
This chapter builds on three Java topics: creating threads and their lifecycle, thread pools with ThreadPoolExecutor, and Future/CompletableFuture. Here is a short recap of the thread pool part.
Thread pools in brief
A thread pool is a set of threads (workers) that wait for tasks. When a thread finishes a task, it goes back to the pool and picks the next one: threads are reused instead of created for every task.
Three numbers control a pool:
- min (core) pool size: threads kept ready
- max pool size: the most threads the pool may ever have
- queue size: how many tasks may wait
java
ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(
minPoolSize, maxPoolSize, 1, TimeUnit.HOURS, // keep-alive for extra threads
new ArrayBlockingQueue<>(queueSize));Take min = 2, max = 4, queue = 3, and tasks that each take a while:
| Task | What happens |
|---|---|
| 1, 2 | Thread 1 and thread 2 take them. Both busy. |
| 3, 4, 5 | No free thread, so they wait in the queue. The queue is now full. |
| 6 | No free thread and no room in the queue, but only 2 of max 4 threads exist: thread 3 is created for task 6. |
| 7 | Same: thread 4 is created. |
| 8 | 4 threads busy, queue full, max reached: task 8 is rejected. |
| later | Thread 1 finishes task 1 and takes task 3 from the front of the queue. |
Note the order: new threads beyond the core size are created only once the queue is full.
@Async
@Async marks a method to run in a new thread, so the calling thread does not wait for it.
First, enable it on the application class:
java
@SpringBootApplication
@EnableAsync
public class SpringbootApplication {
public static void main(String[] args) {
SpringApplication.run(SpringbootApplication.class, args);
}
}@EnableAsync makes Spring create the beans that async processing needs (interceptors and so on). Without it, @Async is ignored.
java
@RestController
@RequestMapping("/api")
public class UserController {
@Autowired
UserService userService;
@GetMapping("/getuser")
public String getUserMethod() {
System.out.println("inside getUserMethod: " + Thread.currentThread().getName());
userService.asyncMethodTest();
return "done";
}
}java
@Component
public class UserService {
@Async
public void asyncMethodTest() {
System.out.println("inside asyncMethodTest: " + Thread.currentThread().getName());
}
}Calling the API several times:
inside getUserMethod: http-nio-8080-exec-1
inside asyncMethodTest: task-1
inside getUserMethod: http-nio-8080-exec-2
inside asyncMethodTest: task-2
...
inside getUserMethod: http-nio-8080-exec-8
inside asyncMethodTest: task-8
inside getUserMethod: http-nio-8080-exec-9
inside asyncMethodTest: task-1The async method runs in a different thread from the request. And after task-8, the names start again from task-1: threads are being reused, so there is a pool behind this.
Which executor does @Async use?
You will often read: "Spring Boot uses SimpleAsyncTaskExecutor by default, which creates a new thread every time." That is not the full answer; the output above already contradicts it.
Spring's AsyncExecutionInterceptor first looks for a default executor, and only if it finds none does it fall back to SimpleAsyncTaskExecutor:
java
protected Executor getDefaultExecutor(BeanFactory beanFactory) {
Executor defaultExecutor = super.getDefaultExecutor(beanFactory);
return (defaultExecutor != null ? defaultExecutor : new SimpleAsyncTaskExecutor());
}Which executor it finds depends on what you have defined. Four cases.
Case 1: nothing defined
At startup, Spring Boot sees no executor bean, so it creates its own ThreadPoolTaskExecutor, and @Async uses it:
| Setting | Value |
|---|---|
| core pool size | 8 |
| max pool size | Integer.MAX_VALUE |
| queue capacity | Integer.MAX_VALUE |
| keep-alive | 60 seconds |
That is why the threads were task-1 to task-8, then reused.
ThreadPoolTaskExecutor is Spring's wrapper around Java's ThreadPoolExecutor: internally it creates a plain ThreadPoolExecutor, with more readable setters.
Not recommended, because of the huge queue:
- Under-used threads. Extra threads are created only when the queue is full, and a queue of 2 billion is practically never full. The pool stays at 8 threads even when the machine could run more.
- High latency. Under load, tasks pile up in the queue waiting for one of the 8 threads.
- High memory use. Every waiting task takes memory, and nothing limits how many wait.
- Thread exhaustion, in theory: if the queue ever did fill, new threads would be created up to
Integer.MAX_VALUE, far more than the server can handle.
Case 2: your own ThreadPoolTaskExecutor (recommended)
java
@Configuration
public class AppConfig {
@Bean(name = "myThreadPoolExecutor")
public Executor taskPoolExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(3);
executor.setThreadNamePrefix("MyThread-");
executor.initialize();
return executor;
}
}At startup Spring Boot sees an executor bean, so it does not create its own. @Async without a name picks up yours as the default, and @Async("myThreadPoolExecutor") does the same thing explicitly:
inside getUserMethod: http-nio-8080-exec-1
inside asyncMethodTest: MyThread-1
inside getUserMethod: http-nio-8080-exec-2
inside asyncMethodTest: MyThread-2
inside getUserMethod: http-nio-8080-exec-3
inside asyncMethodTest: MyThread-1Put a Thread.sleep in the async method and call the API 8 times quickly, and the pool behaves exactly as in the table at the top: threads 1 and 2, three tasks queued, threads 3 and 4 created, and the 8th task rejected with TaskRejectedException. You chose the numbers for your system, and the pool respects them. This fixes all the problems of case 1.
Case 3: your own plain Java ThreadPoolExecutor
java
@Configuration
public class AppConfig {
@Bean(name = "myThreadPoolExecutor")
public Executor taskExecutor() {
return new ThreadPoolExecutor(2, 4, 1, TimeUnit.HOURS,
new ArrayBlockingQueue<>(3), new CustomThreadFactory());
}
}
class CustomThreadFactory implements ThreadFactory {
private final AtomicInteger threadNo = new AtomicInteger(1);
public Thread newThread(Runnable runnable) {
Thread th = new Thread(runnable);
th.setName("MyThread-" + threadNo.getAndIncrement());
return th;
}
}Now, with a plain @Async:
inside asyncMethodTest: SimpleAsyncTaskExecutor-1
inside asyncMethodTest: SimpleAsyncTaskExecutor-2
...
inside asyncMethodTest: SimpleAsyncTaskExecutor-9
inside asyncMethodTest: SimpleAsyncTaskExecutor-10Spring Boot sees an executor bean, so it does not create its default pool. But the lookup for a default wants a Spring TaskExecutor (or a bean named taskExecutor), and a plain Java ThreadPoolExecutor named myThreadPoolExecutor is neither. No default is found, so SimpleAsyncTaskExecutor is used: a new thread for every call, numbers climbing forever.
SimpleAsyncTaskExecutor is not recommended:
- Thread exhaustion: a new thread for every async call, with no limit.
- Creation overhead: threads are never reused, so creating and destroying them costs time.
- High memory use: every running thread needs memory.
The fix: with a plain Java executor, always name it in the annotation:
java
@Async("myThreadPoolExecutor")
public void asyncMethodTest() { ... }Output: MyThread-1, MyThread-2, MyThread-1, ...
Case 4: AsyncConfigurer (one default for everyone)
Cases 1 to 3 are confusing: sometimes the name is optional, sometimes required. To make your executor the default whatever @Async looks like, implement AsyncConfigurer and override getAsyncExecutor():
java
@Configuration
public class AppConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
return new ThreadPoolExecutor(2, 4, 1, TimeUnit.HOURS,
new ArrayBlockingQueue<>(3), new CustomThreadFactory());
}
}Now a plain @Async always uses this executor, whether it is a plain Java ThreadPoolExecutor or a ThreadPoolTaskExecutor (for the latter, call initialize() before returning it). This is common in industry.
getAsyncExecutor() is not an @Bean method, so you may see code that caches the executor in a field inside a synchronized method, to be sure only one pool exists. Spring already calls getAsyncExecutor() only once and keeps the result, so that is not required.
Next: Part 2 covers where @Async silently fails, how it combines with @Transactional, returning results, and handling exceptions.
Check yourself
- In a pool with min 2, max 4, queue 3, all tasks slow, what happens to tasks 3, 6 and 8?
- What does
@EnableAsyncdo? - The output shows
task-1…task-8and thentask-1again. What does that tell you? - What does Spring look for before falling back to
SimpleAsyncTaskExecutor? - What are the settings of Spring Boot's default executor, and why is it not recommended?
- What is the relationship between
ThreadPoolTaskExecutorandThreadPoolExecutor? - You define a
ThreadPoolTaskExecutorbean namedmyThreadPoolExecutor. Which executor does a plain@Asyncuse? - You define a plain Java
ThreadPoolExecutorbean instead. Which executor does a plain@Asyncuse, and how do you fix it? - Give three problems with
SimpleAsyncTaskExecutor. - How does
AsyncConfigurerremove the confusion between the cases?
Answers
- Task 3 waits in the queue. Task 6 finds the queue full, so a third thread is created for it. Task 8 finds 4 threads busy and the queue full, so it is rejected.
- It makes Spring create the beans that process
@Async; without it@Asyncdoes nothing. - Threads are being reused: a pool (Spring Boot's default, with 8 core threads) is behind it.
- A default executor: a unique
TaskExecutorbean, or an executor bean namedtaskExecutor. - Core 8, max
Integer.MAX_VALUE, queueInteger.MAX_VALUE. The queue never fills, so threads stay at 8 (under-use, latency), waiting tasks use unbounded memory, and in theory threads could grow without limit. ThreadPoolTaskExecutoris Spring's wrapper; it creates aThreadPoolExecutorinside.- Yours (
MyThread-...), with or without the name in@Async. SimpleAsyncTaskExecutor, a new thread per call. Write@Async("myThreadPoolExecutor").- Thread exhaustion, thread creation overhead, high memory use.
- Its
getAsyncExecutor()becomes the default for every@Async, whatever kind of executor it returns.