Building a ConnectionPool class from scratch with acquire/release semantics, why BlockingQueue is the natural internal structure, and the block-vs-timeout-vs-throw decision when the pool is exhausted.
Published September 23, 2026
Connection Pooling covered why pools exist and how to tune a production one (HikariCP). This lesson is the from-scratch class design an interviewer might ask for directly.
class ConnectionPool {
private final BlockingQueue<Connection> availableConnections;
private final Set<Connection> allConnections; // tracks every connection this pool owns, for leak detection
private final int maxSize;
ConnectionPool(int maxSize, ConnectionFactory factory) {
this.maxSize = maxSize;
this.availableConnections = new LinkedBlockingQueue<>(maxSize);
this.allConnections = ConcurrentHashMap.newKeySet();
for (int i = 0; i < maxSize; i++) {
Connection conn = factory.create();
availableConnections.offer(conn);
allConnections.add(conn);
}
}
Connection acquire() throws InterruptedException {
return availableConnections.take(); // blocks if none available — see the exhaustion policy discussion below
}
void release(Connection conn) {
if (!allConnections.contains(conn)) throw new IllegalArgumentException("Connection not owned by this pool");
availableConnections.offer(conn); // returns it to the pool for reuse
}
}
A connection pool's core operation — "hand out an available resource, block if none are free, and let another thread's release() unblock a waiting acquirer" — is exactly BlockingQueue's contract (see Concurrent Utilities & Coordination): take() blocks until an element is available, offer()/put() makes one available and wakes a waiting take(). Building this manually with wait()/notify() (the way Producer-Consumer Class Design does deliberately, as a learning exercise) would just be reimplementing what BlockingQueue already provides correctly — for a real connection pool, reaching for the existing concurrent utility is the right call, not an instructive detour.
interface AcquireStrategy {
Connection acquire(BlockingQueue<Connection> pool) throws InterruptedException, PoolExhaustedException;
}
class BlockIndefinitelyStrategy implements AcquireStrategy {
public Connection acquire(BlockingQueue<Connection> pool) throws InterruptedException {
return pool.take(); // waits as long as it takes
}
}
class TimeoutStrategy implements AcquireStrategy {
private final long timeoutMs;
public Connection acquire(BlockingQueue<Connection> pool) throws InterruptedException, PoolExhaustedException {
Connection conn = pool.poll(timeoutMs, TimeUnit.MILLISECONDS);
if (conn == null) throw new PoolExhaustedException("No connection available within " + timeoutMs + "ms");
return conn;
}
}
class FailFastStrategy implements AcquireStrategy {
public Connection acquire(BlockingQueue<Connection> pool) throws PoolExhaustedException {
Connection conn = pool.poll(); // returns immediately, null if empty
if (conn == null) throw new PoolExhaustedException("Pool exhausted");
return conn;
}
}
This is the same Strategy-pattern separation as everywhere else in this course — which policy is correct depends entirely on the caller's context: a background batch job might reasonably block indefinitely, while a user-facing request handler almost certainly wants a bounded timeout (an unbounded wait here means a slow downstream dependency turns into an indefinitely-hanging user request) — HikariCP's own real connectionTimeout setting (see Connection Pooling) is exactly the TimeoutStrategy shape, not a coincidence.
Q: Why track allConnections separately from availableConnections? A: availableConnections only holds connections currently free for reuse — allConnections tracks every connection the pool has ever created, which is what enables release() to validate that a caller isn't returning a connection this pool didn't issue (a real bug class — releasing a foreign or already-released connection), and is also the natural structure for leak detection: periodically checking which allConnections entries have been checked out longer than a threshold without being released.
Q: How would you detect a connection leak (never released)? A: Track a checkout timestamp per connection (a wrapper object pairing Connection with acquiredAt), and run a periodic background check (a ScheduledExecutorService task, see ExecutorService & Thread Pools) flagging or force-reclaiming connections held longer than a configured threshold — this is precisely HikariCP's own leakDetectionThreshold feature, reimplemented at a conceptual level.
Q: What happens if a connection in the pool goes stale (the underlying network connection dies) while sitting idle? A: acquire() should validate a connection's liveness before handing it out (a lightweight ping/health-check), replacing it with a freshly created one if the check fails — handing out a dead connection and only discovering that on actual use would surface as a confusing failure far from its real cause.
Q: Is a fixed-size pool always correct, or would a pool that grows/shrinks be better? A: A fixed size is simpler and avoids the connection-storm risk of aggressively creating many new connections under a sudden load spike — but a pool with a configurable min/max (like HikariCP's minimumIdle/maximumPoolSize) that grows toward maxSize under sustained demand and shrinks back toward minimumIdle when idle balances resource efficiency against burst capacity better than a purely fixed size.