1668 words
8 minutes
Java Concurrency: A Problem-to-Solution Guide

Writing correct concurrent programs ultimately comes down to two essential tasks:

  1. Preserving shared data consistency: ensuring data stays accurate no matter how many threads read or modify it at once.
  2. 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:

  1. Read the current balance from main memory into local working memory (CPU cache/registers).
  2. Compute balance - 100 in local working memory.
  3. 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 1000 before Thread A writes back its update.
  • Thread A computes 900 and writes it to main memory.
  • Thread B computes 900 and writes it to main memory.

Result: two 100withdrawalshappened,butthebalanceonlydroppedby100 withdrawals happened, but the balance only dropped by 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 flag
public 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:

  1. 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.
  2. 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.
  3. 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., separate notFull and notEmpty conditions).

AbstractQueuedSynchronizer (AQS) Architecture#

ReentrantLock is built on AbstractQueuedSynchronizer (AQS), which uses a centralized state + queue model:

  • A volatile int state field tracks lock state (0 for unlocked, >= 1 for locked/reentrant count).
  • A doubly linked FIFO queue manages waiting threads.
  • Thread parking and unparking are handled through LockSupport.park() and LockSupport.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 NN background tasks complete.
  • Mechanism: initialized with a count. Each call to countDown() decrements it; threads calling await() 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 NN 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). Calling await() blocks until all NN threads reach the barrier, then releases them simultaneously. It accepts an optional Runnable that 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 CyclicBarrier and CountDownLatch by 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:

  1. Memory location (VV)
  2. Expected old value (EE)
  3. New value (NN)

VV is updated to NN if and only if V==EV == E. Otherwise, the operation fails without modifying VV, 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 AtomicInteger
public 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 AA to BB and back to AA, CAS sees it as unchanged. AtomicStampedReference resolves 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 synchronized locking 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 ArrayBlockingQueue and LinkedBlockingQueue handle producer-consumer coordination out of the box, offering bounded put() (blocks when full) and take() (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#

ObjectiveUtilityCore Underlying Mechanism
Simple State FlagvolatileMemory barriers (flushes caches, prevents reordering)
Mutual ExclusionsynchronizedJVM monitor + lock inflation (biased \rightarrow lightweight \rightarrow heavyweight)
Advanced Mutual ExclusionReentrantLockAQS framework (volatile state + FIFO queue + LockSupport)
Concurrency ThrottlingSemaphoreAQS shared-mode permit counter
One-Time Event WaitingCountDownLatchSingle-use decrementing AQS state counter
Cyclic Thread AlignmentCyclicBarrierReusable, resettable thread barrier
Point-to-Point Data ExchangeExchangerSynchronized two-thread slot swap
Multi-Stage CoordinationPhaserDynamic party-registration barrier
Lock-Free Atomic StateAtomicIntegerHardware-level CAS instructions
Thread-Safe MapConcurrentHashMapCAS bucket init + per-bucket node lock
Read-Heavy ListCopyOnWriteArrayListArray-copy mutations + lock-free reads
Producer-Consumer QueueBlockingQueueCondition-backed bounded queues
Java Concurrency: A Problem-to-Solution Guide
https://astro-nyc.pages.dev/posts/java-concurrency-problem-to-solution-guide/
Author
Hari Seldon
Published at
2026-08-12
License
CC BY-NC-SA 4.0