Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Deadlock, Livelock and Starvation

The four conditions every deadlock needs and how breaking one prevents it, reading a deadlock out of a thread dump, lock ordering and tryLock, and the pool-starvation deadlock with no locks at all.

Advanced7 min readUpdated
On this page

Deadlock is the concurrency failure everyone can name and few can diagnose from a thread dump. It is also the one with a clean theory: four conditions must all hold, and preventing any one of them prevents the deadlock.

Key Takeaways

  • All four Coffman conditions must hold: mutual exclusion, hold-and-wait, no preemption, circular wait. Break one and deadlock is impossible.
  • Consistent lock ordering is the practical defence — order locks by a stable key such as an id.
  • The JVM detects monitor deadlocks itself and prints them in a thread dump.
  • Livelock looks like a CPU spike; deadlock looks like idle threads that never finish.
  • A thread-pool deadlock involves no locks at all and is invisible to JVM deadlock detection.

The four conditions

ConditionMeaningHow to break it
Mutual exclusionA resource cannot be sharedUse immutable data or copies
Hold and waitA thread holding one lock requests anotherAcquire everything at once, or nothing
No preemptionA lock cannot be taken awaytryLock with a timeout — voluntarily give up
Circular waitA cycle exists in the wait-for graphGlobal lock ordering

Breaking circular wait is almost always the right approach in application code: it costs nothing at runtime and requires only a convention.

The classic

two accounts, two threads, opposite order
public void transfer(Account from, Account to, BigDecimal amount) {
    synchronized (from) {
        synchronized (to) {
            from.debit(amount);
            to.credit(amount);
        }
    }
}
 
// Thread 1: transfer(accountA, accountB, ...)  — locks A, wants B
// Thread 2: transfer(accountB, accountA, ...)  — locks B, wants A
// Both wait forever.
A cycle in the wait-for graph. Neither thread can proceed and neither will ever give up its lock.

This needs a specific interleaving, so it typically passes every test and appears in production under load — which is exactly what makes it a good interview question.

Fix 1: order the locks

a total order over the locks
public void transfer(Account from, Account to, BigDecimal amount) {
    // Any consistent, total ordering works. An account id is stable and unique.
    Account first  = from.id().compareTo(to.id()) < 0 ? from : to;
    Account second = first == from ? to : from;
 
    synchronized (first) {
        synchronized (second) {
            from.debit(amount);
            to.credit(amount);
        }
    }
}

Because every thread acquires locks in the same order, a cycle cannot form. System.identityHashCode is the fallback ordering key when objects have no natural id, with a tie-breaker lock for the rare collision.

The same principle scales beyond locks. Two transactions updating the same rows in different orders deadlock in the database, and the fix is identical: always update rows in a deterministic order, usually by primary key.

Fix 2: give up

tryLock with a timeout
public boolean transfer(Account from, Account to, BigDecimal amount) throws InterruptedException {
    long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1);
 
    while (System.nanoTime() < deadline) {
        if (from.lock().tryLock(50, MILLISECONDS)) {
            try {
                if (to.lock().tryLock(50, MILLISECONDS)) {
                    try {
                        from.debit(amount);
                        to.credit(amount);
                        return true;
                    } finally { to.lock().unlock(); }
                }
            } finally { from.lock().unlock(); }
        }
        // Randomised backoff — without it, two threads can retry in lockstep forever.
        Thread.sleep(ThreadLocalRandom.current().nextInt(1, 20));
    }
    return false;
}

This breaks the no preemption condition: a thread that cannot get the second lock releases the first and retries. Note the randomised backoff — without it, this is exactly how a livelock forms.

Livelock

running hard, achieving nothing
// Two threads, both "politely" backing off when they detect a conflict,
// both retrying immediately, both detecting the conflict again.
while (!acquired) {
    if (lockA.tryLock()) {
        if (lockB.tryLock()) { acquired = true; }
        else { lockA.unlock(); }        // no backoff — immediate retry
    }
}

A livelock has the opposite signature from a deadlock: CPU usage is high, threads are RUNNABLE, and throughput is zero. It is easy to mistake for a legitimate CPU spike, and the tell is that the thread dumps taken seconds apart show the same threads cycling through the same few frames.

Randomised, exponential backoff is the standard cure. The same pattern appears at the distributed level in retry storms — see Cascading failure.

Starvation

Starvation is progress for the system and none for one thread. Three usual causes:

Unfair locks. An intrinsic monitor has no queue, so a thread that just released the lock can barge back in ahead of long-waiting threads. Under sustained contention a particular thread may wait indefinitely. new ReentrantLock(true) guarantees FIFO ordering at a significant throughput cost.

Thread priorities. Thread.setPriority is a hint the OS is free to ignore, and on Linux it does almost nothing by default. Never rely on it for correctness.

Long critical sections. One thread holding a lock while doing I/O starves everything else that needs it. The rule is to never make a network or disk call while holding a lock — compute what you need, release, then call.

Reading a thread dump

capture it
jcmd <pid> Thread.print > dump.txt
# or
jstack -l <pid> > dump.txt
what the JVM prints
Found one Java-level deadlock:
=============================
"order-worker-3":
  waiting to lock monitor 0x00007f8e1c0064f8 (object 0x000000076ab3f1a8, a Account),
  which is held by "order-worker-7"
"order-worker-7":
  waiting to lock monitor 0x00007f8e1c008a10 (object 0x000000076ab3f0c0, a Account),
  which is held by "order-worker-3"
 
Java stack information for the threads listed above:
"order-worker-3":
    at com.acme.TransferService.transfer(TransferService.java:42)
    - waiting to lock <0x000000076ab3f1a8> (a com.acme.Account)
    - locked <0x000000076ab3f0c0> (a com.acme.Account)

The JVM builds the wait-for graph and reports cycles automatically. Two lines to look for in each stack: - locked for what the thread holds, - waiting to lock for what it wants. The line numbers name the exact source locations to fix.

For a programmatic check, ThreadMXBean.findDeadlockedThreads() returns the same information and is worth exposing as a health indicator in a long-running service.

The pool deadlock

no locks, permanently stuck
ExecutorService pool = Executors.newFixedThreadPool(4);
 
Future<Report> outer = pool.submit(() -> {
    // Runs on a pool thread, and blocks waiting for work that must ALSO
    // run on a pool thread.
    Future<Section> inner = pool.submit(() -> buildSection());
    return new Report(inner.get());        // deadlocks when all 4 threads are here
});

With four threads all executing the outer task and all blocked in inner.get(), there is no thread left to run any inner task. The pool is stuck forever. The JVM reports no deadlock because there is no lock cycle — just four threads in WAITING on a FutureTask.

Three fixes: use separate pools for the two levels; restructure so a task never waits for another task on the same pool (thenCompose instead of get()); or use virtual threads, where blocking does not consume a limited resource.

This is the most common deadlock shape in modern Java services, precisely because it looks nothing like the textbook example.

What gets asked

"Write me a deadlock" and "how would you find one in production" are the two standard questions. The answer that distinguishes you is the pool version — describing a deadlock with no locks in it shows you have debugged a real system rather than memorised the two-accounts example.

Frequently Asked Questions

How do you detect a deadlock in a running Java application?
Take a thread dump with jstack or jcmd Thread.print. The JVM detects cycles in monitor ownership itself and prints a "Found one Java-level deadlock" section naming the threads, the locks each holds and the lock each is waiting for. For programmatic detection, ThreadMXBean.findDeadlockedThreads returns the same information and can be exposed as a health check.
What is the difference between deadlock, livelock and starvation?
In a deadlock, threads are blocked forever waiting for each other and use no CPU. In a livelock, threads are running and repeatedly responding to each other but making no progress — CPU usage is high and nothing completes. In starvation, the system is progressing but one thread never gets scheduled or never acquires the resource it needs, usually because higher-priority or luckier threads keep taking it.
Can you have a deadlock without any locks?
Yes, and it is one of the most common production versions. If a task running in a thread pool submits another task to the same pool and blocks waiting for its result, and every thread in the pool does the same, no thread is left to run the submitted work. The JVM sees no lock cycle so it reports no deadlock, but the pool is permanently stuck.

Related tutorials