Java Concurrency Pitfalls

Concurrent code fails in ways single-threaded testing rarely reveals — race conditions, deadlocks, livelocks, starvation, visibility bugs, and broken double-checked locking are the recurring culprits.

Concurrent Java programs introduce an entire category of bugs that single-threaded testing typically does not surface: race conditions, deadlocks, livelocks, starvation, visibility problems, unsafe publication of objects across threads, uncontrolled mutable shared state, and a famous historically broken idiom, double-checked locking without volatile. This topic surveys each pitfall with a concrete example of the failure and the standard fix, building on the concurrency fundamentals covered elsewhere.

Race Conditions

A race condition occurs when the correctness of a program depends on the relative timing or interleaving of multiple threads, and different interleavings produce different (and sometimes wrong) results. The classic example is an unsynchronized compound operation like count++, which is actually three separate steps (read, increment, write) that can interleave between threads, losing updates.

The fix is to make the compound operation atomic — via synchronized, a java.util.concurrent.atomic.AtomicInteger, or another concurrency-safe construct — so no interleaving can lose an update.

class Counter {
    private int count = 0;
    void increment() { count++; } // NOT atomic: read-modify-write
    int get() { return count; }
}

// Two threads each calling increment() 100_000 times concurrently
// commonly produces a final count LESS than 200_000, because some
// increments are lost when both threads read the same value before
// either writes back.

Deadlocks

A deadlock occurs when two or more threads each hold a lock the other needs, and each waits forever for the other to release it — none can proceed. The classic cause is inconsistent lock ordering: thread A locks resource 1 then tries to lock resource 2, while thread B locks resource 2 then tries to lock resource 1.

The standard fix is a globally consistent lock acquisition order: every thread that needs both locks always acquires them in the same order (e.g. always lockA before lockB), which makes the circular-wait condition impossible.

Four conditions must all hold for deadlock: mutual exclusion (locks are exclusive), hold-and-wait (a thread holds one lock while waiting for another), no preemption (a lock can't be forcibly taken away), and circular wait. Breaking any one of these prevents deadlock — consistent lock ordering breaks circular wait.

Object lockA = new Object();
Object lockB = new Object();

// Thread 1
synchronized (lockA) {
    synchronized (lockB) { /* ... */ }
}
// Thread 2 (running concurrently)
synchronized (lockB) {
    synchronized (lockA) { /* ... */ } // deadlock if Thread 1 holds lockA and Thread 2 holds lockB simultaneously
}

Livelocks

A livelock occurs when threads are not blocked — they are actively running — but keep changing state in response to each other in a way that never makes progress, similar to two people repeatedly stepping the same direction trying to let each other pass in a hallway. A common cause is threads that detect potential deadlock and 'politely' back off and retry, but do so in a way that always collides again.

Livelock is often fixed by introducing randomized backoff (each thread waits a random amount of time before retrying, rather than a fixed strategy that keeps colliding) or by imposing a strict ordering/priority so retries don't collide indefinitely.

Starvation

Starvation occurs when a thread is perpetually denied access to a resource it needs because other threads are repeatedly favored — for example, a naive lock implementation or scheduler that always grants access to whichever thread asks first among several waiting, systematically skipping over one unlucky thread. Unlike deadlock, the starved thread is not permanently blocked in principle, just never actually gets its turn in practice.

Fair locks (e.g. new ReentrantLock(true) from java.util.concurrent.locks) grant access roughly in request order, trading some throughput for reduced starvation risk — the default (non-fair) mode favors throughput and does not guarantee ordering.

Visibility Bugs

Without proper synchronization or volatile, one thread's write to a shared variable is not guaranteed to become visible to another thread in a timely way, or at all, from that other thread's perspective — the reading thread may keep observing a stale cached value indefinitely (in principle, though in practice this often manifests as an unpredictable delay). This is a distinct problem from race conditions: it's about visibility of already-written values, not about interleaving of read-modify-write steps.

volatile guarantees that a write by one thread becomes visible to a subsequent read by another thread, and establishes ordering — but it does not make compound operations like count++ atomic; for that, atomic classes or explicit locking are needed instead.

class Flag {
    private boolean running = true; // no volatile: visibility not guaranteed
    void stop() { running = false; }
    void loop() {
        while (running) {
            // may loop forever on some JVMs/optimizations, never observing
            // the write from another thread's stop() call
        }
    }
}

// Fix: mark the field volatile
class FixedFlag {
    private volatile boolean running = true;
    void stop() { running = false; }
    void loop() {
        while (running) { /* now guaranteed to eventually observe false */ }
    }
}

Unsafe Publication

Unsafe publication means making a reference to an object visible to another thread before the object is fully and safely constructed, risking the other thread observing a partially initialized object. This can happen when a constructor leaks this (passing a reference to itself to another object or starting a thread before construction finishes), or when a reference to a mutable object is published via a shared field without synchronization or volatile.

Safe publication idioms include: assigning to a properly synchronized or volatile field, initializing an object entirely within a static initializer, using a final field for the reference (final fields have specific safe-publication guarantees under the Java Memory Model when the object doesn't let this escape during construction), or using a thread-safe concurrent collection to hand off the reference.

Mutable Shared State

Most concurrency bugs ultimately trace back to mutable state shared across threads without adequate coordination. The most robust general strategy is to minimize how much mutable state is actually shared: prefer confining mutable data to a single thread, prefer immutable objects (which are inherently thread-safe to read, since they cannot change after construction), and when sharing mutable state is unavoidable, use well-tested concurrency utilities (java.util.concurrent) rather than ad hoc locking.

Incorrect Double-Checked Locking

Double-checked locking is a lazy-initialization idiom meant to avoid the cost of synchronizing on every access to a singleton, by checking a flag/reference outside the lock first, and only synchronizing (and checking again) if it looks uninitialized. Without volatile, this idiom was famously broken on the pre-Java-5 memory model, and it remains broken today if the field is not declared volatile: a reading thread can observe a reference to a partially constructed object due to instruction reordering.

volatile on the field, since Java 5's revised memory model, restores the guarantee that a thread observing a non-null instance also observes a fully constructed object. A simpler and often preferable alternative is the 'initialization-on-demand holder' idiom, which relies on the JVM's own lazy, thread-safe class initialization instead of hand-rolled double-checked locking.

// BROKEN without volatile: another thread can see 'instance' non-null
// while the Singleton object it points to is not yet fully constructed,
// because the write to 'instance' can be reordered ahead of the constructor's writes.
class BrokenSingleton {
    private static BrokenSingleton instance; // missing volatile
    static BrokenSingleton getInstance() {
        if (instance == null) {
            synchronized (BrokenSingleton.class) {
                if (instance == null) {
                    instance = new BrokenSingleton(); // unsafe publication risk
                }
            }
        }
        return instance;
    }
}

// CORRECT: volatile prevents the reordering that breaks the idiom
class CorrectSingleton {
    private static volatile CorrectSingleton instance;
    static CorrectSingleton getInstance() {
        if (instance == null) {
            synchronized (CorrectSingleton.class) {
                if (instance == null) {
                    instance = new CorrectSingleton();
                }
            }
        }
        return instance;
    }
}

Diagnosing These Pitfalls

Concurrency bugs are notoriously hard to reproduce because they often depend on rare timing windows that pass silently in most runs, including in tests. Thread dumps (jstack, or a profiler like JDK Mission Control) can reveal threads stuck in a deadlock (the JVM can often detect and report classic two-lock deadlocks directly) or in a livelock (threads perpetually RUNNABLE but never completing). Tools and disciplined code review — consistent lock ordering, minimizing shared mutable state, preferring higher-level java.util.concurrent constructs over hand-rolled synchronization — matter more here than after-the-fact debugging, because these bugs are so hard to reliably reproduce once shipped.

Key Takeaways

A race condition is timing-dependent incorrect behavior from unsynchronized compound operations (like count++) interleaving across threads.

Deadlock arises from circular waiting on locks; consistent, program-wide lock ordering is the standard prevention.

Livelock is threads actively responding to each other without making progress; randomized backoff or strict ordering typically fixes it.

Starvation is a thread perpetually denied a resource due to unfair scheduling; fair locks reduce this risk at some throughput cost.

volatile guarantees visibility and ordering of a single variable's writes across threads, but does not make compound operations atomic.

Unsafe publication lets another thread observe a partially constructed object; safe publication uses proper synchronization, volatile, final fields, or thread-safe handoff.

Minimizing shared mutable state — favoring confinement and immutability — is the most robust general defense against concurrency bugs.

Double-checked locking is broken without volatile on the field; with volatile it works correctly since Java 5's revised memory model, though the holder idiom is often simpler.

Why is count++ on a shared int field not thread-safe even though it looks like a single operation?

It is actually three steps — read the current value, add one, write the result back — and those steps can interleave between threads. Two threads can both read the same value before either writes back, causing one increment to be lost.

What are the four conditions required for deadlock, and which one does consistent lock ordering break?

Mutual exclusion, hold-and-wait, no preemption, and circular wait. Consistent, program-wide lock acquisition ordering breaks circular wait, since no cycle of threads can each be waiting on a lock held by the next if everyone acquires locks in the same global order.

How does a livelock differ from a deadlock?

In a deadlock, threads are blocked and make no progress at all. In a livelock, threads are actively running and changing state in response to each other, but the pattern never converges to actual progress — neither is truly stuck, but neither completes.

What guarantee does volatile provide, and what does it NOT provide?

It guarantees that a write to the variable by one thread becomes visible to a subsequent read by another thread, with proper ordering. It does not make compound operations like count++ atomic, and does not by itself make an object 'thread-safe' as a whole.

Why was the classic double-checked locking idiom broken before Java 5, and what fixes it?

Without volatile, the write that assigns the fully constructed object to the shared field could be reordered ahead of the constructor's own writes under the old memory model, letting another thread observe a non-null reference to a partially constructed object. Declaring the field volatile (Java 5+ memory model) restores the correct ordering guarantee.

What is unsafe publication, and name one way to avoid it.

Making a reference to an object visible to another thread before it is fully, safely constructed — risking observation of a partially initialized object. It can be avoided via proper synchronization/volatile on the publishing field, using final fields (with the object not letting this escape during construction), or handing the reference off through a thread-safe concurrent collection.

Why does minimizing shared mutable state reduce concurrency bugs more reliably than adding more locks?

Every additional lock introduces more opportunity for contention, ordering mistakes, and deadlock. Immutable objects need no synchronization to read safely because they cannot change after construction, and thread-confined mutable state needs no coordination at all — reducing what's shared reduces the attack surface for race conditions, visibility bugs, and deadlocks simultaneously.

What concurrency pitfall does this class risk, and how would you fix it with a minimal change?

It risks a visibility bug: without volatile (or other synchronization), Thread B is not guaranteed to ever observe Thread A's write to ready, so the polling loop could in principle spin indefinitely without seeing the update. The minimal fix is declaring the field private volatile boolean ready = false;, which guarantees the write becomes visible to subsequent reads on other threads.

Write a thread-safe Counter class using AtomicInteger that fixes the race condition in a naive count++ implementation, and explain why it's safe.

import java.util.concurrent.atomic.AtomicInteger; class Counter { private final AtomicInteger count = new AtomicInteger(0); void increment() { count.incrementAndGet(); } int get() { return count.get(); } }

AtomicInteger.incrementAndGet() performs the read-modify-write as a single atomic hardware-supported operation (typically compare-and-swap), so no interleaving between threads can lose an update the way plain int count++ can. Note atomicity here is a separate property from lock-freedom — AtomicInteger's operations are atomic, and on typical JVMs implemented without blocking locks, but that implementation detail is not itself what makes them correct; the atomicity guarantee is.

What concurrency hazard exists here, and how should the code be restructured to remove it?

This risks deadlock: if Thread 1 acquires lockX and Thread 2 simultaneously acquires lockY, each then waits forever for the lock the other holds. The fix is to always acquire the two locks in the same global order in every method — e.g. both methods should lock lockX before lockY (or use a consistent ordering derived from object identity hashes for a general pair of locks) — which eliminates the circular-wait condition required for deadlock.

class Resource {
    private boolean ready = false;
    void markReady() { ready = true; }
    boolean isReady() { return ready; }
}

// Thread A calls markReady() once. Thread B polls isReady() in a loop.
Object lockX = new Object();
Object lockY = new Object();

// Method runs on Thread 1
void transferAtoB() {
    synchronized (lockX) {
        synchronized (lockY) { /* transfer */ }
    }
}

// Method runs on Thread 2
void transferBtoA() {
    synchronized (lockY) {
        synchronized (lockX) { /* transfer */ }
    }
}