$ emrebener

Object Pool Pattern

author: emre bener read time: 10 min about: object pool pattern, java repository: https://github.com/Emrebener/GoF-Design-Patterns-Creational
published: updated: mentions: software design pattern, creational pattern, thread pool, connection pool, garbage collection, virtual thread

1. What the object pool pattern is

The Object Pool pattern keeps a fixed-or-bounded set of pre-built objects available for reuse, instead of constructing a fresh one every time a caller needs one. Clients acquire an object from the pool, use it, and release it back so the next caller can get the same instance. The pool itself owns the lifecycle: when to create new ones, when to retire old ones, and what to do when a caller asks while everything is in use.

Strictly speaking, Object Pool isn’t in the original Gang of Four book. It shows up in later pattern catalogues (Kircher & Jain’s Pooling, Pattern-Oriented Software Architecture Vol. 3) and is universally bucketed as creational because, like the others in the series, it’s about who controls construction — here the pool does, on behalf of every caller.

The pattern has one motivation: some objects are too expensive to keep building from scratch. Pooling solves that problem. The next section is about when that trade is worth it, and when modern Java has already made the pattern unnecessary.

2. Why pool instead of allocate?

Pooling earns its place in three specific situations. They overlap, and most real-world pools are motivated by all three at once:

  • Construction is genuinely expensive. When construction takes orders of magnitude longer than the work the object actually does, reusing instances is the difference between a system that scales and one that doesn’t.
  • The resource is finite. A database server enforces a max_connections cap. A GPU has fixed VRAM. The OS has a thread ceiling. A pool both reuses and enforces a hard limit: when every object is checked out, the next caller blocks (or fails fast) instead of overwhelming the underlying resource. The pool’s maxSize is often the most important configuration value in the system.
  • Churn is high. Even when individual construction is cheap, a sustained rate of acquire/release can hammer the allocator, the GC, or whatever’s underneath. Network frameworks (Netty’s ByteBuf pool), high-throughput trading systems and game engines pool aggressively for this reason. The cost they’re amortizing isn’t construction, it’s garbage.

3. Writing one in Java

A pool has to do four things:

  • hand out an object on acquire(),
  • take it back on release(),
  • enforce a size cap,
  • and stay correct under concurrent use (thread-safety is a must).

Java’s java.util.concurrent package gives you almost the whole pattern for free. A BlockingQueue is a thread-safe bounded buffer, which is exactly what the pool’s idle-set wants to be.

3.1. A minimal hand-rolled pool

The simplest correct pool is one that pre-fills its idle set on construction and uses a BlockingQueue to hand instances out:

public final class ConnectionPool {
    private final BlockingQueue<Connection> idle;

    public ConnectionPool(ConnectionFactory factory, int size) {
        this.idle = new ArrayBlockingQueue<>(size);
        for (int i = 0; i < size; i++) {
            idle.offer(factory.create());
        }
    }

    public Connection acquire() throws InterruptedException {
        return idle.take(); // blocks until one is available
    }

    public void release(Connection c) {
        idle.offer(c);
    }
}

That’s the entire pattern! Twenty lines, no explicit locks, no synchronized blocks. The BlockingQueue handles the hard parts:

  • take() blocks the calling thread when the pool is empty, and wakes it up when another thread calls release(). Callers that want to fail fast instead can use poll(timeout, unit).
  • offer() is a non-blocking put — safe because the queue’s bound matches the pool size, so we know there’s always room for a returning instance.
  • The queue’s internal locking handles concurrent acquire/release from many threads.

Callers use it like this:

Connection c = pool.acquire();
try {
    c.execute(...);
} finally {
    pool.release(c); // <- release in a finally; missing this leaks the slot
}

The try/finally is non-negotiable. A pool that loses an instance because a caller forgot to release it is a pool that slowly starves itself; once enough slots leak, every acquire() blocks forever. Real-world pools wrap this in a higher-level abstraction (a try-with-resources object that calls release() on close(), or a “lease” handle the caller can’t forget to return), but the core idle-queue mechanics are unchanged.

A few simplifications worth naming. This pool is eagerly filled: every instance is created in the constructor, before the first caller asks. Production pools typically grow lazily up to a configured maximum, often with a separate idle-timeout to retire instances that haven’t been used in a while. The shape stays the same; the bookkeeping around created count and lazy creation is mechanical, and not what the pattern is about. The more interesting hazard is what state the borrowed object is in. §4 picks that up.

3.2. ExecutorService as a built-in pool

Most Java developers use Object Pool every day without thinking about it. Executors.newFixedThreadPool(4) returns an ExecutorService backed by ThreadPoolExecutor, and ThreadPoolExecutor is exactly an object pool. The pooled resource happens to be a Thread, “acquire” happens implicitly when you submit a task, and “release” happens when the task’s run() method returns and the thread loops back to take the next one off an internal queue.

ExecutorService pool = Executors.newFixedThreadPool(4);

for (int i = 0; i < 10; i++) {
    final int taskId = i;
    pool.submit(() -> {
        System.out.println("task " + taskId + " on " + Thread.currentThread().getName());
    });
}

pool.shutdown();

Run this and you’ll see four thread names — pool-1-thread-1 through pool-1-thread-4 — reused across all ten tasks. Threads aren’t created per task; they’re acquired from the pool, run the task, and go back to wait for another. The pool’s corePoolSize is the equivalent of the idle queue’s bound from §3.1. Every property of the hand-rolled version maps onto something ThreadPoolExecutor exposes:

Hand-rolled (§3.1)ThreadPoolExecutor
acquire() / release()implicit, around Runnable.run()
BlockingQueue<Connection> of idle objectsBlockingQueue<Runnable> of pending tasks
sizecorePoolSize / maximumPoolSize
Block on empty poolconfigurable RejectedExecutionHandler when the queue is full
Eager fillprestartAllCoreThreads()

The naming is different (the queue holds tasks waiting for threads, instead of threads waiting for tasks), but it’s the same pattern flipped on its side. Thread acquisition is what you submit for; the pool’s job is to hand a thread to that task as soon as one is free.

This is also the boring, correct answer when someone asks “should I write my own thread pool?” Almost always: no. ThreadPoolExecutor has been hammered on for two decades, handles edge cases the hand-rolled version doesn’t (graceful shutdown, task rejection policy, scheduled execution via ScheduledThreadPoolExecutor), and integrates with the rest of java.util.concurrent. Same logic applies to JDBC pools (HikariCP), HTTP clients, and Netty’s buffer pools: when a vetted library exists, the right move is almost never to roll your own.

One footnote on Java 21+: virtual threads (Thread.ofVirtual().start(...), Executors.newVirtualThreadPerTaskExecutor()) deliberately don’t pool. Each task gets its own virtual thread, and the JVM schedules thousands of them onto a small set of carrier threads. For I/O-bound work that previously needed newFixedThreadPool, virtual threads make pooling unnecessary — the resource the pool was conserving (OS threads) isn’t the bottleneck anymore. CPU-bound work and bounded-concurrency use cases still want a fixed thread pool, but the “default” answer for I/O-heavy server code has moved.

4. The state-reset hazard

The most common bug in any pool isn’t concurrency or sizing. It’s that a borrowed object retains whatever state the previous borrower left in it. The §3.1 pool hands out the same Connection over and over; if caller A leaves a transaction open and releases the connection, caller B picks it up and inherits that transaction. Same object, same fields, brand new caller, and zero compile-time signal that anything is wrong.

The hazard shows up in every pooled resource type:

  • JDBC connections. Uncommitted transactions, changed auto-commit setting, session variables, temp tables, fetched-but-undrained result sets. HikariCP and DBCP devote significant code to resetting these, and the configurable connectionInitSql and per-property reset hooks exist precisely because real-world drivers leak state in non-obvious ways.
  • HTTP clients. Cookies, default headers, auth state, in-flight redirects, persistent connections.
  • Byte buffers. A ByteBuffer has position, limit, and mark cursors plus the underlying bytes themselves. The next borrower sees whatever the previous one wrote.
  • Threads in a ThreadPoolExecutor. ThreadLocal values set by a previous task are still set when the next task runs on the same thread. This is the reason ThreadLocal.remove() is load-bearing in any code path that runs inside a pooled executor. Frameworks like Spring’s request-scoped beans and Logback’s MDC get this wrong all the time.

There are three places to handle reset, each with different trade-offs:

  • Reset on release. The pool calls obj.reset() (or equivalent) before putting the instance back in the idle queue. The borrower returns a clean object; the next acquirer never sees stale state. The downside is that resetting is wasted work if the object is about to be retired or never reused. In a healthy pool, reuse is the common case, so this is the usual choice anyway.
  • Reset on acquire. The pool resets right before handing the instance out. Symmetric on paper, with one practical advantage: if a borrower releases a broken object (closed socket, bad transaction state), the next acquirer’s reset is also where you’d discover and replace it. HikariCP combines both — fast reset on release, validation on acquire.
  • Reset by wrapper. Don’t expose the raw pooled object at all. Hand the caller a thin wrapper that delegates to the real instance and runs reset/release in its close() method:
public final class PooledConnection implements AutoCloseable {
    private final Connection delegate;
    private final ConnectionPool pool;

    PooledConnection(Connection delegate, ConnectionPool pool) {
        this.delegate = delegate;
        this.pool = pool;
    }

    public void execute(String sql) {
        delegate.execute(sql);
    }

    @Override
    public void close() {
        delegate.reset();   // reset state
        pool.release(delegate); // return to pool
    }
}

This makes the pool work cleanly with try-with-resources and removes the “did you remember to release?” failure mode from §3.1. It’s the shape every production pool wrapper converges on for exactly that reason.

The thing none of these options does is automatically detect what state needs resetting. The pool can only call a reset method that you wrote or that the underlying library exposes. If a new field gets added to the pooled type and nobody updates reset(), the new field’s state silently leaks between borrowers. State-reset is a discipline, not a guarantee; the pattern just gives you one place to enforce it.

State-reset hazard: A leaks, B inheritsCaller APoolCaller Bacquire()fresh conn (clean)A opens a transactionrelease(c) -- still dirtySTATE: open tx persistsacquire()SAME conn, open tx inheritedState-reset hazard: A leaks, B inheritsCaller APoolCaller Bacquire()fresh conn (clean)A opens a transactionrelease(c) -- still dirtySTATE: open tx persistsacquire()SAME conn, open tx inherited

5. When not to use it

Object Pool is the easiest creational pattern to over-apply, because “this might be slow, let’s pool it” feels like a defensible optimization without measuring. A few smells worth watching for:

  • The pooled type is a plain Java object. A pool synchronizes on every acquire and release. The JVM’s allocator does not — TLABs make young-generation allocation roughly a pointer-bump, and the GC reclaims short-lived objects efficiently. Pooling POJOs to “reduce GC pressure” is almost always a net loss; the pool’s locking is more expensive than the allocation it replaced. Profile before pooling, or you’ll make the system slower in the name of making it faster.
  • A vetted library already pools it. Don’t write your own JDBC pool when HikariCP and DBCP exist. Don’t write your own thread pool when ThreadPoolExecutor exists. Don’t write your own byte-buffer pool when Netty’s PooledByteBufAllocator exists. These libraries handle every edge case the §3.1 sketch quietly ignores — connection validation, idle timeout, leak detection, statistics — and they’ve been hardened in production for years. The bar for rolling your own is “the library genuinely doesn’t fit,” not “I’d rather understand my own code.”
  • You can’t reliably reset the object’s state. If the pooled type pulls in framework proxies, reflection-set fields, hidden caches, or ThreadLocals you don’t fully control, you can’t write a reset() that’s safe to trust. The pool will leak state between borrowers in ways that don’t show up under unit tests but do in production. When state-reset is genuinely hard, “construct fresh every time” is the more correct answer, even if it costs more.
  • Virtual threads have removed the motivation. On Java 21+, the canonical I/O-bound use case for thread pools (handle thousands of concurrent requests without spawning thousands of OS threads) is better served by Executors.newVirtualThreadPerTaskExecutor(), which doesn’t pool at all. If you’re reaching for newFixedThreadPool to cap I/O concurrency on modern Java, a Semaphore plus a virtual-thread-per-task executor expresses the same intent without the pooling baggage. Pooling threads still makes sense for CPU-bound work and bounded-parallelism guarantees; it doesn’t make sense as the default I/O pattern anymore.

The rule of thumb: Object Pool earns its place when construction is measurably expensive (network round-trips, OS resources, large memory allocations), no library already covers the use case, and the pooled type’s state is small enough to reset reliably. Hit all three and the pattern is buying something real. Miss any one (especially the first) and a fresh allocation, a Semaphore, or just letting the GC do its job is the better tool.