skip to content

Explain the mechanism that makes a new thread (e.g. from @Async) lose the caller's transaction context.

level: middleimportance: should knowfreq 40%

answer

  1. TransactionSynchronizationManager = ThreadLocal maps
  2. bindResource(dataSource, connectionHolder)
  3. DataSourceUtils.getConnection looks up thread binding
  4. Pool thread = empty context
  5. One connection per tx, not shareable

basics

~10 s

Spring stores the current transaction's connection in a ThreadLocal (via TransactionSynchronizationManager). ThreadLocals are per-thread, so a new/pool thread has none, and thus no active transaction to join.

solid answer

~40 s

When a Spring transaction begins, the transaction manager binds the transactional resource — a JDBC `Connection` (or a JPA `EntityManager`/Hibernate `Session`) — to the current thread through `TransactionSynchronizationManager`, which is backed by `ThreadLocal`. Every subsequent DAO call on that thread resolves the same resource, which is how they all enlist in one transaction. `ThreadLocal` state is scoped to a single thread and is never automatically copied to another thread. `@Async` submits the method to a `TaskExecutor`, so it executes on a different pool thread whose `TransactionSynchronizationManager` is empty. That thread sees no active transaction: `Propagation.REQUIRED` therefore opens a brand-new one, and there is no way to 'reattach' the caller's transaction, because a transaction maps to one physical connection that two threads can't safely share concurrently.

code

java · 10 lines
java
// Conceptually what Spring does when a @Transactional method starts:
Connection con = dataSource.getConnection();
TransactionSynchronizationManager.bindResource(dataSource, new ConnectionHolder(con));

// Any repository call later resolves the SAME connection via:
Connection current = DataSourceUtils.getConnection(dataSource); // thread-local lookup

// On an @Async pool thread, TransactionSynchronizationManager is empty,
// so DataSourceUtils.getConnection() returns a fresh, non-enlisted connection
// (or a new transaction is opened by @Transactional on that thread).

go deeper

for a junior

Enough to say 'transaction is tied to the thread, new thread = no transaction'.

for a middle

Should name TransactionSynchronizationManager and the ThreadLocal binding of the connection.

for a senior

Explains why sharing the connection is unsafe and how DataSourceUtils lookup works.

for a principal

Distinguishes safe context propagation (TaskDecorator/MDC) from unsafe transaction-resource sharing.

## The building blocks - **`PlatformTransactionManager`** (e.g. `DataSourceTransactionManager`, `JpaTransactionManager`) starts/commits/rolls back transactions. - **`TransactionSynchronizationManager`** is a utility holding several `ThreadLocal` maps: the bound **resources** (`DataSource` → `ConnectionHolder`, `EntityManagerFactory` → `EntityManagerHolder`), the **synchronizations**, the current transaction name, read-only flag, and isolation level. - When `@Transactional` (via `TransactionInterceptor`) opens a transaction, the manager acquires a connection and **binds** it: `TransactionSynchronizationManager.bindResource(dataSource, connectionHolder)`. - Repositories don't take a connection as a parameter — Spring's `DataSourceUtils.getConnection(dataSource)` (and JPA's `EntityManagerFactoryUtils`) **look up** the thread-bound holder. That lookup is what makes all the work join the same transaction. ## Why a new thread loses it `ThreadLocal` values live in each `Thread`'s own `threadLocals` map. There is no inheritance for a pool thread (and even `InheritableThreadLocal` only copies at thread *creation*, not at task submission, and Spring does **not** use it for transaction resources). So when `@Async`'s proxy submits the task to a `TaskExecutor`, the executing pool thread has an empty `TransactionSynchronizationManager`. `DataSourceUtils.getConnection` finds no bound holder → no active transaction on that thread. ## What the async thread does instead - Default `Propagation.REQUIRED`: 'join if one exists, else start a new one.' None exists here → a **new** transaction with its **own** connection. - The result is two independent transactions that commit/roll back separately. ## Why not just propagate it? A transaction is bound to a single physical JDBC `Connection`. JDBC connections are **not** designed for concurrent use by multiple threads; sharing one across the caller and the async worker would corrupt state and interleave commits. So the boundary is intentional, not a Spring bug. ## Related knobs (context, not the mechanism) - Spring's `TaskDecorator` can copy *some* context (e.g. `RequestContextHolder`, MDC, `SecurityContext`) to pool threads, but you should **not** try to copy the transaction resources this way — sharing a live connection across threads is unsafe. - The correct pattern is to open a *new* transaction on the async thread (which happens automatically with `@Transactional`) and coordinate via events/IDs, not shared state.

  • Could InheritableThreadLocal solve this?
    No. It copies values only when a child thread is created, not when a task is submitted to an existing pool thread, and Spring doesn't bind transaction resources with it anyway. More fundamentally, you can't safely share one JDBC connection across two threads.
  • How does a TaskDecorator relate to this?
    A TaskDecorator can propagate ThreadLocal-based context (MDC, SecurityContext, request attributes) to pool threads. It's appropriate for logging/security context, but you should not use it to smuggle the transaction's connection across threads.

saying these in an interview costs you the question

  • Claiming Spring uses InheritableThreadLocal to pass transactions to child threads
  • Saying a TaskDecorator can carry the transaction to the async thread
  • Thinking the connection is simply passed along automatically

context