Intermediate15 min readConcurrency & Thread Safetylive prototype

Deadlock, race conditions, starvation

Three classic ways concurrent code goes wrong, and they fail in three different shapes. A race condition is two people updating the same shopping list from memory — one edit quietly overwrites the other. A deadlock is two diners each holding one chopstick, each politely waiting for the other's — nobody eats, ever. Starvation is the polite person at a crowded bar who never gets served because louder people keep cutting in. Learn to spot the shape and the fix follows.

The idea

What it is

The moment two threads touch the same thing, three specific bugs become possible. They are worth learning as a set because they feel completely different when they hit you: a race condition gives you a wrong answer with everything still running; a deadlock gives you a program that stops dead with no error and no CPU usage; starvation gives you a program that works fine except one poor thread that never seems to get anything done.

Here's each one in one picture. Race condition — you and a flatmate both memorise the shopping list, both add one item from memory, and both write the list back: one of the two additions vanishes. Deadlock — two diners share two chopsticks; each grabs one and waits for the other to hand theirs over; both wait forever. Starvation — you're at a busy bar waiting politely while louder people keep getting served ahead of you; nothing is broken, you're just never picked.

The one sentence to remember

A race is wrong answer (two threads interleave inside one logical step), a deadlock is nobody moves (a cycle of threads each waiting on a lock the next one holds), and starvation is someone never moves (a thread that could run but never gets picked). Locks fix races — and cause deadlocks and starvation.

Where you've met these

A double-charged payment or a like count that drifts low is usually a race. A web app that hangs with 0% CPU until you restart it is usually a deadlock — a thread dump will show two threads each BLOCKED on a monitor the other owns. A background job that never finishes while the request threads fly along is usually starvation.

Mechanics

How it works

1. Race condition — the interleaving that eats an update

The trap is that count++ is not one step. The CPU does three: read the value into a register, add one, write it back. Two threads can slide in between each other's steps, and then both add 1 to the same starting value — so two increments produce one.

java
// Both threads run this. count starts at 0.
count++;      // really: 1. read count   2. add 1   3. write count

//  T1: read 0 ....................... add → 1 ... write 1
//  T2: ......... read 0 ... add → 1 ............ write 1
//  count is now 1. Two increments happened. One survived.

The fix is to make the whole read-modify-write atomic — indivisible from any other thread's point of view. Either wrap it in a lock (a critical section: at most one thread inside at a time) or use a hardware atomic like AtomicInteger.incrementAndGet() / atomic.Add. Both mean the same thing: nobody else can observe or touch the value halfway through.

Why races are the nastiest of the three

A race is timing-dependent. Whether it bites depends on when the OS happens to switch threads, so it can pass your test suite 99 runs out of 100 on a quiet laptop and then fail every hour in production on a 32-core box under load. There's no crash, no stack trace — just numbers that are slightly, inexplicably wrong. Never conclude 'the race is fixed' because the test went green; reason about the code, and use a race detector (Go's -race, Java's jcstress, C++ ThreadSanitizer).

2. Deadlock — the cycle of polite waiting

Thread 1 holds lock A and needs B. Thread 2 holds lock B and needs A. Neither will let go of what it has before it gets what it wants, so both wait forever. Nothing crashes; the threads just go to sleep and never wake. Draw the waiting relationships as arrows and you'll see a cycle — that ring is the signature of a deadlock.

A deadlock needs four conditions to hold at once (the Coffman conditions). Break any single one and deadlock becomes impossible:

  • Mutual exclusion — the resource can only be held by one thread at a time. (Break it: use immutable data or a copy per thread, so there's nothing to lock.)
  • Hold and wait — a thread keeps what it has while waiting for more. (Break it: grab all the locks you need in one shot, or release what you hold before waiting.)
  • No preemption — you can't yank a lock away from a thread. (Break it: tryLock with a timeout, so a waiter gives up, releases everything, and retries.)
  • Circular wait — the waiting arrows form a ring. (Break it: a global lock order — every thread takes A before B, always. This is the fix you'll use 90% of the time.)
java
// ✗ Two orders → a ring is possible
void t1() { synchronized (A) { synchronized (B) { work(); } } }   // A → B
void t2() { synchronized (B) { synchronized (A) { work(); } } }   // B → A

// ✓ One global order → no ring can ever form
void t1() { synchronized (A) { synchronized (B) { work(); } } }   // A → B
void t2() { synchronized (A) { synchronized (B) { work(); } } }   // A → B
// T2 now waits at A holding NOTHING, so it can't block anybody.

Other real fixes, in rough order of preference: take fewer locks (one coarse lock is boring and correct), hold them for less time (never call unknown code — a callback, an RPC — while holding a lock), tryLock with a timeout so a stuck thread backs off and retries instead of hanging, and best of all no shared mutable state at all (message passing, immutable values, one owner per piece of data).

Livelock — deadlock's twitchy cousin

If both threads keep timing out, backing off, and retrying in perfect lockstep, they stay busy but never progress — like two people stepping side to side in a corridor, forever. That's livelock: CPU is burning, work isn't happening. The cure is a randomised back-off so the symmetry breaks.

3. Starvation — able to run, never picked

A starving thread is not stuck on anything — it is runnable. It just never wins the turn, because greedier or higher-priority threads keep getting there first. Plain locks make no promise about who goes next: when a lock is released, whichever thread the OS happens to wake grabs it, and a thread that has waited a long time gets no special treatment. Under constant load, one unlucky thread can wait effectively forever.

  • Fair locks / FIFO queues — hand out turns in arrival order, so the longest waiter is served next. In Java that's new ReentrantLock(true); more generally, put requests in a queue instead of letting threads scramble.
  • Don't play priority games — big priority gaps let high-priority work permanently drown low-priority work. Keep priorities close to default unless you have a real reason.
  • Bound the greedy work — batch sizes, per-client rate limits, and time slices stop one hot task from monopolising the resource.

Fairness isn't free — and priority inversion is real

A fair lock guarantees turns but is markedly slower than a barging lock, because it hands the resource off between threads instead of letting whoever is already running keep it — so use it only when you actually observe starvation. And watch for priority inversion: a low-priority thread holding a lock blocks a high-priority one, which is exactly what nearly killed the Mars Pathfinder mission in 1997. The standard cure is priority inheritance — the lock holder temporarily inherits the waiter's priority.

Telling them apart when something goes wrong

  • Wrong numbers, everything still running → race condition. Look for shared mutable state touched without a lock or an atomic.
  • Hung, no CPU, no error → deadlock. Take a thread dump: you'll see threads BLOCKED, each on a lock another blocked thread owns.
  • Hung, CPU pinned at 100% → livelock or a spin loop, not a deadlock.
  • Everything works but one task never completes → starvation. Check who else is hammering that lock, queue, or thread pool.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

Three tabs, one failure each. ⚡ Race — two threads want to add 1 to a shared count. Press ▶ Interleave six times and watch the micro-steps read +1 write interleave: T1 reads 0, T2 reads the same 0, both write 1, and the caption ends at count: 1 ✗ expected 2 with the box flashing red. Now hit 🔒 Add lock and step through the exact same actions — T2's lane greys out until T1 is done, and you land on count: 2 ✓. 💀 Deadlock — click T1 grab A, T2 grab B, T1 wants B, T2 wants A; the four arrows close into a red loop, a 💀 DEADLOCK chip drops in, and the caption reads blocked: 2/2 forever. Flip ↻ Same order and replay: now both threads take A before B, T2 simply waits holding nothing, and everyone finishes. 🥱 Starvation — press ▶ Tick a few times: the two 🐷 hogs keep grabbing the 🍽 resource while 🙂 Polite's waited: counter climbs and its lane turns amber. Hit ⚖️ Fair queue and the very next tick serves Polite, counter back to 0 ✓.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Lose an update, then get it back

On the ⚡ Race tab, press ▶ Interleave six times. Watch the order: T1 read → T2 read (both see 0) → T1 +1 → T2 +1 → T1 write → T2 write. The caption ends at count: 1 ✗ expected 2 and the box flashes red — two increments, one survivor. Now click 🔒 Add lock and step through the same actions again: T2's lane greys out while T1 holds the lock, T2 later reads the fresh 1 instead of a stale 0, and you finish on count: 2 ✓. Same steps, one difference: the read-modify-write became indivisible.

try 02

Close the circular wait, then break it

On the 💀 Deadlock tab, click the buttons left to right: T1 grab A, T2 grab B, T1 wants B, T2 wants A. After the third click one thread is amber (blocked: 1/2); after the fourth the arrows close into a red ring, the 💀 DEADLOCK chip appears and the caption reads blocked: 2/2 forever. Now hit ↻ Same order and replay — this time both threads take A then B, so T2 waits at A holding nothing, T1 finishes, and T2 walks through. One rule (always A before B) deleted the cycle.

try 03

Starve the polite thread, then queue it

On the 🥱 Starvation tab, press ▶ Tick five or six times. The two 🐷 hogs alternate grabbing the 🍽 resource, their served: counters climb, and 🙂 Polite's lane turns amber with waited: 5, 6, 7… — it is never blocked, just never chosen. Click ⚖️ Fair queue and press ▶ Tick once: Polite is served immediately because FIFO picks the longest waiter, and the caption resets to waited: 0 ✓.

In practice

When to use it — and what trips people up

Go looking for these when...

  • Any mutable state is reachable from two threads — a counter, a cache, a shared list, a lazily built singleton. That is the entire precondition for a race.
  • A code path takes two or more locks — the instant that happens, write down the order and make it a rule everyone follows. Two lock orders in one codebase is a deadlock waiting for load.
  • Threads have different priorities or wildly different workloads — a fast, frequent task sharing a lock with a slow, rare one is the classic starvation setup.

Sidestep them entirely when...

  • The state can be immutable — nothing to race on, nothing to lock, no cycle to form. This is the cheapest fix in existence.
  • One thread can own the data — an actor, a single-writer goroutine, an event loop. Others send messages instead of touching state.
  • A library already solved itConcurrentHashMap, AtomicLong, BlockingQueue, channels. Hand-rolled locking loses to battle-tested primitives almost every time.

What it gives you

  • Locks make read-modify-write atomic, which removes lost updates — the single most common concurrency bug in real systems.
  • A global lock ordering rule is nearly free: it costs one code-review habit and makes circular wait, and therefore deadlock, impossible.
  • tryLock with a timeout converts a permanent hang into a retryable failure you can log, meter and alert on.
  • Fair (FIFO) locks give a hard upper bound on waiting time, so no thread can be indefinitely postponed.

Common mistakes

  • Every lock you add is a new chance to deadlock or starve — the cure for one bug is the cause of the other two.
  • Races are timing-dependent, so tests are weak evidence: a suite that passes proves nothing about interleavings it never happened to hit.
  • Fair locks are noticeably slower than barging locks because the resource is handed off between threads instead of staying hot on one.
  • More locks means less parallelism: taken far enough, a heavily locked design performs like single-threaded code with extra overhead.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;

public class ThreeBugs {

    /* ---------- 1. RACE CONDITION ---------- */
    static int plain = 0;                                   // ++ is read → add → write
    static final AtomicInteger atomic = new AtomicInteger(); // one indivisible step

    static void race() throws InterruptedException {
        Runnable job = () -> {
            for (int i = 0; i < 100_000; i++) {
                plain++;                     // ✗ interleaving silently loses updates
                atomic.incrementAndGet();    // ✓ atomic read-modify-write
            }
        };
        Thread a = new Thread(job), b = new Thread(job);
        a.start(); b.start(); a.join(); b.join();
        System.out.println(plain + " vs " + atomic.get());  // e.g. 137421 vs 200000
    }

    /* ---------- 2. DEADLOCK ---------- */
    static final Object A = new Object();
    static final Object B = new Object();

    static void deadlockProne() {            // ✗ this thread goes B → A
        synchronized (B) { synchronized (A) { /* work */ } }
    }

    static void safeOrder() {                // ✓ EVERY thread goes A → B
        synchronized (A) { synchronized (B) { /* work */ } }
    }

    static final ReentrantLock la = new ReentrantLock();
    static final ReentrantLock lb = new ReentrantLock();

    static boolean tryBoth() throws InterruptedException {   // ✓ back off instead of hanging
        if (!la.tryLock(50, TimeUnit.MILLISECONDS)) return false;
        try {
            if (!lb.tryLock(50, TimeUnit.MILLISECONDS)) return false;  // give up, retry later
            try { /* work */ } finally { lb.unlock(); }
            return true;
        } finally { la.unlock(); }
    }

    /* ---------- 3. STARVATION ---------- */
    static final ReentrantLock barging = new ReentrantLock();      // whoever grabs first wins
    static final ReentrantLock fifo    = new ReentrantLock(true);  // fair: longest waiter first

    static void politeThread() {
        fifo.lock();                          // queued in arrival order → always gets a turn
        try { /* work */ } finally { fifo.unlock(); }
    }

    public static void main(String[] args) throws InterruptedException {
        race();
    }
}

References & further reading

7 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 06

Two threads each run count++ once on a shared count that starts at 0, with no lock. Why can the result be 1?

question 02 / 06

Your test suite runs the concurrent code 100 times and passes every time. What does that prove about a suspected race condition?

question 03 / 06

Which Coffman condition does a global lock ordering rule ('always take A before B') break?

question 04 / 06

A service hangs. A thread dump shows two threads BLOCKED, each on a monitor the other owns, and CPU usage is near 0%. What is this?

question 05 / 06

A low-priority background thread on a busy server never seems to finish, though it is always ready to run. Which fix targets the actual problem?

question 06 / 06

Two threads keep timing out on a lock, releasing everything, and retrying in perfect step. CPU is pegged at 100% but no work completes. What is happening, and what fixes it?

0/6 answered