← Back to the section

Threads in Java run in parallel, and most of the time that's a good thing. But sometimes they start getting in each other's way: they wait, mark time in place, or one thread is left forever without work. Let's look at three main scenarios.

Deadlock — mutual blocking

Deadlock (mutual blocking) is a situation where two or more threads wait for each other and none of them can continue. The program doesn't crash with an exception — it simply hangs.

A classic example: thread A holds lock 1 and wants lock 2, thread B holds lock 2 and wants lock 1. Both wait — forever.

Object lock1 = new Object();
Object lock2 = new Object();

Thread threadA = new Thread(() -> {
    synchronized (lock1) {
        // Thread A has grabbed lock1, now it wants lock2
        synchronized (lock2) {
            System.out.println("Thread A is working");
        }
    }
});

Thread threadB = new Thread(() -> {
    synchronized (lock2) {
        // Thread B has grabbed lock2, now it wants lock1
        synchronized (lock1) {
            System.out.println("Thread B is working");
        }
    }
});

threadA.start();
threadB.start();
// Both threads will wait forever

The four Coffman conditions

A deadlock arises only when four conditions hold at the same time — conditions formulated by Edward Coffman in 1971:

  1. Mutual exclusion — a resource can be held by only one thread at a time.
  2. Hold and wait — a thread holds resources it has already acquired while waiting for new ones.
  3. No preemption — a resource cannot be taken away from a thread by force; the thread must release it itself.
  4. Circular wait — there is a chain of threads where each one waits for a resource held by the next.

Short formula: deadlock = all four conditions at once.

To prevent a deadlock, it's enough to break at least one of them.

How to avoid deadlock

Approach 1 — a fixed lock acquisition order

The simplest trick: always acquire multiple locks in the same order. If both threads first take lock1 and then lock2, circular wait is impossible.

// Both threads acquire the locks in the same order: lock1 → lock2
Thread threadA = new Thread(() -> {
    synchronized (lock1) {
        synchronized (lock2) {
            System.out.println("Thread A");
        }
    }
});

Thread threadB = new Thread(() -> {
    synchronized (lock1) { // also start with lock1
        synchronized (lock2) {
            System.out.println("Thread B");
        }
    }
});

Approach 2 — tryLock with a timeout

ReentrantLock lets you attempt to acquire a lock and back off if you didn't succeed within the allotted time. This breaks the "hold and wait" condition: the thread releases what it holds and retries later.

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

ReentrantLock lock1 = new ReentrantLock();
ReentrantLock lock2 = new ReentrantLock();

boolean performOperation() throws InterruptedException {
    boolean acquired1 = lock1.tryLock(100, TimeUnit.MILLISECONDS);
    if (!acquired1) return false;

    try {
        boolean acquired2 = lock2.tryLock(100, TimeUnit.MILLISECONDS);
        if (!acquired2) return false;

        try {
            // Both locks acquired — do the work
            return true;
        } finally {
            lock2.unlock();
        }
    } finally {
        lock1.unlock();
    }
}

If the thread couldn't acquire the second lock within 100 ms, it releases the first one and tries again. Deadlock is impossible.

Diagnostics: thread dump

When an application hangs and you suspect a deadlock, take a thread dump (a snapshot of the state of all threads). The JVM can produce one right while the application is running.

# Find the PID of the Java process
jps -l

# Take a thread dump
jstack <PID>

In the jstack output, look for the block Found one Java-level deadlock: — the JVM automatically detects circular waits and shows which thread holds what and what it's waiting for.

Found one Java-level deadlock:
=============================
"Thread-0":
  waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
  which is held by "Thread-1"
"Thread-1":
  waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
  which is held by "Thread-0"

A thread dump also helps you find threads stuck in the BLOCKED or WAITING state longer than expected.

Livelock — marking time in place

Livelock is when threads are active and not blocked, yet they make no progress. They constantly react to each other and get in each other's way — like two people in a narrow hallway who politely step aside at the same moment and keep colliding anyway.

Example: two threads use tryLock and always back off at the same time — each sees that the other is active and again postpones its work. Formally the threads are running, but there's no progress.

The cure is to add a random delay before retrying, so the threads spread out in time:

// A random pause before retrying
long pause = (long) (Math.random() * 50); // 0–50 ms
Thread.sleep(pause);

This technique is called exponential backoff, and it's widely used in network protocols and distributed systems.

Starvation — a starving thread

Starvation is when one or more threads persistently fail to get CPU time or access to a resource because other threads always turn out to have higher priority.

A typical cause: a low-priority thread, or a thread that forever loses the competition for a lock. It isn't technically blocked, but its task never gets done.

ReentrantLock lets you enable fair mode: the lock is granted to whoever has been waiting the longest.

// true = fair mode, threads get the lock in turn
ReentrantLock fairLock = new ReentrantLock(true);

Fair mode reduces performance because of the overhead of tracking the queue — use it only where starvation is a real problem, not an assumption.

How the three problems differ from each other

diagram
Threads active?Any progress?
DeadlockNo (BLOCKED)No
LivelockYesNo
StarvationOne yes, the other noOne yes, the other no

A checklist for safe multithreaded code

Before you write code with multiple locks, run through this list:

  • Define a lock acquisition order — and stick to it across the whole codebase.
  • If you need to acquire several locks, consider tryLock with a timeout.
  • Minimize the time a lock is held: acquire → do the work → release.
  • Don't call foreign code (callbacks, methods you don't control) inside a synchronized block.
  • When something hangs, take a thread dump immediately and look for Found one Java-level deadlock.
  • If you need fairness, use new ReentrantLock(true) — but do it deliberately.

In short

  • Deadlock — threads wait for each other in a cycle and don't move. It arises when the four Coffman conditions hold.
  • The main defense against deadlock is a single lock acquisition order or tryLock with a timeout.
  • Livelock — threads are active but get in each other's way. Cured by a random pause before retrying.
  • Starvation — one thread forever loses the competition for a resource. new ReentrantLock(true) evens the odds.
  • Thread dump (jstack) is the main diagnostic tool: the JVM finds deadlocks itself and shows who holds what.

Further reading

  • synchronized and monitor in Java — how the synchronized keyword works, the object monitor, the entry conditions for a deadlock.
  • ReentrantLock and other locks — tryLock, lockInterruptibly, Condition, and when locks beat synchronized.
  • ExecutorService and the thread pool — how to manage the thread lifecycle and avoid creating them by hand.