Sitemap

Deadlocks and Concurrency Failures in Java

12 min readOct 30, 2026

--

How threads stop making progress, how to prove it with a thread dump, and how to prevent it

Concurrency lets multiple threads run at the same time, which improves responsiveness and resource utilization. It also creates failures that are hard to diagnose, especially when threads quietly stop making progress.

The best-known of these is the deadlock: application operations hang indefinitely even though the JVM is still running.

In this article, we’ll look at deadlocks, livelocks, starvation, thread states, and thread-pool exhaustion. We’ll reproduce them, diagnose them with JVM tools, and prevent them. Every example below was compiled and run on JDK 21, and the thread-dump output is real.

1. Deadlock, Livelock, and Starvation

All three can make an application unresponsive, but their causes are different.

Deadlock

A deadlock occurs when two or more threads wait indefinitely for resources held by one another. Consider two threads and two locks:

  • Thread A acquires Lock 1 and waits for Lock 2.
  • Thread B acquires Lock 2 and waits for Lock 1.

Thread A can’t release Lock 1 because it’s waiting for Lock 2, and Thread B can’t release Lock 2 because it’s waiting for Lock 1. This circular dependency leaves both threads blocked forever.

Livelock

In a livelock, threads are not blocked. They keep running and reacting to each other without making meaningful progress.

Imagine two threads competing for the same resources. Whenever one detects that the other is trying to proceed, it backs off, and the other does the same. Both keep retrying, and neither finishes. Unlike a deadlock, the threads stay active and keep consuming CPU.

Starvation

Starvation occurs when a thread is repeatedly denied resources or CPU time because other threads keep getting there first. For example, a thread tries to acquire a lock, but other threads keep grabbing it before it gets a chance. Common causes:

  • Unfair lock acquisition.
  • Long-running operations that hold locks.
  • High-priority threads monopolizing the CPU.
  • Thread pools occupied by long-running tasks.

Quick comparison

  • Deadlock: threads wait on a circular dependency. No progress.
  • Livelock: threads keep retrying or reacting. No meaningful progress.
  • Starvation: one thread is repeatedly denied resources. Other threads may still progress.

Knowing the difference stops you from treating every unresponsive thread as a deadlock.

2. The Four Conditions for Deadlock

The Coffman conditions are four conditions that must all hold at the same time for a conventional resource deadlock to occur.

1. Mutual exclusion

A resource can be held by only one thread at a time. Java’s intrinsic locks, used by synchronized, allow only one thread to own an object's monitor at a time.

2. Hold and wait

A thread holds one resource while waiting to acquire another.

synchronized (lockA) {
synchronized (lockB) {
// Critical section
}
}

If another thread holds lockB, the current thread waits while still holding lockA.

3. No preemption

A resource can’t be forcibly taken from the thread holding it. With intrinsic locks, the owning thread must release the monitor itself, usually by leaving the synchronized block or method.

4. Circular wait

A chain of threads exists in which each thread waits for a resource held by the next one in the chain, for example Thread A holds Lock A and waits for Lock B, while Thread B holds Lock B and waits for Lock A.

Key takeaway: break any one of the four conditions and that deadlock can’t happen. In Java applications, consistent lock ordering is the most practical way to break circular wait.

3. Reproducing a Deadlock in Java

Let’s write a program that deadlocks on purpose.

public class DeadlockDemo {
    private static final Object LOCK_A = new Object();
private static final Object LOCK_B = new Object();
public static void main(String[] args) { Thread threadA = new Thread(() -> {
synchronized (LOCK_A) {
System.out.println("Thread A acquired LOCK_A");
sleep(100);
System.out.println("Thread A waiting for LOCK_B");
synchronized (LOCK_B) {
System.out.println("Thread A acquired LOCK_B");
}
}
}, "Thread-A");
Thread threadB = new Thread(() -> {
synchronized (LOCK_B) {
System.out.println("Thread B acquired LOCK_B");
sleep(100);
System.out.println("Thread B waiting for LOCK_A");
synchronized (LOCK_A) {
System.out.println("Thread B acquired LOCK_A");
}
}
}, "Thread-B");
threadA.start();
threadB.start();
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

Why does this program deadlock?

The two threads acquire the locks in opposite orders. Thread A takes LOCK_A and then waits for LOCK_B, which Thread B owns. Thread B takes LOCK_B and then waits for LOCK_A, which Thread A owns. Each thread is waiting for the other.

The Thread.sleep(100) call makes this interleaving likely, but it doesn't guarantee a deadlock on every run.

Making it deterministic

To get a deadlock every time, use a CountDownLatch so that neither thread asks for its second lock until both hold their first one.

import java.util.concurrent.CountDownLatch;
public class DeterministicDeadlockDemo {    private static final Object LOCK_A = new Object();
private static final Object LOCK_B = new Object();
// Each thread counts down after taking its first lock, then waits for the other.
private static final CountDownLatch BOTH_HOLD_FIRST_LOCK = new CountDownLatch(2);
public static void main(String[] args) {
new Thread(() -> lockBoth(LOCK_A, LOCK_B), "Thread-A").start();
new Thread(() -> lockBoth(LOCK_B, LOCK_A), "Thread-B").start();
}
private static void lockBoth(Object first, Object second) {
String name = Thread.currentThread().getName();
synchronized (first) {
System.out.println(name + " acquired its first lock");
BOTH_HOLD_FIRST_LOCK.countDown();
awaitQuietly(BOTH_HOLD_FIRST_LOCK);
System.out.println(name + " waiting for its second lock");
synchronized (second) {
System.out.println(name + " acquired both locks"); // never printed
}
}
}
private static void awaitQuietly(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

4. Diagnosing Deadlocks with Thread Dumps

A thread dump captures the state and stack trace of every thread in a JVM. It’s one of the most useful tools for investigating concurrency problems in production, because it answers:

  • Which threads are blocked?
  • What code are they running?
  • Which lock is each thread waiting for, and which thread owns it?
  • Is there a circular dependency?

Step 1: Run the application

javac DeterministicDeadlockDemo.java
java DeterministicDeadlockDemo

Leave the process running after the output stops.

Step 2: Find the process ID

jps -l

Example output:

21580 DeterministicDeadlockDemo

Step 3: Capture the thread dump

jstack -l 21580 > thread-dump.txt

Or use jcmd, which is the tool the JDK now recommends for diagnostics:

jcmd 21580 Thread.print -l

Step 4: Read the thread dump

Here is the entry for Thread-A from a real JDK 21 dump (addresses will differ on your machine):

"Thread-A" #29 prio=5 os_prio=0 cpu=0.00ms elapsed=2.33s tid=0x000001a337d891e0 nid=23524 waiting for monitor entry
java.lang.Thread.State: BLOCKED (on object monitor)
at DeterministicDeadlockDemo.lockBoth(DeterministicDeadlockDemo.java:25)
- waiting to lock <0x0000000630280a58> (a java.lang.Object)
- locked <0x0000000630280a48> (a java.lang.Object)

Thread-A is BLOCKED. It already owns the object at ...a48 and is waiting for the one at ...a58. Thread-B's entry shows the mirror image: it owns ...a58 and waits for ...a48.

You don’t have to match the addresses by hand. HotSpot detects monitor deadlocks and prints a summary near the end of the dump:

Found one Java-level deadlock:
=============================
"Thread-A":
waiting to lock monitor 0x000001a33708c920 (object 0x0000000630280a58, a java.lang.Object),
which is held by "Thread-B"
"Thread-B":
waiting to lock monitor 0x000001a33708cb70 (object 0x0000000630280a48, a java.lang.Object),
which is held by "Thread-A"

The exact format varies between JDK versions.

Important: a thread dump is a snapshot. When debugging production issues, take several dumps a few seconds apart. If the same threads are blocked at the same places every time, the problem is persistent.

Bonus: detecting deadlocks from inside the application

The JVM exposes the same detection through ThreadMXBean, so a health check or watchdog thread can report deadlocks without anyone running jstack:

ThreadMXBean threads = ManagementFactory.getThreadMXBean();
long[] ids = threads.findDeadlockedThreads(); // null when there is no deadlock
if (ids != null) {
for (ThreadInfo info : threads.getThreadInfo(ids, true, true)) {
System.out.println(info.getThreadName() + " is waiting for " + info.getLockName()
+ " held by " + info.getLockOwnerName());
}
}

Run against the demo above, it prints:

Thread-A is waiting for java.lang.Object@50040f0c held by Thread-B
Thread-B is waiting for java.lang.Object@65b3120a held by Thread-A

findDeadlockedThreads() covers both synchronized monitors and java.util.concurrent locks such as ReentrantLock. It can't detect the thread-pool starvation deadlock in Section 8, because no lock is involved there.

5. BLOCKED, WAITING, and TIMED_WAITING

Thread states tell you what a thread is doing, which matters a lot when reading thread dumps.

BLOCKED

A thread is BLOCKED while it waits to acquire an intrinsic monitor owned by another thread:

synchronized (lock) {
performWork();
}

If another thread owns lock, the current thread waits until the monitor is free.

WAITING

A thread is WAITING when it waits indefinitely for another thread to notify or unblock it, for example in lock.wait(), thread.join(), or LockSupport.park().

Object.wait() releases the object's monitor while waiting. When the thread wakes up, it must reacquire the monitor before wait() returns, and it shows as BLOCKED while it does so.

TIMED_WAITING

A thread is TIMED_WAITING when it waits for a bounded time, for example in Thread.sleep(5000), lock.wait(5000), or thread.join(5000). It wakes when the time runs out, when it's interrupted, or when another permitted wake-up happens.

Quick comparison

  • BLOCKED: waiting to acquire an intrinsic monitor, for example entering a synchronized block.
  • WAITING: waiting indefinitely, for example wait(), join(), or park().
  • TIMED_WAITING: waiting for a set duration, for example sleep(), timed wait(), or timed join().

Important: a WAITING thread isn’t necessarily stuck, and a BLOCKED thread isn’t necessarily deadlocked. A thread waiting for a notification may be doing exactly what it should. Always check the stack trace, the lock ownership, and whether the application is making progress.

Get Karthick K’s stories in your inbox

Join Medium for free to get updates from this writer.

Also, threads waiting for a ReentrantLock show as WAITING or TIMED_WAITING rather than BLOCKED, because they park instead of entering a monitor.

6. Preventing Deadlocks with Consistent Lock Ordering

The simplest prevention is to always acquire locks in the same order. Here’s the earlier example fixed:

public class DeadlockFixed {
    private static final Object LOCK_A = new Object();
private static final Object LOCK_B = new Object();
public static void performTask() {
synchronized (LOCK_A) {
synchronized (LOCK_B) {
System.out.println(
Thread.currentThread().getName() + " acquired both locks");
// Perform the operation.
}
}
}
public static void main(String[] args) {
new Thread(DeadlockFixed::performTask, "Thread-A").start();
new Thread(DeadlockFixed::performTask, "Thread-B").start();
}
}

Both threads now take LOCK_A before LOCK_B. Whichever thread gets LOCK_A first, the other can't hold LOCK_B while waiting for LOCK_A, so circular wait is impossible.

A lock-ordering policy

In a larger application, define a global order for locks and make every code path follow it, for example:

Account Lock -> Transaction Lock -> Notification Lock

Every operation that needs more than one of these acquires them in that order. No code path should ever take the transaction lock and then the account lock. This matters most in backend services, where many methods touch shared state, caches, account records, and in-memory registries.

7. Preventing Deadlocks with ReentrantLock

ReentrantLock gives you more control over acquisition. Its tryLock() method tries to get the lock without waiting forever, and it also supports a timeout:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class TimedLockDemo {    private static final ReentrantLock LOCK_A = new ReentrantLock();
private static final ReentrantLock LOCK_B = new ReentrantLock();
public static boolean performTask() throws InterruptedException {
if (!LOCK_A.tryLock(500, TimeUnit.MILLISECONDS)) {
return false;
}
try {
if (!LOCK_B.tryLock(500, TimeUnit.MILLISECONDS)) {
return false;
}
try {
// Perform the operation.
return true;
} finally {
LOCK_B.unlock();
}
} finally {
LOCK_A.unlock();
}
}
}

Why is this useful?

  • A thread never waits forever for the second lock.
  • If the second lock times out, the first lock is released.
  • The caller can retry, return an error, or fall back.

Timeouts alone don’t guarantee a healthy design, though. Threads that keep timing out and retrying in lockstep can livelock or create heavy contention. Adding a small random back-off before retrying helps.

For acquisition that can be cancelled, use lock.lockInterruptibly(), which lets a thread abandon the attempt when it's interrupted.

Best practice: always release a ReentrantLock in a finally block.

8. Thread-Pool Starvation: Tasks Waiting on Their Own Executor

Deadlocks don’t need explicit locks. A thread pool also stops making progress when every worker is busy waiting for tasks that can’t start. Consider a single-thread executor:

import java.util.concurrent.*;
public class PoolStarvationDemo {    public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<String> outer = executor.submit(() -> {
Future<String> inner = executor.submit(() -> "Inner completed");
return inner.get(); // blocks the only worker
});
// A timeout so the demo ends instead of hanging forever.
System.out.println(outer.get(2, TimeUnit.SECONDS));
} finally {
executor.shutdownNow();
}
}
}

Running it ends with:

Exception in thread "main" java.util.concurrent.TimeoutException

What happens here?

  1. The executor has a single worker thread.
  2. The outer task occupies that worker.
  3. The outer task submits an inner task to the same executor.
  4. The outer task calls inner.get() and waits.
  5. The inner task can’t run, because the only worker is busy with the outer task.

The outer task waits for the inner task, and the inner task waits for the outer task to free the worker. That’s a thread-pool starvation deadlock. Without the timeout, the program would hang forever.

How do we fix it?

  • Don’t block on futures inside tasks running on the same constrained executor.
  • Use separate executors for independent workloads.
  • Compose dependent steps with CompletableFuture.
  • Run dependent work directly when that’s simpler.
  • Size thread pools for the actual workload.

Here is the same flow composed with CompletableFuture, still on a single-thread executor:

ExecutorService executor = Executors.newSingleThreadExecutor();
try {
CompletableFuture<String> result =
CompletableFuture.supplyAsync(() -> "Outer completed", executor)
.thenCompose(outer -> CompletableFuture.supplyAsync(
() -> outer + ", inner completed", executor));
    System.out.println(result.join()); // the main thread waits, not the pool worker
} finally {
executor.shutdown();
}

It prints Outer completed, inner completed. The outer step finishes and frees the worker before the inner step is scheduled, so no pool thread ever blocks on another task. Passing an explicit executor, rather than relying on the common pool, also keeps this workload isolated, which is what you want in production.

9. Debugging Contention with JVM Tools

Thread dumps are great for finding deadlocks. For performance problems and lock contention, reach for these as well.

jcmd

jcmd runs diagnostic commands against a live JVM:

jcmd <pid> Thread.print -l
jcmd <pid> VM.version

Java Flight Recorder (JFR)

JFR is a low-overhead profiler built into the JDK. It records thread activity, lock contention, CPU use, garbage collection, and latency.

jcmd <pid> JFR.start name=Concurrency settings=profile
jcmd <pid> JFR.dump name=Concurrency filename=concurrency.jfr

Open the recording in Java Mission Control. For contention, the jdk.JavaMonitorEnter events (time spent waiting for synchronized) and jdk.ThreadPark events (time spent waiting for java.util.concurrent locks) are the ones to look at. JFR shines when a problem is intermittent and a single thread dump won't catch it.

VisualVM and Java Mission Control

Both give you a graphical view of thread states, CPU, memory, and thread activity, and Mission Control opens JFR recordings. Neither ships with the JDK any more, so download them separately.

A practical production workflow

  1. Identify the symptom: high latency, stalled jobs, timeouts, or falling throughput.
  2. Capture thread dumps: several, a few seconds apart.
  3. Find the affected threads: persistent BLOCKED states, long waits, or the same stack traces in every dump.
  4. Inspect lock ownership: which thread owns each lock, and who’s waiting for it.
  5. Look for cycles: trace the dependencies between threads and locks.
  6. Check executor utilization: are all workers occupied by blocking tasks?
  7. Use JFR when needed: measure how often and how long threads contend.
  8. Fix and verify: reproduce the original scenario, apply the fix, and test under concurrent load.

In backend services, thread dumps also expose problems that look like deadlocks but aren’t: slow database calls, network I/O, exhausted connection pools, and long-running synchronized sections.

10. Practice Exercises

Exercise 1: Reproduce a deadlock

Create two locks and two threads, make each thread acquire the locks in the opposite order, then run the program and watch it hang.

Exercise 2: Analyze a thread dump

Find the process with jps, capture a dump with jstack -l, identify the BLOCKED threads and which thread owns each lock, and trace the cycle.

Exercise 3: Fix the deadlock

Make both threads acquire the locks in the same order, run the program repeatedly, and confirm that both threads finish every time.

Exercise 4: Reproduce thread-pool starvation

Create a single-thread executor, submit an outer task that submits an inner task and waits on it with Future.get(), and explain why the inner task never runs.

Exercise 5: Explore JVM tools

Capture a thread dump with jcmd, record a JFR session, open it in Java Mission Control, and look for contention hot spots.

11. Interview Questions

  1. What is a deadlock, and how does it occur in Java?
  2. What are the four necessary conditions for a deadlock?
  3. How does consistent lock ordering prevent circular wait?
  4. What is the difference between deadlock, livelock, and starvation?
  5. What is the difference between BLOCKED and WAITING?
  6. Can a Java application deadlock without using synchronized or any lock at all?
  7. How does ReentrantLock.tryLock() help prevent indefinite waiting?
  8. Why should unlock() always be called in a finally block?
  9. How would you investigate a deadlock in a production Java application?
  10. What are the differences between jstack, jcmd, and Java Flight Recorder?
  11. How can an application detect its own deadlocks at runtime?
  12. Why can enlarging a thread pool hide starvation without fixing the root cause?
  13. Can threads that repeatedly time out on locks end up in a livelock?

Conclusion

Concurrency failures are hard to diagnose because the application stays alive while some of its threads can’t make progress. Knowing the difference between deadlock, livelock, starvation, and thread-pool exhaustion points you to the real cause.

The practices worth remembering:

  • Follow a consistent lock order.
  • Keep critical sections small.
  • Avoid nested locks you don’t need.
  • Use timed or interruptible lock acquisition where it fits.
  • Never block on tasks that depend on the same constrained executor.
  • Learn to read thread dumps and JVM thread states.
  • Use JFR and other JVM tools to investigate contention.

Debugging concurrency isn’t just finding which thread is blocked. It’s understanding what that thread is waiting for, which thread controls that resource, and whether the application can still make progress.

--

--

0 followers

Google Certified Professional Cloud Architect | Backend Engineer | AI & Spec-Driven Development