PrepZone Logo
PrepZone

Locks and Thread Pools

ReentrantLock over synchronized, read-write locks, and sizing an executor properly.

Read these first

Why this matters

  • A lock you cannot time out of turns a slow dependency into a total outage, and tryLock with a timeout is the one-line defence.
  • Thread pool sizing is one of the few numbers in a backend service that has a principled formula rather than a guess.
  • Choosing the wrong executor factory method is how an application ends up with an unbounded queue that quietly becomes an OutOfMemoryError.

ReentrantLock

The same mutual exclusion as synchronized, with the lifecycle made explicit.

Java
private final ReentrantLock lock = new ReentrantLock();

public void update() {
    lock.lock();
    try {
        mutateSharedState();
    } finally {
        lock.unlock();          // MUST be in finally — an exception would otherwise hold it forever
    }
}

The finally is not optional. synchronized releases its monitor automatically when the block exits; an explicit lock does not, and a missed unlock deadlocks everything that follows.

AspectsynchronizedReentrantLock
ReleaseAutomatic on block exitManual — you must call unlock in finally
TimeoutNot possibletryLock(time, unit)
Interruptible while waitingNolockInterruptibly()
FairnessNo ordering guaranteeOptional FIFO ordering
Wait conditionsOne, via wait/notifySeveral, via newCondition()
Can span methodsNo — block-scopedYes — lock here, unlock there
ReadabilityBetter; harder to misuseMore capable; easier to leak
  • Release

    synchronizedAutomatic on block exit
    ReentrantLockManual — you must call unlock in finally
  • Timeout

    synchronizedNot possible
    ReentrantLocktryLock(time, unit)
  • Interruptible while waiting

    synchronizedNo
    ReentrantLocklockInterruptibly()
  • Fairness

    synchronizedNo ordering guarantee
    ReentrantLockOptional FIFO ordering
  • Wait conditions

    synchronizedOne, via wait/notify
    ReentrantLockSeveral, via newCondition()
  • Can span methods

    synchronizedNo — block-scoped
    ReentrantLockYes — lock here, unlock there
  • Readability

    synchronizedBetter; harder to misuse
    ReentrantLockMore capable; easier to leak

Default to synchronized. Reach for ReentrantLock when you specifically need one of these features.

tryLock, the feature worth knowing

Java
// Give up rather than wait forever
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
    try {
        process();
    } finally {
        lock.unlock();
    }
} else {
    metrics.increment("lock.timeout");
    throw new ServiceBusyException("Could not acquire lock in 500 ms");
}

This converts an indefinite hang into a fast, visible failure. In a request-serving application that is almost always the better outcome — a timeout is a problem you can see in a dashboard, whereas a thread blocked forever looks like nothing at all until the pool is exhausted.

ReadWriteLock

When reads vastly outnumber writes, allowing concurrent readers is a large win.

Java
private final ReadWriteLock rw = new ReentrantReadWriteLock();
private final Map<String, String> data = new HashMap<>();

public String read(String key) {
    rw.readLock().lock();              // many readers may hold this at once
    try {
        return data.get(key);
    } finally {
        rw.readLock().unlock();
    }
}

public void write(String key, String value) {
    rw.writeLock().lock();             // exclusive: blocks all readers and writers
    try {
        data.put(key, value);
    } finally {
        rw.writeLock().unlock();
    }
}
  • Many readers, or one writer — never both.
  • A waiting writer blocks new readers, so readers cannot starve the writer indefinitely.
  • You cannot upgrade a read lock to a write lock; attempting it deadlocks. Release and reacquire instead.
  • StampedLock adds optimistic reading, which is faster still but considerably easier to misuse.

Executors

An executor separates what work to do from which thread does it.

Java
try (ExecutorService pool = Executors.newFixedThreadPool(8)) {      // Java 19+: closes and waits
    Future<Integer> result = pool.submit(() -> compute());
    pool.execute(() -> fireAndForget());
    System.out.println(result.get());
}
submit(task)Caller returns immediately
Task queueBlockingQueue
Worker threadsFixed count, reused
Future resultget() when needed
Tasks queue up and a fixed set of workers drains them. Reusing threads is what makes pools cheap, and it also caps how much work runs at once.

How a pool processes work

  • A submitted task goes to the queue if every core thread is busy.
  • An idle core thread takes the next task from the queue.
  • If the queue is full and the pool is below its maximum, a new thread is created.
  • If the queue is full and the pool is at its maximum, the rejection policy decides what happens.
  • Threads above the core count are retired after keepAliveTime of idleness.

The factory methods, and their traps

Java
Executors.newFixedThreadPool(8);        // UNBOUNDED queue — tasks pile up invisibly
Executors.newCachedThreadPool();        // UNBOUNDED threads — one per task under load
Executors.newSingleThreadExecutor();    // serialises work; unbounded queue
Executors.newScheduledThreadPool(2);    // delayed and periodic tasks
Executors.newVirtualThreadPerTaskExecutor();   // Java 21 — one virtual thread per task

Configure it explicitly

Java
ThreadPoolExecutor pool = new ThreadPoolExecutor(
        8,                                      // core threads
        16,                                     // maximum threads
        60L, TimeUnit.SECONDS,                  // keep-alive for threads above core
        new ArrayBlockingQueue<>(500),           // BOUNDED queue — this is the point
        new ThreadFactoryBuilder().setNameFormat("order-%d").build(),
        new ThreadPoolExecutor.AbortPolicy());   // reject loudly when full

A bounded queue plus an explicit rejection policy gives you backpressure: when the system is overloaded it says so immediately instead of absorbing the overload into memory.

The four rejection policies

  • AbortPolicy — throws RejectedExecutionException. The default, and usually the right choice.
  • CallerRunsPolicy — the submitting thread runs the task itself, which naturally slows the producer. An elegant form of backpressure.
  • DiscardPolicy — silently drops the task. Dangerous; use only where losing work is genuinely acceptable.
  • DiscardOldestPolicy — drops the oldest queued task to make room.

Sizing a pool

The right size depends on whether your tasks compute or wait.

AspectWorkloadPool size
CPU-boundNumber of cores, or cores + 1More threads only add context switching
I/O-boundcores x (1 + wait time / compute time)Threads waiting on I/O use no CPU
MixedSeparate pools per workload typeStops slow I/O from starving computation
Blocking, Java 21+Virtual threadsSizing largely stops being a question
  • CPU-bound

    WorkloadNumber of cores, or cores + 1
    Pool sizeMore threads only add context switching
  • I/O-bound

    Workloadcores x (1 + wait time / compute time)
    Pool sizeThreads waiting on I/O use no CPU
  • Mixed

    WorkloadSeparate pools per workload type
    Pool sizeStops slow I/O from starving computation
  • Blocking, Java 21+

    WorkloadVirtual threads
    Pool sizeSizing largely stops being a question

A task waiting 90 ms per 10 ms of computation justifies roughly ten threads per core.

Java
int cores = Runtime.getRuntime().availableProcessors();

// CPU-bound: parsing, encoding, computation
ExecutorService cpu = Executors.newFixedThreadPool(cores);

// I/O-bound: a service call taking ~100 ms with ~10 ms of work
// cores x (1 + 90/10) = cores x 10
ExecutorService io = new ThreadPoolExecutor(
        cores * 10, cores * 10, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1_000));

Shutting down

Java
pool.shutdown();                                       // stop accepting; finish queued work
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
    pool.shutdownNow();                                 // interrupt running tasks, drop the queue
    if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
        log.error("Pool did not terminate");
    }
}
  • shutdown() rejects new tasks and lets queued ones finish. The polite path.
  • shutdownNow() interrupts running tasks and returns the ones never started.
  • awaitTermination blocks until the pool is done or the timeout expires. Neither shutdown method waits by itself.
  • Tasks must honour interruption for shutdownNow to work. A task that swallows InterruptedException will hang the shutdown.
  • Java 19 made ExecutorService AutoCloseable, so try-with-resources performs an orderly shutdown and wait.

Common misreadings

  • "ReentrantLock is faster than synchronized." Modern JVMs optimise uncontended synchronized heavily. Choose ReentrantLock for its features, not its speed.
  • "unlock() in the try block is fine." An exception before it holds the lock forever. It belongs in finally.
  • "newFixedThreadPool is bounded." The threads are. The queue is not.
  • "More threads means more throughput." Past the formula, context switching costs more than it gains.
  • "shutdown() waits for tasks." It returns immediately. Use awaitTermination.
  • "A read lock can be upgraded to a write lock." It cannot — the attempt deadlocks.
  • "CallerRunsPolicy is a degraded mode." It is often the best policy: it throttles the producer automatically.

Quick recall

Everything you need if you only revisit this box.

  • ReentrantLock adds tryLock with timeout, lockInterruptibly, fairness and multiple conditions. Always unlock() in a finally block.
  • Default to synchronized; use ReentrantLock when you need one of those features.
  • ReadWriteLock allows many readers or one writer, and cannot upgrade a read lock to a write lock.
  • A pool fills core threads → queue → extra threads → rejection policy.
  • newFixedThreadPool has an unbounded queue and newCachedThreadPool has unbounded threads. Configure a ThreadPoolExecutor with a bounded queue instead.
  • Rejection policies: Abort (default), CallerRuns (natural backpressure), Discard, DiscardOldest.
  • Sizing: cores for CPU-bound, cores × (1 + wait/compute) for I/O-bound, separate pools for mixed.
  • Shut down with shutdown() → awaitTermination → shutdownNow(). An un-shut-down pool keeps the JVM alive.

Test yourself

Answer these before moving on — recall is what makes it stick.