The Concurrent Toolbelt
Raw new Thread(...) is concurrency's goto: legal, occasionally necessary, and almost never the right first move. Modern platforms provide a toolbelt of higher abstractions that manage the dangerous parts for you. This page tours the essentials, in the order you should reach for them.
Thread Pools: Stop Creating Threads by Hand
// Don't: a thread per task — unbounded, expensive, unmanageable
new Thread(() -> handle(request)).start();
// Do: a pool sized to the resource, tasks as values
ExecutorService pool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors());
Future<Receipt> future = pool.submit(() -> checkout(order)); // returns immediately
Receipt receipt = future.get(); // rendezvous when needed
pool.shutdown();
An ExecutorService separates what to run (tasks) from how it runs (worker threads, queueing, lifecycle). Threads are recycled instead of created per task, the pool size caps resource use, and shutting down is one call instead of a manhunt. Rule of thumb: CPU-bound pools sized near the core count; IO-bound work given many more (or, in modern Java, virtual threads via Executors.newVirtualThreadPerTaskExecutor(), which makes thread-per-task cheap again for waiting workloads).
Atomic Variables: Fixing the Counter Properly
class Counter {
private final AtomicLong count = new AtomicLong();
void increment() { count.incrementAndGet(); } // one indivisible hardware operation
long value() { return count.get(); }
}
The racing counter loses updates because read-add-write can interleave. AtomicLong performs the whole update as a single hardware compare-and-swap instruction — no lock, no lost updates, and under contention it simply retries. The java.util.concurrent.atomic family (and ConcurrentHashMap, LongAdder beside it) covers most "shared counter/flag/cache" needs without a synchronized in sight.
Locks, When You Must
private final Object lock = new Object();
private final Map<String, Balance> balances = new HashMap<>();
void transfer(String from, String to, long pence) {
synchronized (lock) { // one guardian for one invariant
balances.get(from).debit(pence);
balances.get(to).credit(pence); // both-or-neither, never observed half-done
}
}
When an invariant spans multiple variables — debit and credit must move together — atomics can't help; you need mutual exclusion. Keep three habits: one named lock per invariant (not "synchronise everything", which serialises your program back to one thread); tiny critical sections (never do I/O while holding a lock); and a global acquisition order when multiple locks are unavoidable, which forecloses the deadlock by construction.
The Quiet Superpower: Immutability
record PriceList(Map<String, Long> pricesPence) {
PriceList { pricesPence = Map.copyOf(pricesPence); } // defensive, immutable copy
}
// Publish a NEW list; readers holding the old one are unaffected, no locks anywhere
volatile PriceList current = load();
void refresh() { current = load(); }
Thread safety is not something you add; it is something you stop needing. An immutable object can be read by any number of threads forever with zero synchronisation, because no observation can ever be half-complete. Design the majority of your objects immutable (records again), confine mutation to a few well-named places guarded by the tools above, and most of your codebase becomes trivially, provably thread-safe.
Choosing From the Belt
| Situation | Reach for |
|---|---|
| Run many independent tasks | ExecutorService (fixed pool CPU-bound; virtual threads IO-bound) |
| Shared counter, flag, or single reference | AtomicLong / AtomicReference / LongAdder |
| Shared map or queue | ConcurrentHashMap, BlockingQueue |
| Invariant spanning several fields | One synchronized block / ReentrantLock, held briefly |
| Data that is read far more than written | Immutable snapshots republished via volatile |
| Cross-thread hand-off of work or results | Queues and messages — not shared structures (see Event-Driven Programming) |