Part 2: Connection Pool Leaks — How Spring Helps You Avoid Them
Series Navigation - Part 1: What They Are and Why They Matter - Part 2: How Spring Helps You Avoid Them (current) - Part 3: Built-in Detection in Major Connection Pools - Part 4: Building a Custom Connection Monitor - Part 5: Root Cause Analysis with Heap Dumps
1. How Spring Manages Connections Automatically
Spring provides several abstraction layers that automatically handle connection borrowing and returning, serving as the first line of defense against connection leaks.
1.1 JdbcTemplate: Automatic Connection Lifecycle Management
// ❌ Manual management — prone to leaks
public User findUser(Long id) throws SQLException {
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT * FROM user WHERE id = ?");
ps.setLong(1, id);
ResultSet rs = ps.executeQuery();
// if an exception is thrown here, conn is never closed → leak!
User user = mapRow(rs);
conn.close();
return user;
}
// ✅ Using JdbcTemplate — connection managed automatically
public User findUser(Long id) {
return jdbcTemplate.queryForObject(
"SELECT * FROM user WHERE id = ?",
(rs, rowNum) -> mapRow(rs),
id
);
// connection is correctly released whether or not an exception occurs
}
The core release logic inside JdbcTemplate (simplified):
public <T> T execute(StatementCallback<T> action) {
Connection con = DataSourceUtils.getConnection(obtainDataSource());
try {
return action.doInStatement(stmt);
} catch (SQLException ex) {
throw translateException(ex);
} finally {
DataSourceUtils.releaseConnection(con, obtainDataSource()); // always executed
}
}
DataSourceUtils.releaseConnection() checks the current context:
- Inside a transaction: does not close the connection; the transaction manager will close it when the transaction ends
- Outside a transaction: returns the connection to the pool immediately
1.2 @Transactional: Transaction Manager Owns the Connection Lifecycle
@Service
public class OrderService {
@Transactional
public void createOrder(Order order) {
orderDao.insert(order); // uses the transaction-bound connection
inventoryDao.decrease(order); // same connection
// normal return → auto commit + release connection
// RuntimeException thrown → auto rollback + release connection
}
}
Connection lifecycle under @Transactional:
@Transactional method begins
│
├── TransactionManager borrows a connection from the pool
├── Binds the connection to ThreadLocal (TransactionSynchronizationManager)
├── Sets autoCommit = false
│
├── Business logic executes (all DAO calls share the same connection)
│
├── Method returns normally → commit → release connection back to pool
└── Method throws exception → rollback → release connection back to pool
1.3 Spring Data JPA / MyBatis: Highest-Level Abstraction
Developers never touch the Connection object at all:
// Spring Data JPA
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByName(String name);
}
// MyBatis
@Mapper
public interface UserMapper {
@Select("SELECT * FROM user WHERE id = #{id}")
User findById(Long id);
}
1.4 The Connection Management Hierarchy
Spring Data JPA / MyBatis ← safest: no contact with Connection at all
↓
JdbcTemplate / NamedParameterJdbcTemplate ← safe: connection released automatically
↓
@Transactional + DataSourceUtils ← safe: transaction manager owns the connection
↓
dataSource.getConnection() ← dangerous: must be managed manually
Principle: prefer higher-level abstractions whenever possible. Avoid operating on Connection directly.
2. Scenarios Where Leaks Can Still Occur with Spring
Spring is not a silver bullet. The following scenarios require special attention.
Scenario 1: Bypassing Spring to Get a Connection Directly
@Service
public class SomeService {
@Autowired
private DataSource dataSource;
public void doSomething() throws SQLException {
// ❌ bypassing Spring, getting a connection manually
Connection conn = dataSource.getConnection();
// forgetting close() or not closing on an exception path → leak
}
}
Fix: use DataSourceUtils, which correctly handles the transaction context:
public void doSomething() {
Connection conn = DataSourceUtils.getConnection(dataSource);
try {
// ... use connection ...
} finally {
DataSourceUtils.releaseConnection(conn, dataSource);
}
}
Scenario 2: @Transactional Not Taking Effect
When @Transactional does not take effect, any code that manually acquires a connection will not have that connection managed by the transaction manager.
| Situation | Reason |
|---|---|
Calling a method on this within the same class |
Self-invocation bypasses the Spring proxy |
Method is not public |
Spring AOP only proxies public methods by default |
| Exception is caught and swallowed | The transaction manager never sees the exception |
| Class is not managed by Spring | Objects created with new are not proxied |
| Checked exception is thrown | Only RuntimeException and Error trigger rollback by default |
@Service
public class UserService {
// ❌ self-invocation — @Transactional has no effect
public void batchProcess(List<User> users) {
for (User user : users) {
this.updateUser(user); // calls this directly, bypasses the proxy
}
}
@Transactional
public void updateUser(User user) {
userDao.update(user);
}
}
Scenario 3: Long-Running Non-Database Operations Inside a Transaction
@Transactional
public void processOrder(Order order) {
orderDao.insert(order);
// ❌ calling an external HTTP service inside the transaction
// may take tens of seconds, holding the connection the entire time
// behaves like a leak: other requests cannot get a connection
httpClient.callExternalService(order);
orderDao.updateStatus(order, "CONFIRMED");
}
Fix: move non-database operations outside the transaction boundary:
public void processOrder(Order order) {
saveOrder(order); // inside transaction: database only
httpClient.callExternalService(order); // outside transaction: HTTP call
confirmOrder(order); // inside transaction: database only
}
@Transactional
public void saveOrder(Order order) {
orderDao.insert(order);
}
@Transactional
public void confirmOrder(Order order) {
orderDao.updateStatus(order, "CONFIRMED");
}
Scenario 4: Database Operations in Async Threads Within a Transaction
This is the most easily overlooked scenario.
Root cause: Spring's transaction context is bound to the current thread via ThreadLocal (TransactionSynchronizationManager). An async thread is a new thread and does not inherit the parent thread's ThreadLocal, which means:
- The async thread cannot see the parent thread's transaction connection
- If the async thread needs database access, it obtains a new connection from the pool
- This new connection is not under the control of the parent thread's transaction manager — the async thread is solely responsible for releasing it
Case 1: Manually spawning threads inside a @Transactional method
@Transactional
public void processOrders(List<Order> orders) {
// parent thread: has a transaction, connection A is bound in ThreadLocal
ExecutorService executor = Executors.newFixedThreadPool(4);
for (Order order : orders) {
executor.submit(() -> {
// ❌ child thread: no transaction context
// manually getting connection B without closing it → leak
Connection conn = dataSource.getConnection();
// ... operations, but close() is forgotten ...
});
}
executor.shutdown();
// parent thread's transaction commits, connection A is released
// child thread's connection B has no owner → leak
}
Case 2: Exception swallowed in a child thread
executor.submit(() -> {
Connection conn = null;
try {
conn = dataSource.getConnection();
// ... operations ...
throw new RuntimeException("something went wrong");
} catch (Exception e) {
// ❌ exception is swallowed, conn is not closed in a finally block
log.error("error", e);
}
// conn is leaked!
});
Case 3: Manual connection acquisition inside CompletableFuture
@Transactional
public void batchProcess(List<Long> ids) {
List<CompletableFuture<Void>> futures = ids.stream()
.map(id -> CompletableFuture.runAsync(() -> {
// ❌ async thread, no transaction context
// manual getConnection() without close() → leak
Connection conn = dataSource.getConnection();
// ...
}, customExecutor))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
Connection safety comparison across async scenarios:
| Scenario | Connection Management | Leaks? | Notes |
|---|---|---|---|
| Async thread + JdbcTemplate | Spring automatic | ❌ No | JdbcTemplate releases automatically; operations are outside the parent transaction |
| Async thread + manual getConnection() not closed | Manual | ✅ Yes | Nobody manages the child thread's connection |
| @Async + @Transactional | Spring manages new transaction | ❌ No | New transaction manager owns the independent connection |
| @Async + manual getConnection() not closed | Manual | ✅ Yes | Same as above |
| CompletableFuture + JdbcTemplate | Spring automatic | ❌ No | JdbcTemplate releases automatically |
| Child thread exception swallowed + manual getConnection() | Manual | ✅ Yes | Exception path does not close the connection |
Conclusion: whether a leak occurs in an async thread depends on how the connection is managed, not on whether the operation is async.
Fixes:
// ✅ Option 1: use JdbcTemplate in the async thread — Spring manages the connection
executor.submit(() -> {
orderDao.update(order); // JdbcTemplate underneath, connection released automatically
});
// ✅ Option 2: use @Async + @Transactional — Spring manages an independent transaction
@Service
public class AsyncOrderService {
@Async
@Transactional
public CompletableFuture<Void> processOrderAsync(Order order) {
orderDao.update(order);
return CompletableFuture.completedFuture(null);
// Spring commits the transaction and releases the connection when the method returns
}
}
// ✅ Option 3: if manual connection access is unavoidable, use try-with-resources
executor.submit(() -> {
try (Connection conn = dataSource.getConnection()) {
// ... use connection ...
} catch (SQLException e) {
log.error("error", e);
// try-with-resources guarantees the connection is closed regardless of exceptions
}
});
Scenario 5: Manually Created EntityManager Not Closed
@Service
public class ReportService {
@PersistenceUnit
private EntityManagerFactory emf;
public List<Report> generateReport() {
// ❌ manually creating an EntityManager and forgetting to close it
EntityManager em = emf.createEntityManager();
List<Report> reports = em.createQuery("SELECT r FROM Report r", Report.class)
.getResultList();
// em.close() is omitted → underlying connection leaks
return reports;
}
}
Fix: inject a container-managed EntityManager via @PersistenceContext:
@Service
public class ReportService {
@PersistenceContext
private EntityManager em; // lifecycle managed by the Spring container
public List<Report> generateReport() {
return em.createQuery("SELECT r FROM Report r", Report.class)
.getResultList();
// connection managed by Spring automatically
}
}
3. Best Practices Checklist
| Practice | Description |
|---|---|
| Prefer JdbcTemplate / Spring Data / MyBatis | Avoid touching Connection directly |
| Keep transactions short — no slow non-database operations inside | Move HTTP calls, file I/O, and message sending outside transaction boundaries |
| Ensure @Transactional actually takes effect | Avoid self-invocation, non-public methods, swallowed exceptions |
| Use JdbcTemplate or @Async + @Transactional in async threads | Do not manually acquire connections in async threads |
| If Connection must be used directly, use DataSourceUtils or try-with-resources | Ensure the connection is released on every code path |
| Enable connection pool leak detection as a safety net | Recommended even when all the above practices are followed |
This series is based on Druid 1.2.x, HikariCP 5.x (Java 11+; use HikariCP 3.x for Java 8), Tomcat JDBC 10.x, and DBCP2 2.9.x. All code examples are for reference only. Adjust them to your specific environment before using in production.