Writing correct concurrent programs ultimately comes down to two essential tasks:
- Preserving shared data consistency: ensuring data stays accurate no matter how many threads read or modify it at once.
- Coordinating thread execution: managing thread interaction through waiting, signaling, or throttling execution flow.
Java offers a large array of concurrency utilities, but at their core they all serve these two objectives. They simply differ in technical approach: some rely on mutual exclusion (mutex locks), others on optimistic retries (spin locks with CAS), some on concurrency throttling (semaphores), and others on coordination signaling (wait/notify mechanisms).
This guide follows a problem-to-solution approach—skipping API enumeration to focus on why each tool was created, what specific problem it solves, and when to reach for it in Java development.
Part 1: The Root Cause — How Data Gets Corrupted
All concurrency bugs stem from multiple threads accessing shared state simultaneously. But concurrency itself isn’t the problem—unsynchronized mutation is. So how does shared state actually break?
Consider a shared bank account balance where two threads attempt to withdraw $100 concurrently. The withdrawal involves three steps:
- Read the current balance from main memory into local working memory (CPU cache/registers).
- Compute
balance - 100in local working memory. - Write the updated balance back to main memory.
If both threads execute strictly in sequence, the result is correct. But preemptive thread scheduling lets these steps interleave:
- Thread A reads a balance of
1000. - Thread B reads the same balance of
1000before Thread A writes back its update. - Thread A computes
900and writes it to main memory. - Thread B computes
900and writes it to main memory.
Result: two 100. State was lost, and consistency was compromised.
This scenario exposes three underlying hardware and runtime issues:
1. Loss of Atomicity
A “read-modify-write” operation must execute as an indivisible unit. When OS time-slicing interrupts execution mid-sequence, atomicity is violated.
2. Visibility Failure
When Thread A writes 900 back to main memory, Thread B may keep reading its own stale cached value of 1000. Modifications made by one thread stay invisible to others.
3. Instruction Reordering
To maximize instruction throughput, compilers and CPUs aggressively reorder instructions. This is safe in single-threaded code, but reordering can cause race conditions across threads. For instance, if a compiler reorders the write to a data buffer and a readyFlag, another thread may observe readyFlag == true before data has actually been initialized.
Key takeaway: Concurrency defects originate from CPU caches, OS thread scheduling, and compiler/CPU instruction reordering. Together they break atomicity, visibility, and ordering. Java’s concurrency constructs exist to restore these three properties.
Part 2: Restoring the Core Memory Properties
Rather than manipulating hardware directly, Java defines the Java Memory Model (JMM)—a specification enforced through compiler directives and language primitives.
2.1 volatile: Lightweight Visibility and Ordering
Applying the volatile modifier enforces two behaviors:
- Visibility: reads bypass CPU caches and fetch directly from main memory; writes flush immediately to main memory.
- Ordering: memory barriers prevent instruction reordering across the field access.
Limitation: volatile does not guarantee atomicity. Individual reads or writes to a volatile field are atomic, but compound operations—such as count++—are not.
// Ideal volatile use case: a single-writer state flagpublic class TaskRunner { private volatile boolean running = true;
public void stop() { running = false; } // writer thread public void run() { while (running) { /* process */ } // reader thread(s) notice the change immediately }}2.2 synchronized: Intrinsic Mutual Exclusion
When atomicity is required, locks are necessary. Java’s intrinsic synchronized keyword guarantees all three memory properties:
- Atomicity: only one thread executes a synchronized block at a time.
- Visibility: flushes local memory to main memory on lock release, and refreshes local memory on lock acquisition.
- Ordering: prevents instructions inside the synchronized block from being reordered across its boundaries.
Low-Level Implementation & Lock Inflation
Every Java object carries an intrinsic monitor. When a thread enters a synchronized block, it attempts to acquire the monitor. If it succeeds, execution proceeds; if the monitor is held elsewhere, the thread enters the monitor’s _EntryList and parks.
In early JVM versions, parking a thread required an OS-level context switch between user mode and kernel mode, which made synchronized computationally expensive.
JDK 6 lock optimizations (lock inflation):
To avoid kernel-level context-switch overhead in uncontended scenarios, the JVM progresses through a sequence of lock states based on the object’s Mark Word header:
- Biased locking: assumes a single thread repeatedly accesses the monitor. The thread ID is written to the object header via CAS once; subsequent re-entry costs almost nothing.
- Lightweight locking: if a second thread contends for the lock, it inflates to a lightweight lock that uses CAS spinning (busy-waiting) instead of parking the thread.
- Heavyweight locking: under sustained contention or prolonged spinning, the lock inflates to a full OS-level monitor lock that parks competing threads.
Note: biased locking was disabled by default in JDK 15 and removed in later releases because its benefits rarely outweighed its overhead in modern multi-threaded applications, but the lightweight → heavyweight escalation still applies.
Limitations of synchronized
- Uninterruptible: a thread waiting for a lock cannot be interrupted or given a timeout.
- Single condition set:
wait()/notify()operate on a single implicit condition queue per monitor. - Non-fair: starvation is possible, since waiting threads aren’t guaranteed FIFO lock acquisition.
2.3 ReentrantLock: Advanced Synchronization Control
ReentrantLock complements synchronized by offering explicit, more flexible synchronization control:
- Interruptible acquisition:
lockInterruptibly()lets a thread abort if it’s interrupted while waiting. - Timed acquisition:
tryLock(timeout, unit)bounds the wait and helps avoid deadlocks. - Multiple condition objects:
newCondition()supports multiple wait-sets per lock instance (e.g., separatenotFullandnotEmptyconditions).
AbstractQueuedSynchronizer (AQS) Architecture
ReentrantLock is built on AbstractQueuedSynchronizer (AQS), which uses a centralized state + queue model:
- A
volatile int statefield tracks lock state (0for unlocked,>= 1for locked/reentrant count). - A doubly linked FIFO queue manages waiting threads.
- Thread parking and unparking are handled through
LockSupport.park()andLockSupport.unpark().
Part 3: Thread Coordination Beyond Mutual Exclusion
Mutual exclusion prevents concurrent state mutation, but real-world systems also need to throttle resources or align threads at specific points.
3.1 Semaphore: Concurrency Throttling
- Use case: resource pooling (e.g., capping database connections at 10) or rate limiting.
- Mechanism: maintains an internal permit counter backed by AQS shared mode.
acquire()decrements permits (blocking if none are available);release()increments permits and wakes a parked thread.
3.2 CountDownLatch: One-Time Event Barrier
- Use case: blocking a main thread until background tasks complete.
- Mechanism: initialized with a count. Each call to
countDown()decrements it; threads callingawait()block until the count reaches zero. - Constraint: not reusable—the counter cannot be reset once it hits zero.
3.3 CyclicBarrier: Reusable Phase Synchronization
- Use case: synchronizing parallel execution paths (e.g., multi-step parallel processing) where all threads must reach a checkpoint before any can proceed.
- Mechanism: initialized with a participant count (
parties). Callingawait()blocks until all threads reach the barrier, then releases them simultaneously. It accepts an optionalRunnablethat runs once, executed by the last thread to arrive—and the barrier automatically resets for the next cycle.
3.4 Exchanger: Point-to-Point Data Handoff
- Use case: two-thread pipeline handoffs where a pair of threads swaps buffers.
- Mechanism: both threads call
exchange(data). The runtime blocks the first arrival until the second arrives, then atomically swaps payloads between them.
3.5 Phaser: Dynamic Multi-Phase Coordination
- Use case: complex iterative computations where the number of participating tasks changes across distinct lifecycle stages.
- Mechanism: extends the capabilities of
CyclicBarrierandCountDownLatchby allowing parties to dynamically register and deregister across advancing phases.
Part 4: Lock-Free Concurrency via Hardware Atomic Instructions
When suspending threads adds unnecessary overhead, lock-free patterns offer an alternative by avoiding thread state transitions altogether.
4.1 Compare-And-Swap (CAS) Mechanics
CAS executes as an atomic CPU instruction (e.g., CMPXCHG on x86).
CAS takes three inputs:
- Memory location ()
- Expected old value ()
- New value ()
is updated to if and only if . Otherwise, the operation fails without modifying , and the caller typically retries.
4.2 Atomic Primitives (java.util.concurrent.atomic)
Classes like AtomicInteger, AtomicLong, and AtomicReference use CAS retry loops for lock-free, single-variable mutation.
// Lock-free increment pattern inside AtomicIntegerpublic final int incrementAndGet() { for (;;) { int current = get(); int next = current + 1; if (compareAndSet(current, next)) return next; }}CAS Trade-offs
- The ABA problem: if a value changes from to and back to , CAS sees it as unchanged.
AtomicStampedReferenceresolves this by pairing the reference with a version stamp. - High-contention CPU overhead: under heavy contention, repeated CAS failures waste CPU cycles spinning and retrying.
Part 5: Concurrent Collections
java.util.concurrent provides thread-safe data structures designed to eliminate manual synchronization.
5.1 ConcurrentHashMap
- JDK 7 implementation: used an array of
Segments (segmented locking), spreading contention across isolated lock regions. - JDK 8+ implementation: drops segments in favor of CAS for lock-free bucket initialization, combined with per-bucket
synchronizedlocking on the head node of each bin. Lock granularity is scoped to individual buckets, significantly improving concurrent throughput.
5.2 CopyOnWriteArrayList
- Mechanism: reads execute lock-free against an immutable array reference. Writes copy the underlying array, apply the mutation, and atomically swap in the new array reference.
- Use case: optimized for read-heavy, write-rare workloads (e.g., event listener registries).
5.3 Blocking Queues (BlockingQueue)
- Implementations such as
ArrayBlockingQueueandLinkedBlockingQueuehandle producer-consumer coordination out of the box, offering boundedput()(blocks when full) andtake()(blocks when empty) without manual wait/notify boilerplate.
Summary: Selecting the Right Concurrency Tool
Evaluate your concurrency requirements across three layers:
┌─────────────────────────────────────────┐ │ 1. Shared State Consistency Need │ └────────────────────┬────────────────────┘ │ ┌──────────────────────────┼──────────────────────────┐ ▼ ▼ ▼ Single State Flag Group State Mutation Atomic Variable Updates (Visibility-Only) (Mutual Exclusion) (Lock-Free CAS) │ │ │ [ volatile ] ┌─────────┴─────────┐ [ AtomicInteger / Long ] ▼ ▼ [ synchronized ] [ ReentrantLock ] (Default Choice) (Timed/Interruptible) ┌─────────────────────────────────────────┐ │ 2. Execution Coordination Need │ └────────────────────┬────────────────────┘ │ ┌──────────────────────────┼──────────────────────────┐ ▼ ▼ ▼ Limit Concurrency Wait for N Events Wait for N Threads │ │ │ [ Semaphore ] [ CountDownLatch ] [ CyclicBarrier ] ┌─────────────────────────────────────────┐ │ 3. Concurrent Data Structures │ └────────────────────┬────────────────────┘ │ ┌──────────────────────────┼──────────────────────────┐ ▼ ▼ ▼ Key-Value Storage Read-Heavy Lists Producer-Consumer │ │ │ [ ConcurrentHashMap ] [ CopyOnWriteArrayList ] [ BlockingQueue ]Reference Table
| Objective | Utility | Core Underlying Mechanism |
|---|---|---|
| Simple State Flag | volatile | Memory barriers (flushes caches, prevents reordering) |
| Mutual Exclusion | synchronized | JVM monitor + lock inflation (biased lightweight heavyweight) |
| Advanced Mutual Exclusion | ReentrantLock | AQS framework (volatile state + FIFO queue + LockSupport) |
| Concurrency Throttling | Semaphore | AQS shared-mode permit counter |
| One-Time Event Waiting | CountDownLatch | Single-use decrementing AQS state counter |
| Cyclic Thread Alignment | CyclicBarrier | Reusable, resettable thread barrier |
| Point-to-Point Data Exchange | Exchanger | Synchronized two-thread slot swap |
| Multi-Stage Coordination | Phaser | Dynamic party-registration barrier |
| Lock-Free Atomic State | AtomicInteger | Hardware-level CAS instructions |
| Thread-Safe Map | ConcurrentHashMap | CAS bucket init + per-bucket node lock |
| Read-Heavy List | CopyOnWriteArrayList | Array-copy mutations + lock-free reads |
| Producer-Consumer Queue | BlockingQueue | Condition-backed bounded queues |