Report this

What is the reason for this report?

Deadlock in Java: Examples, Detection, and Prevention

Updated on March 20, 2026
Deadlock in Java: Examples, Detection, and Prevention

A deadlock in Java is a situation where two or more threads are permanently blocked because each thread holds a lock that another thread in the same group needs. No thread can proceed, no lock is ever released, and the JVM provides no recovery mechanism by default. Deadlocks rank among the hardest bugs to reproduce in production: they appear intermittently under load, leave no exception or stack trace in application logs, and cause processes to hang silently.

After reading this tutorial, you will be able to recognize a deadlock from thread dump output, reproduce the conditions that cause it in both synchronized and ReentrantLock code, and apply prevention strategies that make deadlocks structurally impossible in a given code path. To understand the foundational threading model underlying this discussion, see Java Thread class and multithreading fundamentals and multithreading in Java.

Key Takeaways

  • Deadlock happens only when all four Coffman conditions are in place: mutual exclusion, hold and wait, no preemption, and circular wait. If you break any one of these, deadlock can’t form.
  • Both synchronized blocks and ReentrantLock are susceptible to deadlocks—the problem isn’t the syntax, it’s the way locks are taken.
  • Using tryLock() with a timeout is one way to avoid deadlocks, though it adds extra logic for retries—something you’ll want to consider based on your production needs.
  • Tools like jstack, JConsole, VisualVM, and ThreadMXBean can each detect deadlocks in their own way. Picking the right one can save you time when troubleshooting.
  • Deadlock and livelock both leave threads unable to make progress, but there’s a key difference: livelocked threads keep running and use CPU (RUNNABLE), while deadlocked threads are waiting on locks (BLOCKED).
  • If you’re using virtual threads in JDK 21+, watch for “pinning” when you use synchronized. To avoid issues, use ReentrantLock for any code that blocks within a virtual thread.

What Is a Deadlock in Java?

From the outside, a deadlock looks like a healthy process that has stopped doing anything: CPU usage drops to near zero, no new log output appears, and requests either queue up indefinitely or start timing out. There is no exception, no error message, and no signal from the JVM. The application is running; it is simply not making progress.

The minimum setup for a deadlock requires two threads and two shared resources. Thread A holds resource 1 and waits for resource 2; Thread B holds resource 2 and waits for resource 1. In production, deadlocks often involve more threads and a more complex dependency graph, which is part of why they are difficult to reproduce in a test environment: the exact interleaving of lock acquisitions must occur at the right moment.

A slow application differs from a deadlocked one in that a slow application continues to produce log output and CPU remains at least partially active. A deadlocked application produces no new log output, CPU stays flat, and jstack shows the same threads in the BLOCKED state on every successive dump. That combination, flat CPU, no log progress, and a stable BLOCKED thread set, is the diagnostic signal that separates a deadlock from ordinary slowness.

For a broader look at how Java manages concurrent access to shared state, see the thread safety in Java tutorial.

The Four Coffman Conditions for Deadlock

A deadlock can only occur when all four Coffman conditions are satisfied simultaneously. This matters for prevention: removing any single condition makes a deadlock impossible in that code path.

Mutual Exclusion

A resource can only be held by one thread at a time. synchronized blocks and ReentrantLock both enforce mutual exclusion concurrency by design. If a resource could be accessed by multiple threads concurrently without exclusive ownership, this condition would not hold and no deadlock could form.

Hold and Wait

A thread holds at least one lock while blocked waiting to acquire another. Nested synchronized blocks create this condition directly. In the three-thread example below, thread t1 holds the monitor on obj1 while it waits for the monitor on obj2.

No Preemption

The JVM cannot forcibly remove a lock from a thread. A thread holding a monitor will not give it up until it exits the synchronized block or calls wait(). There is no scheduler mechanism to reclaim a held lock on behalf of a waiting thread.

Circular Wait

Threads form a cycle in their waiting relationships. Thread t1 waits for a lock held by t2, thread t2 waits for a lock held by t3, and t3 waits for a lock held by t1. This resource allocation cycle is what makes the situation permanent: no thread in the cycle can ever acquire its needed lock because the thread holding it is itself waiting.

The table below maps each Coffman condition to specific lines in the three-thread synchronized example in the next section:

Condition Where it appears in the code
Mutual exclusion synchronized (obj1) — only one thread holds the monitor at a time
Hold and wait Thread holds obj1 monitor while blocked waiting on synchronized (obj2)
No preemption The JVM cannot revoke obj1 from t1 while t1 is executing inside the block
Circular wait t1 waits for obj2 (held by t2), t2 waits for obj3 (held by t3), t3 waits for obj1 (held by t1)

Deadlock in Java Example Using synchronized

The most direct deadlock in Java example with synchronized uses nested locks and a circular acquisition order. Three threads each acquire one object monitor and then attempt to acquire a second, forming the circular wait condition.

package com.example.threads;

public class ThreadDeadlock {

    public static void main(String[] args) throws InterruptedException {
        Object obj1 = new Object();
        Object obj2 = new Object();
        Object obj3 = new Object();

        // t1 holds obj1, wants obj2
        // t2 holds obj2, wants obj3
        // t3 holds obj3, wants obj1  <-- completes the circular wait condition
        Thread t1 = new Thread(new SyncThread(obj1, obj2), "t1");
        Thread t2 = new Thread(new SyncThread(obj2, obj3), "t2");
        Thread t3 = new Thread(new SyncThread(obj3, obj1), "t3");

        t1.start();
        Thread.sleep(5000);
        t2.start();
        Thread.sleep(5000);
        t3.start();
    }
}

class SyncThread implements Runnable {
    private final Object obj1;
    private final Object obj2;

    public SyncThread(Object o1, Object o2) {
        this.obj1 = o1;
        this.obj2 = o2;
    }

    @Override
    public void run() {
        String name = Thread.currentThread().getName();
        System.out.println(name + " acquiring lock on " + obj1);
        synchronized (obj1) {               // mutual exclusion; hold-and-wait begins here
            System.out.println(name + " acquired lock on " + obj1);
            work();
            System.out.println(name + " acquiring lock on " + obj2);
            synchronized (obj2) {           // circular wait: each thread's obj2 is held by the next thread
                System.out.println(name + " acquired lock on " + obj2);
                work();
            }
            System.out.println(name + " released lock on " + obj2);
        }
        System.out.println(name + " released lock on " + obj1);
        System.out.println(name + " finished execution.");
    }

    private void work() {
        try {
            Thread.sleep(30000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
// Expected output (program does not terminate):
t1 acquiring lock on java.lang.Object@6d9dd520
t1 acquired lock on java.lang.Object@6d9dd520
t2 acquiring lock on java.lang.Object@22aed3a5
t2 acquired lock on java.lang.Object@22aed3a5
t3 acquiring lock on java.lang.Object@218c2661
t3 acquired lock on java.lang.Object@218c2661
t1 acquiring lock on java.lang.Object@22aed3a5
t2 acquiring lock on java.lang.Object@218c2661
t3 acquiring lock on java.lang.Object@6d9dd520

After all three threads acquire their first lock, each attempts to acquire the second. Because each second lock is held by another thread, all three block indefinitely. The program never prints “finished execution.”

Reading the Thread Dump

Running jstack <pid> against the blocked process produces a thread dump. The section that identifies the deadlock looks like this:

Found one Java-level deadlock:
=============================
"t3":
  waiting to lock monitor 0x00007fb0a1074b08 (object 0x000000013df2f658, a java.lang.Object),
  which is held by "t1"
"t1":
  waiting to lock monitor 0x00007fb0a1010f08 (object 0x000000013df2f668, a java.lang.Object),
  which is held by "t2"
"t2":
  waiting to lock monitor 0x00007fb0a1012360 (object 0x000000013df2f678, a java.lang.Object),
  which is held by "t3"

Found 1 deadlock.

The key fields are waiting to lock and which is held by. Each entry names both the blocked thread and the thread holding the lock it needs. Following the chain reveals the cycle: t1 waits on t2, t2 waits on t3, t3 waits on t1. Every thread in the cycle shows BLOCKED (on object monitor) in its state line.

Deadlock Example Using ReentrantLock

ReentrantLock from java.util.concurrent.locks provides the same mutual exclusion guarantee as synchronized but requires explicit lock() and unlock() calls. The explicit calls make the lock acquisition order visible in code, which helps reviewers spot unsafe ordering, but visibility alone does not prevent a deadlock. If two threads acquire the same pair of locks in opposite orders, the deadlock forms regardless.

Prefer ReentrantLock over synchronized when you need timed lock attempts (tryLock), interruptible acquisition (lockInterruptibly), or multiple condition queues (newCondition()). For straightforward mutual exclusion, synchronized is simpler and carries less risk of forgetting an unlock() call.

Deadlock Scenario with ReentrantLock

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockDeadlock {

    private static final ReentrantLock lockA = new ReentrantLock();
    private static final ReentrantLock lockB = new ReentrantLock();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            lockA.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " acquired lockA");
                sleep(100);
                lockB.lock();       // blocks here indefinitely: Thread-2 holds lockB
                try {
                    System.out.println(Thread.currentThread().getName() + " acquired lockB");
                } finally {
                    lockB.unlock();
                }
            } finally {
                lockA.unlock();
            }
        }, "Thread-1");

        Thread t2 = new Thread(() -> {
            lockB.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " acquired lockB");
                sleep(100);
                lockA.lock();       // blocks here indefinitely: Thread-1 holds lockA
                try {
                    System.out.println(Thread.currentThread().getName() + " acquired lockA");
                } finally {
                    lockA.unlock();
                }
            } finally {
                lockB.unlock();
            }
        }, "Thread-2");

        t1.start();
        t2.start();
    }

    private static void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
// Expected output (program does not terminate):
Thread-1 acquired lockA
Thread-2 acquired lockB

Thread-1 and Thread-2 each acquire their first lock, then block waiting for the lock the other thread holds. Because ReentrantLock.lock() waits indefinitely, both threads wait forever.

Corrected Version Using tryLock() with Timeout

tryLock(timeout, unit) attempts to acquire the lock and returns false if it is not available within the timeout, rather than blocking indefinitely. This breaks the hold-and-wait condition: a thread that cannot acquire the second lock releases the first lock and retries, so neither thread holds a lock while waiting forever.

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

public class ReentrantLockTryLock {

    private static final ReentrantLock lockA = new ReentrantLock();
    private static final ReentrantLock lockB = new ReentrantLock();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> acquireLocks("Thread-1", lockA, lockB), "Thread-1");
        Thread t2 = new Thread(() -> acquireLocks("Thread-2", lockB, lockA), "Thread-2");
        t1.start();
        t2.start();
    }

    private static void acquireLocks(String name, ReentrantLock first, ReentrantLock second) {
        while (true) {
            boolean gotFirst  = false;
            boolean gotSecond = false;
            try {
                gotFirst  = first.tryLock(50, TimeUnit.MILLISECONDS);
                gotSecond = second.tryLock(50, TimeUnit.MILLISECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }

            if (gotFirst && gotSecond) {
                try {
                    System.out.println(name + " acquired both locks, doing work");
                    Thread.sleep(10);
                    return;
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    second.unlock();
                    first.unlock();
                }
            } else {
                if (gotFirst)  first.unlock();
                if (gotSecond) second.unlock();
                System.out.println(name + " could not acquire both locks, retrying");
                sleep(10);
            }
        }
    }

    private static void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
// Expected output (order varies by scheduler; program terminates):
Thread-1 could not acquire both locks, retrying
Thread-2 acquired both locks, doing work
Thread-1 acquired both locks, doing work

Both threads complete. The exact output order varies between runs because the scheduler determines which thread wins each retry cycle.

Production trade-off: tryLock() with a timeout prevents deadlock but introduces retry logic that adds complexity. You must choose a timeout that is long enough to allow forward progress under load, but short enough to recover quickly from contention. Under extreme contention, both threads may keep timing out on the same cycle, which is a form of livelock (covered in the comparison section below). Combine tryLock() with a small randomized retry delay or exponential back-off to reduce the chance of synchronized timeouts.

For a deeper look at these design patterns, see Java concurrency interview guide.

How to Detect a Deadlock in Java

Detecting a deadlock requires a tool that can inspect the JVM’s thread state while the application is running, or code that queries the thread management API at runtime.

jstack

jstack is a command-line utility included in the JDK. It attaches to a running JVM process by PID and prints a full thread dump to stdout.

jstack <pid>
// Expected output (relevant section at end of dump):
Found one Java-level deadlock:
=============================
"t3":
  waiting to lock monitor 0x00007fb0...658 (a java.lang.Object),
  which is held by "t1"
...
Found 1 deadlock.

Scroll to the end of the output and look for the Found N Java-level deadlock block. Each entry identifies the blocked thread, the lock address it is waiting for, and the thread currently holding that lock. Every thread in the cycle shows BLOCKED (on object monitor) in its state line. Reading each which is held by chain in a loop reveals the complete circular wait.

jstack detects deadlocks on both synchronized monitors and java.util.concurrent locks that extend AbstractOwnableSynchronizer (which includes ReentrantLock). It requires no code changes to the application.

JConsole

JConsole is a graphical JMX monitoring tool included with the JDK. To detect a deadlock using JConsole:

  1. Start JConsole from a terminal: jconsole
  2. Connect to the running Java process from the local process list.
  3. Open the Threads tab.
  4. Click Detect Deadlock at the bottom of the panel.
Name: Thread-1
State: BLOCKED
Blocked on: java.lang.Object@6d9dd520 (owned by Thread-2)

Name: Thread-2
State: BLOCKED
Blocked on: java.lang.Object@22aed3a5 (owned by Thread-1)

Deadlock detected.

This panel maps directly to the waiting to lock and which is held by fields in a jstack dump, so both tools produce equivalent diagnostic information through different interfaces.

If a deadlock exists, JConsole highlights the involved threads in red and shows each thread’s stack trace alongside the lock it is waiting for and the name of the thread holding it. The report is equivalent to the jstack deadlock section but presented as a visual list, which makes it useful when you need to navigate between multiple blocked threads quickly without parsing raw text.

VisualVM

VisualVM provides a thread timeline that makes deadlock patterns visible at a glance. After connecting to the process:

  1. Open the Threads tab.
  2. Look for threads displayed in red (blocked state). In a three-thread deadlock, three red bars appear simultaneously and stay red with no forward movement.
  3. Click any blocked thread to see its stack trace and the lock it is waiting on.
  4. VisualVM also displays a Deadlock detected! notification banner in the threads view when it identifies a cycle automatically.
Thread-1  [BLOCKED]  waiting for lock held by Thread-2
Thread-2  [BLOCKED]  waiting for lock held by Thread-1

Deadlock detected!

VisualVM’s thread timeline shows the moment the threads entered the BLOCKED state, which helps identify whether the deadlock is persistent or intermittent.

The thread timeline is particularly useful for distinguishing a deadlock (threads permanently red) from temporary lock contention (threads briefly red and then returning to green).

ThreadMXBean: Programmatic Detection

ThreadMXBean from java.lang.management lets you detect deadlocks from inside the running application itself. This is useful for monitoring endpoints, background watchdog threads, or automated health checks in long-running services.

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;

public class DeadlockDetector {

    public static void main(String[] args) throws InterruptedException {
        Object lockA = new Object();
        Object lockB = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (lockA) {
                System.out.println("Thread-1 acquired lockA");
                sleep(100);
                synchronized (lockB) {
                    System.out.println("Thread-1 acquired lockB");
                }
            }
        }, "Thread-1");

        Thread t2 = new Thread(() -> {
            synchronized (lockB) {
                System.out.println("Thread-2 acquired lockB");
                sleep(100);
                synchronized (lockA) {
                    System.out.println("Thread-2 acquired lockA");
                }
            }
        }, "Thread-2");

        t1.start();
        t2.start();
        Thread.sleep(500);
        detectDeadlock();
    }

    private static void detectDeadlock() {
        ThreadMXBean bean = ManagementFactory.getThreadMXBean();
        long[] deadlockedIds = bean.findDeadlockedThreads();

        if (deadlockedIds == null) {
            System.out.println("No deadlock detected.");
            return;
        }

        ThreadInfo[] infos = bean.getThreadInfo(deadlockedIds, true, true);
        System.out.println("Deadlock detected! Threads involved:");
        for (ThreadInfo info : infos) {
            System.out.printf(
                "  Thread '%s' (id=%d) is waiting on a lock held by '%s'%n",
                info.getThreadName(),
                info.getThreadId(),
                info.getLockOwnerName()
            );
        }
    }

    private static void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
// Expected output:
Thread-1 acquired lockA
Thread-2 acquired lockB
Deadlock detected! Threads involved:
  Thread 'Thread-1' (id=12) is waiting on a lock held by 'Thread-2'
  Thread 'Thread-2' (id=13) is waiting on a lock held by 'Thread-1'

findDeadlockedThreads() returns the IDs of threads in a deadlock cycle, or null when no deadlock exists. Passing those IDs to getThreadInfo() retrieves the stack traces and lock ownership details needed to identify the source code location responsible.

Use findDeadlockedThreads() rather than findMonitorDeadlockedThreads(). The former detects cycles involving both synchronized monitors and java.util.concurrent locks; the latter only detects synchronized monitor deadlocks.

fastThread.io

If you have a thread dump file saved from a previous incident (produced by jstack, a kill signal, or a heap dump tool) and want a visual analysis, fastThread.io is a free online thread dump analyzer. Upload the dump file and it presents the deadlock cycle, affected threads, and lock ownership as a diagram with call-graph navigation. This is well-suited to post-incident analysis when the process has already been restarted and you are working from a saved file rather than a live JVM.

What to Do After You Confirm a Deadlock

Confirming a deadlock is the start of recovery, not the end. Before restarting the process, capture the full thread dump by running jstack against the PID or by collecting the output of findDeadlockedThreads() if your watchdog is already running. This dump contains the complete lock ownership chain: which thread holds which lock and which threads are blocked waiting for it. Once the process restarts, this evidence is gone, and post-mortem analysis without it amounts to guessing at the lock ordering that produced the cycle.

After capturing the dump, restart the process or kill and respawn the specific threads if your runtime supports it. The JVM provides no built-in mechanism to recover from a deadlock: once threads are permanently blocked, they cannot be unblocked without termination. Restarting restores service, but it does not fix the root cause, so the deadlock will recur under the same load conditions if the underlying lock ordering problem is not addressed.

The production-grade response to a recurring deadlock is a ThreadMXBean watchdog: a background thread that calls findDeadlockedThreads() on a fixed schedule (every 10 to 30 seconds is typical), logs the thread names and the full lock chain when a deadlock is detected, and triggers either a graceful shutdown or an alert to your on-call system. This gives you automated recovery and a preserved diagnostic record before the process exits, which is what you need to fix the root cause rather than restart around it indefinitely.

How to Prevent Deadlock in Java

Prevention works by making at least one Coffman condition impossible to satisfy. Each strategy below targets a specific condition, with an explanation of why it works.

Avoid Nested Locks

Acquiring more than one lock at a time creates the hold-and-wait condition. Eliminating nested lock acquisition removes the deadlock risk entirely for that code path.

The refactored run() method below releases obj1 before acquiring obj2, so no thread ever holds both simultaneously:

public void run() {
    String name = Thread.currentThread().getName();
    System.out.println(name + " acquiring lock on " + obj1);
    synchronized (obj1) {
        System.out.println(name + " acquired lock on " + obj1);
        work();
    }
    System.out.println(name + " released lock on " + obj1);
    System.out.println(name + " acquiring lock on " + obj2);
    synchronized (obj2) {
        System.out.println(name + " acquired lock on " + obj2);
        work();
    }
    System.out.println(name + " released lock on " + obj2);
    System.out.println(name + " finished execution.");
}
// Expected output (program terminates normally):
t1 acquiring lock on java.lang.Object@6d9dd520
t1 acquired lock on java.lang.Object@6d9dd520
t2 acquiring lock on java.lang.Object@22aed3a5
t2 acquired lock on java.lang.Object@22aed3a5
t3 acquiring lock on java.lang.Object@218c2661
t3 acquired lock on java.lang.Object@218c2661
t1 released lock on java.lang.Object@6d9dd520
t1 acquiring lock on java.lang.Object@22aed3a5
...
t1 finished execution.
t2 finished execution.
t3 finished execution.

Why this works: a thread holding only one lock at a time cannot be part of a circular wait, because it has nothing to offer to another waiting thread in the cycle.

Lock Only What Is Required

Acquiring a lock on a large object when you only need one field forces other threads to wait for data they do not actually need to access. Narrow the lock scope to the minimum resource needed. For thread synchronization in Java, this means locking on a dedicated private lock object or a narrowly scoped field rather than this or a broad shared object.

When you synchronize on this, any thread that needs any part of that object must wait, even if two threads are updating entirely different fields. Locking a narrowly scoped private field or a dedicated lock object means fewer threads compete for the same monitor, which reduces the number of code paths where a circular dependency can form.

The before example synchronizes on the entire BankAccount object:

public class BankAccount {
    private int balance;

    // Locks the entire BankAccount object while updating only the balance field.
    // Any other synchronized method on this instance must wait, even if it
    // accesses a completely different field.
    public synchronized void deposit(int amount) {
        balance += amount;
    }
}
// Expected behavior: every synchronized method on this BankAccount instance
// serializes on the same monitor, including methods that touch unrelated fields.

The after example locks only the field that actually needs protection:

public class BankAccount {
    private int balance;
    private final Object balanceLock = new Object(); // dedicated lock for balance only

    public void deposit(int amount) {
        synchronized (balanceLock) {   // only the balance field is protected
            balance += amount;
        }
    }
}
// Two threads updating balance concurrently: only the balance
// field is locked. Other fields on the same BankAccount object
// remain accessible to other threads without contention.

Why this works: narrower locks reduce contention and lower the probability that two threads ever need overlapping sets of locks.

Avoid Indefinite Waits

Using the wait() and notify() methods in Java requires care about termination. If a thread calls wait() inside a synchronized block and the thread responsible for calling notify() has already run (or never runs due to a bug), the waiting thread blocks forever while still holding the monitor.

The example below shows a missed-notify race: the notifier sets the flag and calls notify() before the waiter ever enters the synchronized block. When the waiter then calls wait(), there is no pending notification and it blocks indefinitely.

public class MissedNotifyExample {

    static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread notifier = new Thread(() -> {
            synchronized (lock) {
                System.out.println("Notifier: calling notify()");
                lock.notify();      // no thread is waiting yet; the signal is lost
            }
        });

        Thread waiter = new Thread(() -> {
            synchronized (lock) {
                try {
                    System.out.println("Waiter: calling wait()");
                    lock.wait();    // notify() already fired; this waits forever
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                System.out.println("Waiter: woke up"); // never reached
            }
        });

        notifier.start();
        notifier.join();    // ensure notify() fires before waiter starts
        waiter.start();     // too late: the notify() is already gone
    }
}
// Expected behavior: notify() fired before wait(); the waiter blocks
// indefinitely. No exception is thrown. The program does not terminate.
Notifier: calling notify()
Waiter: calling wait()

The same risk applies to Thread.join() without a timeout. If the joined thread never finishes, the calling thread waits forever. Always pass a maximum wait time and check isAlive() afterward to determine whether the join completed normally or the timeout expired:

public class JoinWithTimeoutExample {

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            System.out.println("Worker: started");
            try {
                Thread.sleep(2000); // simulate work that finishes in 2 seconds
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            System.out.println("Worker: finished");
        });

        worker.start();
        worker.join(5000); // wait at most 5 seconds

        // join() returns in both cases and throws no exception.
        // isAlive() tells you which case occurred.
        if (worker.isAlive()) {
            System.out.println("Main: worker still running after timeout, continuing anyway");
        } else {
            System.out.println("Main: worker finished before timeout");
        }
    }
}
// Expected output (worker finishes within the 5-second timeout):
Worker: started
Worker: finished
Main: worker finished before timeout

// If the worker runs longer than 5 seconds, join() returns after the timeout
// and isAlive() returns true:
// Main: worker still running after timeout, continuing anyway

Why this works: a bounded wait prevents a thread from holding a lock indefinitely against a condition that may never be signaled.

Enforce a Consistent Lock Ordering

When acquiring multiple locks is unavoidable, establish a global ordering and require all threads to acquire locks in the same order. If every thread acquires lockA before lockB, the circular wait condition cannot form, because no thread ever holds lockB while waiting for lockA.

Why this works: a consistent ordering removes the cycle from the wait-for graph entirely, even when nested locking is necessary.

For a broader look at how race condition prevention in Java fits into a well-structured concurrency design, see the thread safety in Java tutorial.

Deadlock vs. Livelock: Key Differences

The key distinction between a livelock and a deadlock is thread state: in a livelock, threads are RUNNABLE and consuming CPU; in a deadlock, they are BLOCKED and consuming none. In a livelock, threads are actively executing and responding to each other’s state changes. They keep changing their own state in reaction to what the other thread is doing, but those reactions cancel each other out and no work is completed. The threads are not blocked; they are busy doing nothing.

The example below uses two Worker objects, each with an active flag. When a worker detects that the other is active, it sets its own flag to inactive, waits briefly, then sets itself back to active and retries. Because both workers mirror this behavior exactly and both reactivate at the same time, neither makes progress. An iteration cap of six is included so the program terminates and the pattern is observable; without the cap the loop never exits.

public class LivelockExample {

    static class Worker {
        private volatile boolean active = true;
        private final String name;

        Worker(String name) { this.name = name; }

        void work(Worker other) {
            int count = 0;
            // Without this cap the loop never exits: every time this worker yields,
            // the other has already reactivated and blocks progress again.
            while (count < 6) {
                if (other.active) {
                    System.out.println(name + ": other worker is active, stepping aside");
                    this.active = false;
                    sleep(50);
                    this.active = true;
                    count++;
                } else {
                    System.out.println(name + ": path is clear, proceeding");
                    return;
                }
            }
            System.out.println(name + ": gave up after " + count + " attempts");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Worker w1 = new Worker("Worker-1");
        Worker w2 = new Worker("Worker-2");

        Thread t1 = new Thread(() -> w1.work(w2), "t1");
        Thread t2 = new Thread(() -> w2.work(w1), "t2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}
// Expected output (livelock: threads keep yielding to each other;
// ordering varies by scheduler):
Worker-1: other worker is active, stepping aside
Worker-2: other worker is active, stepping aside
Worker-1: other worker is active, stepping aside
Worker-2: other worker is active, stepping aside
Worker-1: other worker is active, stepping aside
Worker-2: other worker is active, stepping aside
...
Worker-1: gave up after 6 attempts
Worker-2: gave up after 6 attempts

Unlike a deadlock, both threads are RUNNABLE and consuming CPU throughout the livelock; neither is BLOCKED waiting on a monitor.

Deadlock Livelock
Definition Threads permanently blocked, each waiting for a lock held by another thread Threads actively running but making no progress; each reacts to the other’s state
Thread state BLOCKED RUNNABLE
CPU usage Near zero (threads are not executing) High (threads execute continuously but do no useful work)
Detection method jstack, JConsole, VisualVM, ThreadMXBean.findDeadlockedThreads() No JVM-level detector; requires profiling or manual analysis of thread activity
Resolution strategy Break one of the four Coffman conditions Add randomness or exponential back-off to break the synchronized-response pattern

Deadlock in Java and Project Loom

Virtual threads in JDK 21 introduce a failure mode called pinning that can exhaust the carrier thread pool and halt the application without producing a blocked-thread report from jstack. Virtual threads are lightweight, JVM-managed threads that normally unmount from their underlying platform thread (the carrier thread) when they block on I/O or a lock, freeing the carrier to run other virtual threads. Pinning breaks this: when a virtual thread holds a synchronized monitor, it stays mounted on its carrier and cannot yield, keeping the carrier unavailable to other virtual threads until the lock is released.

Virtual Thread Pinning

A virtual thread is pinned to its carrier thread when it holds a monitor acquired with synchronized. While pinned, the carrier thread cannot be reassigned to other virtual threads. If many virtual threads are pinned simultaneously, all carrier threads can become occupied by threads waiting inside synchronized blocks, and no new virtual threads can run. This has the same external symptom as a deadlock: the application stops making progress and stops accepting new work, with no obvious exception in the logs.

This pinning behavior means a synchronized block that is harmless in traditional-thread code can become a hard availability problem in a virtual thread context if the locked section contains any blocking operation.

Use ReentrantLock in Virtual Thread Code

For code that runs on virtual threads and contains blocking calls (network I/O, database access, Thread.sleep(), or any wait()), replace synchronized with ReentrantLock. ReentrantLock does not pin the virtual thread to its carrier; the virtual thread unmounts normally while waiting for the lock.

import java.util.concurrent.locks.ReentrantLock;

public class VirtualThreadSafeService {

    private final ReentrantLock lock = new ReentrantLock();

    public void doBlockingWork() {
        lock.lock();
        try {
            // Blocking I/O here is safe: virtual thread unmounts from carrier
            // while waiting. Replace this comment with the actual blocking call.
            performNetworkCall();
        } finally {
            lock.unlock();
        }
    }

    private void performNetworkCall() {
        // placeholder for blocking I/O
    }
}
// Expected behavior: virtual thread unmounts from carrier while waiting on
// the lock or during performNetworkCall(); no carrier thread pinning occurs.
// The carrier thread is free to run other virtual threads during the wait.

Do not use synchronized to guard blocking operations in code that runs on virtual threads. The pinning behavior can exhaust the carrier thread pool and cause the application to stop accepting new work, producing the same external effect as a deadlock but with no blocked-thread report from jstack.

Detecting Pinning

The JVM flag -Djdk.tracePinnedThreads=full prints a stack trace to stdout whenever a virtual thread is pinned to its carrier. Use this during development and load testing to find synchronized blocks that need to be converted.

java -Djdk.tracePinnedThreads=full -jar your-application.jar
// Expected output when pinning is detected:
Thread[#23,ForkJoinPool-1-worker-1,5,CarrierThreads]
    com.example.VirtualThreadSafeService.doBlockingWork(VirtualThreadSafeService.java:9) <== monitors:1

The frame marked <== monitors:1 identifies the synchronized block causing the pin. Refactor that specific block to use ReentrantLock to resolve it.

FAQ

What are the four conditions of deadlock in Java?

The four conditions are mutual exclusion (only one thread holds a resource at a time), hold and wait (a thread holds one lock while waiting for another), no preemption (the JVM cannot forcibly take a lock from a thread), and circular wait (threads form a cycle in their waiting relationships). All four must be present simultaneously for a deadlock to occur. Removing any one of them prevents the deadlock.

What is a simple example of a deadlock in Java?

Two threads, each holding one synchronized lock and trying to acquire the other thread’s lock, produce the simplest deadlock in Java. Thread-1 acquires lockA and waits for lockB; Thread-2 acquires lockB and waits for lockA. Neither can proceed, so the program hangs indefinitely.

How do you resolve a deadlock in Java?

You cannot resolve a deadlock in a running program without terminating one of the involved threads. The correct approach is prevention: design the locking strategy so that at least one Coffman condition cannot be satisfied. Use consistent lock ordering, avoid nested lock acquisition where possible, or use tryLock() with a timeout so that threads back off and retry instead of blocking forever.

What are the four ways to handle deadlock?

The four approaches are: prevention (design code so a Coffman condition cannot be met), avoidance (use algorithms such as the Banker’s algorithm to refuse resource allocations that could lead to deadlock), detection and recovery (detect the cycle at runtime using ThreadMXBean and terminate one thread to break it), and ignorance (accept that deadlocks are rare enough in your system to handle by restarting the process). Production Java applications most commonly use prevention and, in systems where deadlock risk cannot be fully eliminated, detection with automated recovery.

What is the difference between deadlock and livelock in Java?

In a deadlock, threads are in the BLOCKED state and consuming no CPU. In a livelock, threads are in the RUNNABLE state and consuming CPU continuously, but they keep reacting to each other in a way that prevents any work from being completed. Both conditions produce the same external symptom (no progress), but they have different causes, different thread states, and different detection methods.

How do I detect a deadlock in a running Java application?

Run jstack <pid> and look for the Found N Java-level deadlock block at the end of the output. From JConsole, go to the Threads tab and click Detect Deadlock. In VisualVM, open the Threads tab and look for the deadlock notification banner and red thread bars. Programmatically, call ManagementFactory.getThreadMXBean().findDeadlockedThreads() and check for a non-null return value.

Does ReentrantLock prevent deadlock in Java?

No. ReentrantLock can produce deadlocks through the same mechanism as synchronized: two threads acquiring the same two locks in opposite orders. What ReentrantLock provides is tryLock(), which lets you attempt lock acquisition with a timeout and back off when the attempt fails. That is a tool for deadlock prevention, but it requires deliberate use. Simply switching from synchronized to ReentrantLock does not remove deadlock risk.

How do virtual threads in Project Loom affect deadlock risk?

Virtual threads introduce a failure mode called pinning: when a virtual thread holds a synchronized monitor, it cannot unmount from its carrier thread. If enough virtual threads are pinned simultaneously, all carrier threads become occupied and no new virtual threads can run, which has the same external effect as a deadlock. Use ReentrantLock instead of synchronized for blocking sections in virtual thread code, and use -Djdk.tracePinnedThreads=full during development to locate pinning points.

Conclusion

Deadlocks are preventable by design, not just detectable after the fact. The four Coffman conditions are the exact mechanism: because a deadlock requires all four to hold simultaneously, removing any single one from a code path makes that path structurally deadlock-free. The detection tools in this article give you a way to confirm a live deadlock and preserve the evidence before restarting, but the real goal is a locking design where those tools are rarely needed.

Working through all three layers, understanding which Coffman condition your locking strategy satisfies, diagnosing a deadlock from a thread dump when one appears, and applying a prevention strategy that eliminates the condition, moves you from reactive debugging to deliberate concurrency design. For readers who want to go deeper on Java concurrency beyond deadlocks, the Java concurrency interview guide tutorial covers the broader set of concurrency patterns, trade-offs, and design decisions that come up in production Java systems.

Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

Learn more about our products

About the author(s)

Pankaj Kumar
Pankaj Kumar
Author
See author profile

Java and Python Developer for 20+ years, Open Source Enthusiast, Founder of https://www.askpython.com/, https://www.linuxfordevices.com/, and JournalDev.com (acquired by DigitalOcean). Passionate about writing technical articles and sharing knowledge with others. Love Java, Python, Unix and related technologies. Follow my X @PankajWebDev

Vinayak Baranwal
Vinayak Baranwal
Editor
Technical Writer II
See author profile

Building future-ready infrastructure with Linux, Cloud, and DevOps. Full Stack Developer & System Administrator. Technical Writer @ DigitalOcean | GitHub Contributor | Passionate about Docker, PostgreSQL, and Open Source | Exploring NLP & AI-TensorFlow | Nailed over 50+ deployments across production environments.

Category:
Tags:
While we believe that this content benefits our community, we have not yet thoroughly reviewed it. If you have any suggestions for improvements, please let us know by clicking the “report an issue“ button at the bottom of the tutorial.

Still looking for an answer?

Was this helpful?

Thanks for the article … Helped me lot to improve my understanding.

- santosk1902

If you desire to increase your experience simply keep visiting this site and be updated with the latest news update posted here.

- {lawrence crane enterprises

Thanks for this informative blog, It is really helpful to understand deadlock situation,

- Muhammad Asif

Very nicely explained. Thanks.

- Rohit K

The suggestion of using a thread dump is awesome.

- Mudassir Shahzad

It’s very useful for me

- Prabha

i guest you can lock all resource without any problem, and it’s possible if all thread lock resource in same order.

- redouane

sir you were talking about deadlock in servlet.But in your example there is no any servlet. Give a deadlock example with servlet.

- Mukesh Verma

Very nice explanation Pankaj… Very useful and easy to understand…

- Pramod Bablad

Good explaination. Can you please explain how “Lock Only What is Required” avoid deadlock with example ?

- Kapil Agarwal

Creative CommonsThis work is licensed under a Creative Commons Attribution-NonCommercial- ShareAlike 4.0 International License.
Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

The developer cloud

Scale up as you grow — whether you're running one virtual machine or ten thousand.

Get started for free

Sign up and get $200 in credit for your first 60 days with DigitalOcean.*

*This promotional offer applies to new accounts only.