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:
- Mutual exclusion — a resource can be held by only one thread at a time.
- Hold and wait — a thread holds resources it has already acquired while waiting for new ones.
- No preemption — a resource cannot be taken away from a thread by force; the thread must release it itself.
- 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
| Threads active? | Any progress? | |
|---|---|---|
| Deadlock | No (BLOCKED) | No |
| Livelock | Yes | No |
| Starvation | One yes, the other no | One 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
tryLockwith 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
synchronizedblock. - 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
tryLockwith 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
synchronizedkeyword works, the object monitor, the entry conditions for a deadlock. - ReentrantLock and other locks —
tryLock,lockInterruptibly,Condition, and when locks beatsynchronized. - ExecutorService and the thread pool — how to manage the thread lifecycle and avoid creating them by hand.