Zum Hauptinhalt springen

Week 8 Day 5 — @Async, @Transactional, and ThreadLocal

Goal

Today I want the Spring-specific punchline: proxies and thread-bound state. Both @Async and @Transactional care which thread you are on, and this.foo() skips the proxy.

Main questions:

  1. How does @Async run work?
  2. Why does self-invocation skip it?
  3. Why is the persistence context thread-bound?
  4. What is ThreadLocal for, and how does it leak?
  5. What should I say in an interview that ties this week together?

1. @Async is a proxy that submits


@Service
public class MailService {
@Async("ordersExecutor")
public CompletableFuture<Void> send(Order order) {
mail.send(...);
return CompletableFuture.completedFuture(null);
}
}

The container wraps the bean. A caller with the injected reference hits the proxy. The proxy submits send to the named executor. The HTTP thread continues.

Needs @EnableAsync and a configured executor (Day 4). Exceptions on the pool thread die unless the return type is Future/CompletableFuture I handle, or I set an async exception handler.

Memory sentence:

@Async is a proxy that submits the method. this is not the proxy.


2. Self-invocation


public void place(Order order) {
this.send(order); // @Async on send does not run — same as @Transactional
}

Same rule as Week 3 Day 5: this is the target. The submit never happens; send runs on the request thread.

Fix: inject self (ugly), split into two beans (OrderService calls MailService), or submit to an Executor explicitly.

@Transactional on this.save() skips the transaction interceptor. @Async on this.send() skips the executor. One OOP fact, two annotations.


3. @Transactional is thread-bound

Spring stores the EntityManager / JDBC connection in a ThreadLocal for the duration of the proxy call.

Consequences:

  • The same thread that entered @Transactional must do the lazy loads and the save.
  • @Async work is a different thread: no session, unless that method is itself @Transactional (a new transaction, not the caller’s).
  • stream().parallel() inside a transactional method (Week 7) walks entities on ForkJoin threads with no persistence context.
  • After the method returns, the thread-local is cleared. Collecting a lazy Stream in the controller later fails.

Propagation (REQUIRED, REQUIRES_NEW) is still the same thread unless I hop pools.

Memory sentence:

The persistence context is bound to the thread that opened the transaction. @Async is another thread.


4. ThreadLocal uses and leaks

ThreadLocal is a per-thread slot. Spring uses it for:

  • transaction resources
  • SecurityContext (mode dependent; can be inheritable)
  • MDC logging
  • request attributes in some filters

On a pool thread, a value I set and forget stays for the next task. That is a leak: the next HTTP or async job sees the previous user’s security context or MDC.

Rules:

  • try { set; work } finally { remove; }
  • Do not store request-scoped objects in a static ThreadLocal on a singleton
  • @Async does not copy ThreadLocals unless I configure a TaskDecorator (Boot can copy MDC)

Request thread Tomcat workers are reused too. Filters must clear.


5. Interview bridge (90 seconds)

A Boot app is one JVM with a request pool sharing singleton beans, so I keep services stateless. When I need shared mutation I use atomics or concurrent collections, not a raw int field. Locks exclude and publish; volatile only publishes. I run background work on a bounded TaskExecutor, never new Thread() per task. @Transactional and @Async are proxies: self-invocation skips them. The persistence context is a ThreadLocal on the caller’s thread, so async and parallel streams do not see it. I remove() ThreadLocals on pool threads so the next task does not inherit a user.

Then I shut up.


6. Common traps

Trap 1: @Async on a private method or on this.

Trap 2: Assuming @Async joins the caller’s transaction.

Trap 3: Default @Async executor under load (unbounded threads).

Trap 4: SecurityContext missing in @Async without a decorator.

Trap 5: ThreadLocal as a “hidden parameter” on a singleton service.


Practice Questions and Answers

Question 1

How does @Async relate to CompletableFuture?

Answer:

@Async is Spring’s proxy that submits the method to an executor. Returning CompletableFuture lets the caller compose. thenApply still needs a thought about which thread runs it. Self-invocation skips the proxy, same as transactions. Exceptions on the pool thread need handling.


Question 2

Why does @Transactional care which thread I am on?

Answer:

The session/connection is stored in a ThreadLocal for that invocation. Another thread does not see it. Lazy loads and saves belong on the transactional thread. @Async starts a different thread and therefore a different (or empty) transaction.


Question 3

How does ThreadLocal leak?

Answer:

Pool threads are reused. If I set and never remove, the next task on that worker sees the old value — MDC, security, a leftover EntityManager. Always remove in finally, or use a framework decorator that does.


Question 4

Why split OrderService and MailService for async mail?

Answer:

So the call goes through the MailService proxy. this.send() inside OrderService would stay on the request thread. Two beans are composition (Week 3) plus a working proxy.


Question 5

Does @Async run after the transaction commits?

Answer:

Not by default. Submit happens when the proxy is invoked, which may be during the caller’s transaction. The mail thread can run before commit and see uncommitted data, or fail while the HTTP thread still holds the transaction. If I need “after commit,” I use TransactionSynchronization / an application event with @TransactionalEventListener(AFTER_COMMIT), not raw @Async from inside the transactional method.


Memory sentences

@Async is a proxy that submits the method. this is not the proxy.

The persistence context is bound to the thread that opened the transaction.

ThreadLocal on a pool thread leaks unless I remove.

Next: Week 8 Review