Resource Binding and the Thread-Local Transaction Registry
Resource Binding and the Thread-Local Transaction Registry
Every Spring Data JPA repository method needs an EntityManager. Every JdbcTemplate call needs a Connection. Within a @Transactional method, all of them must use the same database transaction. Spring achieves this without passing these objects as parameters. The mechanism is thread-local binding.
TransactionSynchronizationManager is the registry. Resources are bound to the current thread at transaction start and unbound at transaction end. Between those two points, any code running on the same thread can retrieve the bound resources.
The Binding API
public abstract class TransactionSynchronizationManager {
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<>("Transactional resources");
public static void bindResource(Object key, Object value) {
Map<Object, Object> map = resources.get();
if (map == null) {
map = new HashMap<>();
resources.set(map);
}
Object oldValue = map.put(key, value);
if (oldValue != null) {
throw new IllegalStateException(
"Already value [" + oldValue + "] for key [" + key + "] bound to thread"
);
}
}
public static Object getResource(Object key) {
Map<Object, Object> map = resources.get();
if (map == null) return null;
return map.get(key);
}
public static Object unbindResource(Object key) {
Map<Object, Object> map = resources.get();
if (map == null) return null;
Object value = map.remove(key);
if (map.isEmpty()) {
resources.remove();
}
return value;
}
}
The key is typically a factory object: EntityManagerFactory for JPA resources, DataSource for JDBC resources. The value is a holder object wrapping the actual resource.
bindResource() throws if a resource is already bound for the same key on the same thread. This prevents accidental double-binding and signals misuse of the transaction manager.
EntityManagerHolder and ConnectionHolder
Resources are not stored directly. They are wrapped in holder objects that carry additional state:
public class EntityManagerHolder extends ResourceHolderSupport {
private final EntityManager entityManager;
// ResourceHolderSupport provides:
// - synchronizedWithTransaction flag
// - referenceCount (for nested transactions)
// - rollbackOnly flag
// - deadline for timeout
}
public class ConnectionHolder extends ResourceHolderSupport {
private Connection connection;
private boolean transactionActive;
}
The holder pattern serves two purposes:
-
Reference counting: When a transaction suspends (e.g.,
REQUIRES_NEW) and a new transaction begins, the holder tracks how many transactions reference the resource. The resource is only released when the count reaches zero. -
Transaction metadata: The holder carries flags like
rollbackOnlyandsynchronizedWithTransactionthat the transaction manager checks during commit/rollback.
JpaTransactionManager: The Full Binding Sequence
When @Transactional triggers on OrderService.createOrder():
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final JdbcTemplate jdbcTemplate;
@Transactional
public Order createOrder(Order order) {
Order saved = orderRepository.save(order);
jdbcTemplate.update("INSERT INTO audit ...", ...);
return saved;
}
}
JpaTransactionManager.doBegin() executes:
Step 1: Create EntityManager
em = entityManagerFactory.createEntityManager()
Step 2: Wrap in holder
emHolder = new EntityManagerHolder(em)
Step 3: Bind to thread
TransactionSynchronizationManager.bindResource(entityManagerFactory, emHolder)
Step 4: Get JDBC Connection from EntityManager
conn = em.unwrap(SessionImplementor.class).getJdbcCoordinator()
.getLogicalConnection().getPhysicalConnection()
Step 5: Wrap Connection in holder
connHolder = new ConnectionHolder(conn)
Step 6: Bind Connection to thread
TransactionSynchronizationManager.bindResource(dataSource, connHolder)
Step 7: Begin database transaction
conn.setAutoCommit(false)
After these steps, the thread-local map contains:
Thread-42:
EntityManagerFactory@7a3d -> EntityManagerHolder(em@9f2c)
HikariDataSource@3b1e -> ConnectionHolder(conn@5d4a)
Any code running on Thread-42 can retrieve either resource.
SharedEntityManagerCreator: The EntityManager Proxy
When you inject EntityManager using @PersistenceContext:
@Service
public class CustomOrderService {
@PersistenceContext
private EntityManager entityManager;
}
The injected EntityManager is not a real EntityManager. It is a proxy created by SharedEntityManagerCreator. This proxy implements EntityManager and, on every method call, looks up the thread-bound EntityManager:
// SharedEntityManagerCreator.SharedEntityManagerInvocationHandler (simplified)
public Object invoke(Object proxy, Method method, Object[] args) {
EntityManagerHolder holder =
TransactionSynchronizationManager.getResource(entityManagerFactory);
if (holder != null && holder.getEntityManager() != null) {
// Use the thread-bound EntityManager (inside a transaction)
return method.invoke(holder.getEntityManager(), args);
}
// No transaction active: create a short-lived EntityManager
EntityManager em = entityManagerFactory.createEntityManager();
try {
return method.invoke(em, args);
} finally {
em.close();
}
}
The proxy ensures that @PersistenceContext EntityManager always delegates to the transaction’s EntityManager when one exists. Without the proxy, the injected EntityManager would be a single instance shared across all transactions on all threads, which would be catastrophic.
Spring Data JPA uses the same mechanism internally. When SimpleJpaRepository calls entityManager.persist(), the entityManager field is a SharedEntityManagerCreator proxy. It resolves to the thread-bound EntityManager, ensuring repository operations participate in the current transaction.
How Spring Data Gets the EntityManager
The chain from repository method call to EntityManager lookup:
orderRepository.save(order)
-> JDK dynamic proxy (CH18-S1)
-> SimpleJpaRepository.save(order)
-> this.entityManager.persist(order)
-> SharedEntityManagerCreator proxy
-> TransactionSynchronizationManager.getResource(emf)
-> EntityManagerHolder.getEntityManager()
-> actual EntityManager.persist(order)
Every repository method call goes through this chain. The proxy lookup is fast (thread-local HashMap get), but it is not free. In a tight loop with thousands of individual saves, the overhead becomes measurable. This is one reason saveAll() exists: batch operations reduce the number of proxy traversals.
Thread Safety and Virtual Threads
ThreadLocal means each thread gets its own copy. On a traditional thread-per-request model, this is safe: each request runs on its own thread, each thread has its own EntityManager, no sharing.
With virtual threads (Java 21), the same contract holds. Virtual threads are still threads. Each virtual thread has its own ThreadLocal storage. The concern with virtual threads is not thread-safety but resource exhaustion: if you spawn 10,000 virtual threads, each with its own EntityManager and Connection, you exhaust the connection pool.
Spring Framework 6.1+ provides TransactionSynchronizationManager awareness for virtual threads. The thread-local binding works identically. The constraint shifts from thread management to connection pool sizing.
The Failure Mode: Manual EntityManager Creation
// BROKEN: manually creating an EntityManager inside a @Transactional method
@Service
public class OrderService {
@PersistenceUnit
private EntityManagerFactory entityManagerFactory;
private final OrderRepository orderRepository;
@Transactional
public Order createOrder(Order order) {
// This EntityManager is NOT the transaction's EntityManager
EntityManager manualEm = entityManagerFactory.createEntityManager();
Order saved = orderRepository.save(order); // Uses thread-bound EM
// This runs in a SEPARATE persistence context
manualEm.persist(new AuditEntry(saved.getId(), "CREATED"));
manualEm.flush();
manualEm.close();
return saved;
}
}
Two EntityManagers exist on the same thread. The repository’s save() uses the thread-bound EntityManager (managed by JpaTransactionManager). The manually created EntityManager is independent. It has its own persistence context. Its persist() call does not participate in the transaction.
If the repository save() succeeds but something later throws and the transaction rolls back, the audit entry persisted by the manual EntityManager is already flushed and committed (assuming autocommit). The data is inconsistent.
Worse: the manually created EntityManager is never closed if an exception occurs before manualEm.close(). Connection leak.
Thread-42 state:
Bound EM (transaction-managed): em@9f2c -> Connection from pool
Manual EM (unmanaged): em@1a3b -> SECOND Connection from pool
save(order) -> [email protected](order) [in transaction]
persist(audit) -> [email protected](audit) [autocommit, separate connection]
The Correct Pattern
// CORRECT: let Spring manage the EntityManager
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final AuditRepository auditRepository;
@Transactional
public Order createOrder(Order order) {
Order saved = orderRepository.save(order);
auditRepository.save(new AuditEntry(saved.getId(), "CREATED"));
return saved;
}
}
Both repositories use the same thread-bound EntityManager. Both participate in the same transaction. If either fails, both roll back.
If you need EntityManager directly for a criteria query or a native query that does not fit a repository method:
// CORRECT: inject the SharedEntityManagerCreator proxy
@Service
public class OrderService {
private final OrderRepository orderRepository;
@PersistenceContext
private EntityManager entityManager; // This is a proxy
@Transactional
public Order createOrder(Order order) {
Order saved = orderRepository.save(order);
// Same thread-bound EntityManager, same transaction
entityManager.createNativeQuery(
"INSERT INTO audit (order_id, action) VALUES (?, ?)")
.setParameter(1, saved.getId())
.setParameter(2, "CREATED")
.executeUpdate();
return saved;
}
}
The @PersistenceContext EntityManager is a SharedEntityManagerCreator proxy. It resolves to the thread-bound EntityManager. Same persistence context. Same transaction. No leaks.
Never call entityManagerFactory.createEntityManager() inside application code. Let the transaction manager and the SharedEntityManagerCreator proxy handle EntityManager lifecycle. You write business logic. Spring manages plumbing.