Why this matters
- A lock you cannot time out of turns a slow dependency into a total outage, and
tryLockwith 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.
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.
| Aspect | synchronized | ReentrantLock |
|---|---|---|
| Release | Automatic on block exit | Manual — you must call unlock in finally |
| Timeout | Not possible | tryLock(time, unit) |
| Interruptible while waiting | No | lockInterruptibly() |
| Fairness | No ordering guarantee | Optional FIFO ordering |
| Wait conditions | One, via wait/notify | Several, via newCondition() |
| Can span methods | No — block-scoped | Yes — lock here, unlock there |
| Readability | Better; harder to misuse | More capable; easier to leak |
Release
synchronizedAutomatic on block exitReentrantLockManual — you must call unlock in finallyTimeout
synchronizedNot possibleReentrantLocktryLock(time, unit)Interruptible while waiting
synchronizedNoReentrantLocklockInterruptibly()Fairness
synchronizedNo ordering guaranteeReentrantLockOptional FIFO orderingWait conditions
synchronizedOne, via wait/notifyReentrantLockSeveral, via newCondition()Can span methods
synchronizedNo — block-scopedReentrantLockYes — lock here, unlock thereReadability
synchronizedBetter; harder to misuseReentrantLockMore capable; easier to leak
Default to synchronized. Reach for ReentrantLock when you specifically need one of these features.
tryLock, the feature worth knowing
// 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.
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.
StampedLockadds optimistic reading, which is faster still but considerably easier to misuse.
Executors
An executor separates what work to do from which thread does it.
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());
}
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
keepAliveTimeof idleness.
The factory methods, and their traps
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
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— throwsRejectedExecutionException. 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.
| Aspect | Workload | Pool size |
|---|---|---|
| CPU-bound | Number of cores, or cores + 1 | More threads only add context switching |
| I/O-bound | cores x (1 + wait time / compute time) | Threads waiting on I/O use no CPU |
| Mixed | Separate pools per workload type | Stops slow I/O from starving computation |
| Blocking, Java 21+ | Virtual threads | Sizing largely stops being a question |
CPU-bound
WorkloadNumber of cores, or cores + 1Pool sizeMore threads only add context switchingI/O-bound
Workloadcores x (1 + wait time / compute time)Pool sizeThreads waiting on I/O use no CPUMixed
WorkloadSeparate pools per workload typePool sizeStops slow I/O from starving computationBlocking, Java 21+
WorkloadVirtual threadsPool sizeSizing largely stops being a question
A task waiting 90 ms per 10 ms of computation justifies roughly ten threads per core.
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
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.awaitTerminationblocks until the pool is done or the timeout expires. Neither shutdown method waits by itself.- Tasks must honour interruption for
shutdownNowto work. A task that swallowsInterruptedExceptionwill hang the shutdown. - Java 19 made
ExecutorServiceAutoCloseable, so try-with-resources performs an orderly shutdown and wait.
Common misreadings
- "
ReentrantLockis faster thansynchronized." Modern JVMs optimise uncontendedsynchronizedheavily. ChooseReentrantLockfor its features, not its speed. - "
unlock()in the try block is fine." An exception before it holds the lock forever. It belongs infinally. - "
newFixedThreadPoolis 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. UseawaitTermination. - "A read lock can be upgraded to a write lock." It cannot — the attempt deadlocks.
- "
CallerRunsPolicyis 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.
ReentrantLockaddstryLockwith timeout,lockInterruptibly, fairness and multiple conditions. Alwaysunlock()in afinallyblock.- Default to
synchronized; useReentrantLockwhen you need one of those features. ReadWriteLockallows 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.
newFixedThreadPoolhas an unbounded queue andnewCachedThreadPoolhas unbounded threads. Configure aThreadPoolExecutorwith 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.