Most explanations of Java concurrency start with threads and locks, which is starting in the middle. The foundation is the Java Memory Model, and it says something stronger than 'operations can interleave': in the absence of proper synchronisation, one thread is under no obligation to ever see another thread's writes, and the compiler and CPU are free to reorder instructions in ways that make a correct-looking program produce impossible results. Locks and atomics are not merely mutual-exclusion devices; they are how you establish the ordering relationships that make writes visible at all. Understand that first and the rest of the toolkit -- the concurrent collections, the executors, the futures, and now virtual threads -- reads as a series of increasingly convenient packagings of the same guarantees.

The memory model — visibility, not just interleaving

The classic broken example is a boolean flag polled by one thread and set by another. Without synchronisation the reading thread may loop forever, because the JIT compiler is entitled to hoist the field read out of the loop -- nothing in the program told it the value could change underneath it. This is not a theoretical hazard; it is routine once code is hot enough to be optimised.

The model is defined by the happens-before relation. If action A happens-before action B, then everything A did is visible to B. The relation is established by specific constructs: releasing a monitor happens-before any subsequent acquisition of the same monitor; a write to a volatile field happens-before every subsequent read of it; Thread.start() happens-before everything the new thread does; everything a thread does happens-before another thread's successful join() on it; and the concurrent library's operations carry their own documented guarantees. Where no such relation exists, there is a data race and the program's behaviour is not defined by any interleaving you can reason about.

volatile is the minimum tool: it guarantees visibility and prevents reordering across the access, and it does not provide atomicity. volatile int count; count++; is still broken, because the increment is a read, an add and a write. Use volatile for flags and for safe publication of immutable objects, not for anything that reads and then writes.

final fields have their own guarantee worth knowing: an object's final fields are visible, fully initialised, to any thread that sees a reference to the object, provided the constructor did not leak this before finishing. That single rule is what makes immutable objects safe to share with no synchronisation at all, and it is the strongest argument for immutability as a concurrency strategy.

Java concurrency layersThreads + syncbasic lockingj.u.c primitiveslocks + atomicsExecutors + Futurestask-basedPrefer higher levels: thread pools over raw threads, atomics over locks
Concurrency abstraction levels.
Advertisement

Intrinsic locks — synchronized

Every Java object has a monitor, and synchronized acquires it. A synchronized method locks the instance -- or the class object, for a static method -- and a synchronized block locks whatever you name. Locks are reentrant, so a thread already holding a monitor can acquire it again without deadlocking itself, which is what makes calling one synchronized method from another work.

Two practices matter more than the syntax. Lock on a private object rather than on this or on a public field: if the lock is reachable from outside, any code anywhere can acquire it and participate in -- or wreck -- your locking protocol. A private final Object lock = new Object(); is the safe idiom. And hold locks for the shortest span that is still correct, which means not performing I/O, not calling unknown code, and not waiting on anything inside a synchronized block. Calling a listener or a callback while holding a lock is the standard route to a deadlock that only appears in production.

wait, notify and notifyAll are the intrinsic condition mechanism, and they carry two rules that are violated constantly. Always call wait inside a loop that re-checks the condition, because spurious wakeups are permitted and because another thread may have consumed the state between the notification and your reacquiring the lock. And prefer notifyAll unless you can prove all waiters are interchangeable -- a notify that wakes the wrong waiter loses the signal and hangs the system. In practice, most code that reaches for wait/notify would be better served by a BlockingQueue or a CountDownLatch.

Explicit locks — ReentrantLock, ReadWriteLock, StampedLock

ReentrantLock offers what synchronized cannot: tryLock() to attempt acquisition without blocking, a timed variant to give up after a deadline, lockInterruptibly() so a waiting thread can be cancelled, and an optional fairness policy. The cost is that unlocking is your responsibility, which means the try/finally discipline is not optional:

lock.lock();
try {
    // critical section
} finally {
    lock.unlock();
}

Fairness deserves a caution: a fair lock hands ownership to the longest waiter, which removes starvation and costs a great deal of throughput, because it forfeits the barging that makes uncontended handoffs fast. Default to unfair and switch only if you have measured starvation.

ReentrantReadWriteLock permits many concurrent readers or one writer. It pays off when reads dominate heavily and critical sections are long enough to amortise the extra bookkeeping; on short critical sections it is frequently slower than a plain lock. StampedLock adds an optimistic read mode that acquires no lock at all -- you read a stamp, read the fields, then validate the stamp and retry if a writer intervened -- which is very fast for read-mostly data. It is also not reentrant and not usable with the condition mechanism, so it is a specialist tool, not a drop-in upgrade.

Atomics and compare-and-swap

Below locks sits the hardware's compare-and-swap instruction, and the atomic classes expose it. AtomicInteger, AtomicLong and AtomicReference provide atomic read-modify-write without blocking: compareAndSet(expected, new) succeeds only if the value is still what you read, and the standard idiom loops until it does.

int prev, next;
do {
    prev = counter.get();
    next = computeFrom(prev);
} while (!counter.compareAndSet(prev, next));

No thread ever blocks, so there is no deadlock and no context switch -- but under heavy contention the retry loop burns CPU as threads repeatedly lose the race. That is the failure mode LongAdder exists to fix: it spreads updates across multiple internal cells and sums them on read, trading exact instantaneous readings for dramatically better throughput on hot counters. For metrics and statistics, LongAdder is almost always the right choice over AtomicLong.

The subtle hazard in CAS code is the ABA problem: a value changes from A to B and back to A, so your compare succeeds even though the world moved. It rarely bites with plain counters and bites hard with lock-free data structures built on references. AtomicStampedReference attaches a version counter for exactly this case. More generally, writing your own lock-free structures is a specialist activity -- the library's are written by people who verified them formally, and yours probably has not been.

Concurrent collections

The synchronised wrappers from the collections framework -- Collections.synchronizedMap and friends -- lock the entire collection for every operation and do not make compound actions atomic. They are a compatibility feature, not a concurrency strategy. The java.util.concurrent collections are designed for the problem.

ConcurrentHashMap is the workhorse. Reads are generally lock-free; writes lock only the affected bin, so throughput scales with cores rather than collapsing onto one monitor. Its most useful feature is the set of atomic compound operations -- computeIfAbsent, compute, merge, putIfAbsent -- which eliminate the check-then-act race that makes if (!map.containsKey(k)) map.put(k, v) wrong. One caution: the mapping function passed to compute runs while the bin is locked, so it must be short and must not touch the same map, or you deadlock.

CopyOnWriteArrayList copies the backing array on every mutation, making reads completely lock-free and iteration snapshot-consistent with no risk of concurrent modification errors. That is ideal for listener registries -- read constantly, written rarely -- and catastrophic for anything written in a loop.

The BlockingQueue family is the standard producer-consumer connector, and choosing among them is a design decision. ArrayBlockingQueue is bounded and therefore applies backpressure; LinkedBlockingQueue is optionally bounded and unbounded by default, which converts overload into an out-of-memory error; SynchronousQueue holds nothing and hands off directly, so a producer waits for a consumer; PriorityBlockingQueue orders by comparator and is unbounded. Prefer a bounded queue in production: an unbounded one hides the fact that consumers cannot keep up until the heap is exhausted.

Executors, and the traps in the convenience factories

Creating threads directly couples task submission to thread lifecycle and offers no control over how many exist. ExecutorService separates the two: you submit tasks, the pool runs them.

The Executors factory methods are convenient and two of them are hazards worth naming explicitly. newFixedThreadPool bounds the threads and uses an unbounded queue, so tasks arriving faster than they complete accumulate until the heap dies -- with no backpressure and no error until it is too late. newCachedThreadPool bounds the queue at zero and lets the thread count grow without limit, so a burst creates thousands of threads. Both fail under exactly the conditions you most need them to behave.

Constructing a ThreadPoolExecutor directly is the correct habit, because it forces the four decisions that matter: core and maximum pool size, a bounded work queue, and a rejection policy for when both are full. CallerRunsPolicy is a particularly useful rejection handler -- the submitting thread executes the task itself, which naturally throttles producers. Name your threads through a thread factory too; an unnamed pool is a thread dump you cannot read.

Sizing follows from what the tasks do. CPU-bound work wants roughly one thread per core -- more only adds context switching. I/O-bound work wants cores multiplied by (1 + wait time / compute time), which for tasks that spend ninety percent of their time waiting means an order of magnitude more threads than cores. Measure the ratio rather than guessing, and keep separate pools for separate workloads so a slow dependency cannot exhaust the pool serving everything else.

Advertisement

Futures and composition

Future.get() blocks, which makes plain futures a poor composition mechanism: a chain of three dependent calls costs three blocked threads and their latencies add. CompletableFuture replaces blocking with callbacks, so a pipeline is expressed as a graph of continuations that occupy a thread only while actually computing.

CompletableFuture<Order> f =
    fetchUser(id)                                   // CF<User>
        .thenCompose(user -> fetchCart(user))       // flatMap
        .thenApply(cart -> price(cart))             // map
        .exceptionally(ex -> Order.empty());        // recover

The distinction to internalise is thenApply versus thenCompose: the first maps a value, the second flattens a nested future, and using the wrong one yields a CompletableFuture<CompletableFuture<T>> that compiles. allOf and anyOf fan in; the *Async variants let you say which executor runs the continuation.

That last point is a real production concern. Without an explicit executor, continuations run either on the thread that completed the previous stage or on the common fork-join pool. Both are easy to overload -- and the common pool is shared with every parallel stream in the JVM, so a blocking continuation there degrades unrelated code. Pass an explicit executor for anything that blocks or takes real time. Exception handling deserves the same discipline: an exception in a stage propagates down the chain, and a future whose failure nobody observes is a silently swallowed error, so terminate chains with exceptionally or whenComplete.

Fork-join and parallel streams

The fork-join framework targets divide-and-conquer: split a task until pieces are small, solve them, combine. Its distinguishing mechanism is work stealing -- each worker has its own deque and idle workers steal from the tails of others', which keeps cores busy without a central queue becoming a bottleneck.

Parallel streams are the everyday face of it: list.parallelStream() and the work is distributed across the common pool. When it helps, it helps for free; when it hurts, it hurts invisibly. It helps when the dataset is large, the per-element work is genuinely CPU-bound, and the source splits evenly -- arrays and ArrayList split well, LinkedList and most streams from I/O do not. It hurts when the work is small (the splitting and merging cost exceeds the saving), when the operation is order-sensitive, or when the lambda blocks.

Blocking inside a parallel stream is the serious mistake, because the common pool is a fixed, JVM-wide resource sized to your core count. A few blocked tasks stall every other parallel stream in the process, including ones inside libraries you did not write. If you must parallelise blocking work, submit it to your own executor rather than borrowing the common pool -- or use virtual threads, which are built for exactly that case.

Virtual threads

Java 21 made virtual threads a permanent feature, and they change the economics of the platform's dominant server architecture. A platform thread maps one-to-one onto an operating-system thread, costing a megabyte-scale stack and a kernel context switch, which is why thread-per-request stopped scaling and why the industry moved to reactive and asynchronous styles. A virtual thread is scheduled by the JVM onto a small pool of carrier threads; when it blocks on I/O it is unmounted from its carrier, which is freed to run something else. Millions can exist, and blocking one costs almost nothing.

The consequence is that straightforward blocking code becomes the scalable style again. A thread-per-request server on virtual threads handles the concurrency that previously required callback chains or reactive operators, with sequential code that is debuggable and produces meaningful stack traces.

The rules that come with them are short and matter. Do not pool virtual threads -- they are cheap enough to create per task, and pooling reintroduces the limit the feature exists to remove; use Executors.newVirtualThreadPerTaskExecutor(). Do not use them for CPU-bound work, where they add nothing over a fixed pool sized to cores. Be careful with ThreadLocal, since state sized for a few hundred pooled threads becomes state times a million; scoped values are the intended replacement. And watch for pinning: a virtual thread that blocks while inside a synchronized block or a native frame historically could not unmount, holding its carrier and undermining the whole model. Recent JDK releases removed that limitation for synchronized blocks specifically; on earlier ones, converting hot synchronized regions to ReentrantLock is the standard remedy.

Alongside them, structured concurrency -- still evolving through preview at the time of writing -- gives a scope in which child tasks are forked and joined together, with cancellation and error propagation handled as a unit. It is the missing piece that makes concurrent code compose the way sequential code does.

The bugs you will actually write

Check-then-act. Any sequence that inspects shared state and then acts on the result is a race unless the whole sequence is atomic. This covers lazy initialisation, 'put if absent' written by hand, and size-then-remove. Use the atomic compound operations the library provides.

Deadlock from inconsistent lock ordering. Two threads acquiring the same two locks in opposite orders will eventually meet. The fix is a global ordering -- sort by an intrinsic identifier before locking -- or timed acquisition with tryLock and backoff.

Double-checked locking without volatile. The famous broken idiom: without a volatile field, another thread can observe a non-null reference to a partially constructed object. Prefer a holder class for lazy singletons, or an enum.

Unsafe publication. Handing an object to another thread through a non-final, non-volatile field publishes the reference without publishing its contents. Publish through a concurrent collection, a volatile field, or an immutable object with final fields.

Swallowing InterruptedException. Catching it and doing nothing destroys the cancellation signal. Either propagate it or restore the flag with Thread.currentThread().interrupt().

Leaked executors. A pool with non-daemon threads that is never shut down keeps the JVM alive. Shut down in a finally or use try-with-resources, which ExecutorService now supports.

Diagnosing and testing concurrent code

Concurrency bugs are probabilistic, so ordinary unit tests are close to worthless against them -- a test that passes a thousand times proves the bug is rare, not absent.

For a hung or slow system, take a thread dump. jstack or jcmd Thread.print shows every thread's stack and lock state and detects cycles of monitor ownership directly, which turns most deadlocks from a mystery into a two-minute diagnosis. Take several seconds apart: threads stuck in the same frame across dumps are blocked, threads moving are merely busy. Flight Recorder adds the quantitative view -- lock contention events, blocking durations, thread parks -- with low enough overhead to leave enabled in production.

For testing, three levels are useful. Deterministic tests of the state machine with concurrency removed. Stress tests that run many threads for a long time with assertions on invariants, which catch coarse errors. And for anything genuinely lock-free or memory-model-sensitive, a harness such as jcstress, which explores the reorderings a normal test never triggers.

The most effective practice is not a tool at all: write down each class's thread-safety contract. Whether an object is immutable, confined to one thread, guarded by a specific lock, or fully thread-safe is invisible in Java's type system, so it belongs in the class documentation. Most concurrency bugs enter a codebase when someone uses a class in a way its author never intended and nothing recorded the intent.

Concurrency in Java is a visibility problem before it is a locking problem: without a happens-before relationship, another thread's writes may never appear. Prefer immutability and thread confinement, then the highest-level construct that fits -- a concurrent collection over a lock, an executor over raw threads, a bounded queue over an unbounded one. Build ThreadPoolExecutor by hand rather than taking the Executors factories, whose unbounded queue and unbounded thread count both fail under load. And on Java 21 or later, virtual threads make plain blocking code scale again -- just do not pool them.