Most Java engineers understand concurrency conceptually — multiple tasks running at the same time, shared state being the source of bugs, synchronization as the fix. What gets less attention is the cost model underneath: why concurrent code fails in specific, predictable ways, and why the same bug that works fine at 10 requests per second destroys a system at 10,000. Understanding that cost model is what separates engineers who can reason about concurrent systems from those who can only fix the symptoms.
This article covers how the JVM implements concurrency, what the memory model actually guarantees, the failure modes that appear under load, and the tools Java provides to manage shared state correctly.
Concurrency vs Parallelism: A Distinction That Matters
Concurrency is about structure — managing multiple tasks that can overlap in time. Parallelism is about execution — multiple tasks running simultaneously on multiple CPUs. You can have concurrency on a single-core machine: the CPU switches between tasks fast enough that they appear to overlap. Parallelism requires multiple cores.
In Java, concurrency is managed by the JVM thread scheduler, which multiplexes Java threads onto available CPUs. On a 4-core machine, at most 4 threads run truly in parallel at any instant. If you have 200 threads, the other 196 are waiting for a CPU turn. The scheduler’s decisions — which thread runs when, for how long — are not deterministic from your application’s perspective. This non-determinism is the root cause of most concurrency bugs.
The Java Memory Model: What the JVM Guarantees
The Java Memory Model (JMM), specified in the Java Language Specification, defines what values a thread is allowed to read from shared variables. Without understanding the JMM, you cannot reason correctly about concurrent code.
The core concept is happens-before. A happens-before relationship guarantees that if action A happens-before action B, then the effects of A are visible to B. Without a happens-before relationship between a write in one thread and a read in another, the reading thread may see a stale cached value, a partially-constructed object, or the write may be reordered by the compiler or CPU beyond what you expect.
The most common way to establish happens-before in Java is through: releasing a monitor lock happens-before acquiring the same lock, writing a volatile variable happens-before reading the same volatile variable, and starting a thread happens-before any action in that thread. Every other concurrency guarantee in Java derives from these foundations.
// Java 5+ — visibility problem without proper synchronization
// This class has a data race on 'ready' and 'value'
public class VisibilityProblem {
private static boolean ready = false;
private static int value = 0;
public static void main(String[] args) throws InterruptedException {
Thread writer = new Thread(() -> {
value = 42; // write to value
ready = true; // write to ready — may be seen BEFORE value=42 by the reader
});
Thread reader = new Thread(() -> {
while (!ready) { /* spin */ }
// Not guaranteed to see value=42 here — no happens-before relationship
// JVM or CPU may reorder the writer's assignments
System.out.println(value); // could print 0
});
reader.start();
writer.start();
}
}
// Fix: use volatile to establish happens-before between the writes and reads
// Or use synchronized blocks, or an AtomicBooleanCode language: Java (java)
Synchronization: Mutual Exclusion and Memory Visibility
synchronized does two things that are easy to conflate. First, it provides mutual exclusion — only one thread can hold a monitor at a time, preventing race conditions on the protected state. Second, it establishes happens-before — when a thread releases a monitor, all writes made while holding it are visible to the next thread that acquires the same monitor.
Many engineers focus only on the mutual exclusion aspect and miss visibility entirely. This leads to code that is correctly serialized but still reads stale values because the synchronization boundaries do not cover the reads.
// Java 5+ — thread-safe counter using synchronized
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.CountDownLatch;
public class SynchronizedCounter {
private static final Logger log = LoggerFactory.getLogger(SynchronizedCounter.class);
private int count = 0;
// synchronized provides both mutual exclusion AND memory visibility for count
public synchronized void increment() {
count++;
}
public synchronized int get() {
return count;
}
public static void main(String[] args) throws InterruptedException {
SynchronizedCounter counter = new SynchronizedCounter();
ExecutorService executor = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(1000);
for (int i = 0; i < 1000; i++) {
executor.submit(() -> {
counter.increment();
latch.countDown();
});
}
latch.await();
executor.shutdown();
log.info("Final count: {}", counter.get()); // always 1000
}
}Code language: Java (java)
Atomic Operations and Lock-Free Concurrency
For simple shared counters and flags, synchronized is often heavier than necessary. The java.util.concurrent.atomic package provides atomic operations that use CPU-level compare-and-swap (CAS) instructions rather than locking. CAS operations are non-blocking — if the CAS fails (another thread modified the value), the operation retries rather than parking the thread.
This makes atomics significantly faster under contention for single-variable operations. The tradeoff: atomics only guarantee atomicity for operations on a single variable. Compound operations — read-modify-write across two variables — still require locks.
// Java 5+ — atomic counter using CAS; no locking, no thread parking
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
public class AtomicExamples {
private static final Logger log = LoggerFactory.getLogger(AtomicExamples.class);
// AtomicInteger: increment/decrement/CAS without synchronized
private final AtomicInteger requestCount = new AtomicInteger(0);
// AtomicLong: useful for request IDs, sequence numbers
private final AtomicLong requestId = new AtomicLong(0);
public void handleRequest() {
long id = requestId.incrementAndGet(); // atomic; safe under any concurrency
requestCount.incrementAndGet();
log.info("Handling request id={}", id);
}
// compareAndSet: only updates if current value matches expected
public boolean tryAcquirePermit(AtomicInteger permits) {
int current = permits.get();
if (current <= 0) return false;
// If another thread decremented permits between get() and compareAndSet(),
// compareAndSet returns false and we retry or give up
return permits.compareAndSet(current, current - 1);
}
}Code language: Java (java)
Concurrent Collections: What Is Thread-Safe and What Is Not
HashMap is not thread-safe. Concurrent modifications from multiple threads produce unpredictable results — including infinite loops in Java 6 and below due to broken linked list cycles during rehashing. Collections.synchronizedMap() wraps a HashMap with coarse-grained locking — every read and write acquires the same lock, which serializes all access and eliminates parallel reads.
ConcurrentHashMap is the correct choice for most concurrent map use cases. It uses segment-level locking (Java 7) and node-level CAS (Java 8+), allowing reads to proceed without locking and limiting write lock scope to the affected bucket. Under typical read-heavy workloads, it is significantly faster than a synchronized wrapper.
// Java 8+ — ConcurrentHashMap for thread-safe key-value storage
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentMapExample {
private static final Logger log = LoggerFactory.getLogger(ConcurrentMapExample.class);
// ConcurrentHashMap: reads never lock; writes lock at bucket level only
private final ConcurrentHashMap<String, Integer> sessionCache = new ConcurrentHashMap<>();
public void recordVisit(String sessionId) {
// computeIfAbsent and merge are atomic — safe to call from multiple threads
sessionCache.merge(sessionId, 1, Integer::sum);
}
public int getVisitCount(String sessionId) {
return sessionCache.getOrDefault(sessionId, 0);
}
// compute() is atomic: the function runs under the bucket lock
// Use this for read-modify-write on a single key
public void conditionalUpdate(String sessionId, int threshold) {
sessionCache.compute(sessionId, (key, existing) -> {
if (existing == null || existing < threshold) return threshold;
return existing;
});
log.debug("Updated session {}", sessionId);
}
}Code language: Java (java)
ReentrantLock: When synchronized Is Not Enough
synchronized has limitations that matter in real systems. You cannot try to acquire a lock without blocking. You cannot acquire a lock with a timeout. You cannot interrupt a thread waiting for a lock. ReentrantLock provides all of these, and also supports fair lock ordering — threads acquire the lock in the order they requested it, preventing starvation.
In the context of virtual threads, ReentrantLock has an additional advantage: a virtual thread waiting to acquire a ReentrantLock can be unmounted from its carrier thread. A virtual thread blocked on a synchronized monitor cannot. For applications using virtual threads (Java 21+), replacing synchronized blocks that involve I/O with ReentrantLock is not just a style choice — it directly affects throughput.
// Java 5+ — ReentrantLock for advanced locking scenarios
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class ReentrantLockExample {
private static final Logger log = LoggerFactory.getLogger(ReentrantLockExample.class);
private final ReentrantLock lock = new ReentrantLock();
private int balance = 0;
// tryLock with timeout: does not block indefinitely
public boolean withdraw(int amount) {
try {
// Returns false immediately if lock is not available within 100ms
if (!lock.tryLock(100, TimeUnit.MILLISECONDS)) {
log.warn("Could not acquire lock within timeout — backing off");
return false;
}
try {
if (balance < amount) return false;
balance -= amount;
return true;
} finally {
lock.unlock(); // always in finally — never leave lock acquired on exception
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
// With virtual threads: lock acquisition unmounts the virtual thread
// With synchronized: virtual thread is pinned to its carrier during the wait
public void deposit(int amount) {
lock.lock();
try {
balance += amount;
} finally {
lock.unlock();
}
}
}Code language: Java (java)
Common Concurrency Bugs and How They Appear Under Load
Race conditions are the most common class. Two threads read a shared value, both compute a new value based on it, and both write back — one write is lost. The check-then-act pattern is a specific form: check if a condition is true, then act on it, but another thread changes the condition between the check and the act. These bugs are often invisible at low concurrency and emerge only when request rates increase.
Deadlocks occur when two threads each hold a lock the other needs. Thread A holds lock 1 and waits for lock 2. Thread B holds lock 2 and waits for lock 1. Both are stuck permanently. The most reliable prevention is lock ordering — always acquire locks in the same global order across all code paths. Detecting deadlocks in production is done with jstack, which reports threads in BLOCKED state and the lock each is waiting for.
Livelock is similar to deadlock but threads keep changing state in response to each other without making progress — like two people in a hallway both stepping the same direction repeatedly. Starvation occurs when a low-priority thread never gets CPU time because high-priority threads always run first. Both are less common than race conditions and deadlocks but harder to diagnose.
Interview Questions
What happens if two threads increment a shared int counter without synchronization?
The increment operation — read current value, add 1, write back — is not atomic on the JVM. Two threads can read the same value, both add 1, and both write the same result, losing one increment. Additionally, without a happens-before relationship, one thread may read a stale cached value from its CPU register rather than the updated value in main memory. The result is a counter lower than expected. The fix is AtomicInteger.incrementAndGet(), synchronized, or a lock — all of which establish the necessary happens-before and atomicity.
How does the system behave when Thread A holds lock X and tries to acquire lock Y, while Thread B holds lock Y and tries to acquire lock X?
Both threads block permanently — a deadlock. Neither can proceed because each holds the lock the other needs. The JVM does not detect or resolve deadlocks automatically. In production, the symptom is requests to these code paths hanging indefinitely until timeout. Detection requires a thread dump via jstack showing both threads in BLOCKED state, the lock each holds, and the lock each is waiting for. Prevention is consistent lock ordering across all code paths that acquire both locks.
What issues arise when a ConcurrentHashMap is used but multiple related updates need to be atomic?
ConcurrentHashMap guarantees atomicity for individual operations — put, get, computeIfAbsent, merge — but not for compound operations spanning multiple keys or multiple calls. If you read key A, then read key B, and make a decision based on both values, another thread can modify either key between your reads, making your decision based on an inconsistent snapshot. The fix is to use compute() or merge() to perform the compound operation atomically under the bucket lock, or to use a separate ReentrantLock that guards all related state together.
Summary
Concurrency bugs are deterministic — they follow from the Java Memory Model and the scheduler’s non-determinism. Race conditions come from unsynchronized compound operations. Deadlocks come from inconsistent lock ordering. Visibility bugs come from missing happens-before relationships. The tools Java provides — synchronized, volatile, AtomicInteger, ConcurrentHashMap, ReentrantLock — each address specific parts of this problem. The skill is knowing which tool addresses which problem and what guarantees each one actually provides.




