Appearance
@Async Part 2: Pitfalls, Return Types and Exceptions
Part 1 showed @Async and the thread pool behind it. This part covers what goes wrong in practice: when @Async silently does nothing, how it combines with @Transactional, how to return results with Future and CompletableFuture, and how to handle exceptions thrown by async methods.
When does @Async work?
Two conditions:
- The call must come from a different class. The
@Asyncmethod must be called from another bean, not from a method in the same class. - The method must not be
private.
This does not work:
java
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping("/getuser")
public String getUserMethod() {
System.out.println("inside getUserMethod: " + Thread.currentThread().getName());
asyncMethodTest(); // same class
return "done";
}
@Async
public void asyncMethodTest() {
System.out.println("inside asyncMethodTest: " + Thread.currentThread().getName());
}
}inside getUserMethod: http-nio-8080-exec-1
inside asyncMethodTest: http-nio-8080-exec-1Same thread: no new thread was created. Move asyncMethodTest into another class (such as UserService) and inject it, as in part 1.
Why: @Async works through AOP (see the AOP chapter). Other beans do not get your object; they get a proxy around it, which intercepts the call and hands the method to an executor. A call inside the same class uses this, not the proxy, so there is no interception. And the proxy is a generated subclass, which cannot override a private method, so a private @Async method is never intercepted either. (Many articles say the method must be public. With Spring Boot 3's class-based proxies, protected and package-private @Async methods run asynchronously too; only private fails.)
@Async and @Transactional
A transaction belongs to a thread. Its context (the transaction itself, its propagation, and so on) is not passed to a new thread.
Use case 1: @Transactional caller, @Async callee (avoid)
java
@Component
public class UserService {
@Autowired
UserUtility userUtility;
@Transactional
public void updateUser() {
// update user status, first name, last name...
userUtility.updateUserBalance();
}
}
@Component
public class UserUtility {
@Async
public void updateUserBalance() {
// update the balance
}
}updateUserBalance runs in a new thread without any transaction. If something fails, the status and name updates are rolled back, but the balance update is not. Do not do this.
Use case 2: @Async and @Transactional on the same method (use with care)
java
@Component
public class UserService {
@Async
@Transactional
public void updateUser() {
// update user status, first name, last name...
}
}A new thread is created, and it runs in a new transaction of its own: failures inside are rolled back. The catch: if the caller has a transaction, that context does not reach the new thread, so propagation settings (for example "join the caller's transaction if there is one") do not work as you would expect.
Use case 3: @Async first, then @Transactional (recommended)
java
@Component
public class UserService {
@Autowired
UserUtility userUtility;
@Async
public void updateUser() {
userUtility.updateUser();
}
}
@Component
public class UserUtility {
@Transactional
public void updateUser() {
// update user status, first name, last name...
}
}The calling thread is free immediately. The new thread then starts a transaction in UserUtility, and all the work happens inside it. This is the safe and common way to combine the two.
Return types: Future and CompletableFuture
To get a result back from the new thread, return a Future or a CompletableFuture. (Both are covered in depth in the Java multithreading chapters.)
java
@Component
public class UserService {
@Async
public CompletableFuture<String> performTaskAsync() {
// long work...
return CompletableFuture.completedFuture("async task result");
}
}java
@GetMapping("/getuser")
public String getUserMethod() {
CompletableFuture<String> result = userService.performTaskAsync();
// the calling thread keeps working here...
String output = null;
try {
output = result.get(); // waits until the async method has finished
System.out.println(output);
} catch (Exception e) {
System.out.println("exception");
}
return output;
}The call returns at once with a reference; get() blocks until the result is ready. CompletableFuture (Java 8) is an enhanced Future that also supports chaining, so it is the usual choice. The method may declare Future<String> and return the same value. (Older code returns Spring's new AsyncResult<>(...); that class is deprecated. The Future interface itself is not.)
Methods of Future:
| Method | Purpose |
|---|---|
boolean cancel(boolean mayInterruptIfRunning) | Tries to cancel the task. Returns false if it cannot be cancelled (typically because it already finished). |
boolean isCancelled() | true if the task was cancelled before it completed. |
boolean isDone() | true if the task completed, normally, with an exception, or by cancellation. |
V get() | Waits if needed, then returns the result. |
V get(long timeout, TimeUnit unit) | Waits at most the timeout; throws TimeoutException if the task is still not done. |
Exception handling
With a return type, it is simple. The exception is stored in the future and thrown when you call get(), wrapped in an ExecutionException:
java.util.concurrent.ExecutionException: java.lang.ArithmeticException: / by zeroCatch it around get() and handle it as you like.
With void, there is nothing to call get() on: the caller has already moved on, so who sees the exception? Two ways.
1. A try/catch inside the async method:
java
@Async
public void performTaskAsync() {
try {
// ...
} catch (Exception e) {
// log it, retry, ...
}
}2. An AsyncUncaughtExceptionHandler. If you do nothing, Spring uses its SimpleAsyncUncaughtExceptionHandler, which just logs:
java
public class SimpleAsyncUncaughtExceptionHandler implements AsyncUncaughtExceptionHandler {
private static final Log logger = LogFactory.getLog(SimpleAsyncUncaughtExceptionHandler.class);
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
if (logger.isErrorEnabled()) {
logger.error("Unexpected exception occurred invoking async method: " + method, ex);
}
}
}ERROR [task-5] .a.i.SimpleAsyncUncaughtExceptionHandler : Unexpected exception occurred invoking async method: public void com.example.UserService.performTaskAsync()
java.lang.ArithmeticException: / by zeroFor your own handling, implement the same interface and register it through AsyncConfigurer:
java
@Component
public class DefaultAsyncUncaughtExceptionHandler implements AsyncUncaughtExceptionHandler {
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
System.out.println("in default uncaught exception method: " + method.getName() + " " + ex);
}
}java
@Configuration
public class AppConfig implements AsyncConfigurer {
@Autowired
DefaultAsyncUncaughtExceptionHandler asyncUncaughtExceptionHandler;
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return asyncUncaughtExceptionHandler;
}
}Now every exception escaping a void @Async method goes to your handler. This saves writing a try/catch in each async method; choose based on what your business logic needs.
Next: That completes AOP, transactions and async work. The next module is about handling HTTP requests and responses. Chapter 18 starts with custom interceptors: running your own code before a request reaches a controller, and using AOP with a custom annotation of your own.
Check yourself
- What two conditions must hold for
@Asyncto run the method in a new thread? Why? - A controller calls its own
@Asyncmethod. Which thread runs it? - Is a
protected@Asyncmethod run asynchronously in Spring Boot 3? Aprivateone? - A
@Transactionalmethod calls an@Asyncmethod in another bean, which then fails. What is rolled back? - What is the risk of putting
@Asyncand@Transactionalon the same method? - What is the recommended way to combine them?
- How do you get a result back from an
@Asyncmethod, and when does the caller wait? - An
@Asyncmethod returningCompletableFuturethrows. Where and how does the caller see the exception? - An
@Async voidmethod throws and nothing is configured. What happens? - How do you plug in your own handler for such exceptions?
Answers
- It must be called from a different bean, and must not be private.
@Asyncworks through an AOP proxy; same-class calls bypass the proxy, and a private method cannot be overridden by the proxy subclass. - The caller's own thread, for example
http-nio-8080-exec-1. - Yes. No.
- The caller's own updates are rolled back. The async method's work ran without a transaction, so it is not.
- It runs in a new, separate transaction; the caller's transaction context is not carried over, so propagation settings do not behave as expected.
@Asyncon the outer method, which calls a@Transactionalmethod in another bean: the whole transaction runs in the new thread.- Return
FutureorCompletableFuture. The caller waits only when it callsget(). - At
get(), as anExecutionExceptionwrapping the original exception. SimpleAsyncUncaughtExceptionHandlerlogs "Unexpected exception occurred invoking async method" with the method and stack trace.- Implement
AsyncUncaughtExceptionHandlerand return it fromgetAsyncUncaughtExceptionHandler()in anAsyncConfigurer.