Welcome back. In our previous lessons, we've built a robust toolkit for Java concurrency, moving from low-level synchronized blocks and ReentrantLocks to the high-level management of tasks with the Executor framework. You now have the tools to run multiple operations in parallel. However, with this power comes a significant responsibility: ensuring that these concurrent operations don't end up in a state of permanent gridlock.
This lesson confronts one of the most classic and challenging problems in concurrent programming: deadlock. We will explore what a deadlock is, the precise set of conditions that must be met for one to occur, and—most importantly—demonstrate practical strategies to design your code to avoid them. This knowledge is critical for building resilient, high-performance applications.
What is a Deadlock?
At its core, a deadlock is a state where two or more threads are blocked forever, each waiting for a resource that is held by another thread in the group. Because every thread is waiting, none can proceed, and the application grinds to a halt without throwing any exceptions or errors. It simply stops making progress.
The video below offers some excellent analogies to help build an intuition for this problem.
L-4.1: DEADLOCK concept | Example | Necessary condition | Operating System
This video from Gate Smashers provides simple, real-world analogies for deadlock, such as cars stuck at an intersection.
Watch the introductory segment from the beginning to understand the fundamental concept of deadlock as a state of indefinite waiting.
In a software context, the "resources" are typically locks. Imagine two threads, T1 and T2, and two locks (or resources), R1 and R2. A deadlock occurs if:
- Thread T1 acquires the lock for R1.
- Simultaneously, Thread T2 acquires the lock for R2.
- Now, Thread T1 attempts to acquire the lock for R2, but it's held by T2, so T1 blocks.
- Finally, Thread T2 attempts to acquire the lock for R1, but it's held by T1, so T2 also blocks.
Both threads are now stuck in a circular dependency, waiting for a lock that will never be released.

The Four Necessary Conditions for Deadlock
Deadlocks don't happen by chance; they are the result of four specific conditions occurring simultaneously. These are often called the Coffman conditions. For a deadlock to exist, all four must be true. This is a crucial point because to prevent deadlocks, we only need to break one of these conditions.

Let's examine each condition in detail. The following article provides a thorough explanation.
This section from a DigitalOcean tutorial clearly defines each of the four necessary conditions for a deadlock.
Read the section The Four Coffman Conditions for Deadlock. As you read, focus on how Java's synchronized blocks naturally fulfill the first three conditions.
To summarize the four conditions:
-
Mutual Exclusion: At least one resource must be non-shareable, meaning only one thread can use it at a time. The
synchronizedkeyword andReentrantLockare designed explicitly to enforce this. -
Hold and Wait: A thread must be holding at least one resource while it is waiting to acquire another resource. A nested
synchronizedblock is a classic example of this. -
No Preemption: A resource cannot be forcibly taken away from the thread that holds it. A thread holding a lock will only release it voluntarily (e.g., by exiting the
synchronizedblock). The JVM doesn't have a mechanism to preemptively reclaim a lock from a waiting thread. -
Circular Wait: A set of threads {T1, T2, ..., Tn} must exist such that T1 is waiting for a resource held by T2, T2 is waiting for a resource held by T3, and so on, with Tn waiting for a resource held by T1. This closes the loop and makes the wait indefinite.
Deadlock in Action: A Code Example
Understanding the conditions is one thing; seeing them cause a program to freeze is another. Let's look at a concrete code example that creates a deadlock.
This part of the DigitalOcean article provides a complete Java program that reliably produces a deadlock using three threads and three object monitors.
Review the code in the section Deadlock in Java Example. Notice how the SyncThread class is designed to acquire locks in an order that, when combined with how the threads are started in main, creates a circular dependency. The Thread.sleep() calls are used to increase the probability of the deadlock occurring by orchestrating the timing of lock acquisitions.
In this example, t1 takes obj1 and wants obj2. t2 takes obj2 and wants obj3. Finally, t3 takes obj3 and wants obj1, completing the circular wait. All four Coffman conditions are met, and the program hangs.
Strategies for Deadlock Avoidance and Prevention
Since a deadlock requires all four conditions, we can prevent it by ensuring that at least one of them can never be met. Let's explore the most practical strategies.
1. Breaking Circular Wait: Enforce Lock Ordering
This is the most common and often the most effective strategy. If all threads are required to acquire locks in the same, globally consistent order, a circular wait is impossible.
For example, if we have two locks, lockA and lockB, we can establish a rule: any thread needing both locks must always acquire lockA before acquiring lockB. With this rule in place, a situation where one thread holds lockB while waiting for lockA can never happen, breaking the cycle.
The following resource clearly demonstrates this simple but powerful technique.
Deadlocks and Lock Mechanisms | CodeSignal Learn
This CodeSignal article first presents a deadlock scenario and then shows how enforcing a consistent lock order resolves it.
Read the sections Deadlocks in Action and Preventing Deadlocks. Pay close attention to the simple change made in the second code block that prevents the deadlock.
When dealing with objects whose order isn't naturally apparent, you can impose an artificial order by using their System.identityHashCode() or another unique, consistent identifier.
2. Breaking Hold-and-Wait: Timed and Careful Locking
Another approach is to attack the "Hold and Wait" condition. This can be done in two main ways.
a) Avoid Nested Locks:
The simplest way is to not hold multiple locks at once. If your logic allows, acquire a lock, perform the critical section, release the lock, and only then acquire the next one. This eliminates the "hold" part of the condition. However, this is often not feasible, as many operations require atomic access to multiple resources.
b) Use tryLock with a Timeout:
The ReentrantLock class from java.util.concurrent offers more flexibility than synchronized. Its tryLock() method attempts to acquire a lock but doesn't block indefinitely. You can provide a timeout. If the thread fails to acquire the second lock within the timeout, it can release the first lock, wait for a moment, and then retry acquiring both. This breaks the "wait" part of the condition, as the thread doesn't wait forever while holding a resource.
This approach transforms a potential deadlock into a "livelock" (where threads are busy retrying but not making progress), but with backoff and retry logic, it can lead to a robust solution.
The following resource provides a fantastic example of a ReentrantLock deadlock and its correction using tryLock.
This section first demonstrates a deadlock using ReentrantLock and then shows how to fix it with a tryLock() with a timeout.
First, study the code under Deadlock Scenario to see how ReentrantLock can deadlock just like synchronized. Then, carefully analyze the Corrected Version. Note the while (true) loop and how locks are released in the else block if both could not be acquired. This "back-off and retry" pattern is a powerful deadlock avoidance technique.
A Note on Other Conditions
- Breaking No Preemption: This is generally not possible with Java's intrinsic locks. The JVM does not support forcibly taking a lock from a thread.
- Breaking Mutual Exclusion: This is often not practical, as some resources (like a bank account balance) are inherently exclusive. However, using advanced non-blocking, lock-free data structures (e.g., from the
java.util.concurrent.atomicpackage) is a way to avoid locks entirely, thus breaking this condition.
Conclusion
In this lesson, we dissected the dangerous phenomenon of deadlock. You learned that it's not a random bug but a predictable outcome when four specific conditions—mutual exclusion, hold and wait, no preemption, and circular wait—are met. This understanding is the key to prevention.
Key Takeaways:
- A deadlock is a state where multiple threads are blocked indefinitely, each waiting for a resource held by another.
- The four Coffman conditions must all be true for a deadlock to occur.
- The most effective prevention strategy is to break the circular wait condition by enforcing a consistent global order for lock acquisition.
- An alternative strategy is to break the hold-and-wait condition by using timed lock attempts (
ReentrantLock.tryLock()) with a back-off and retry mechanism. - Designing for deadlock prevention from the start is far superior to trying to detect and recover from it in a running system.
Our journey through concurrency has covered threads, locks, executors, and now deadlocks. These topics primarily deal with liveness—ensuring your program can continue to execute. However, there is another, more subtle class of concurrency problems related to correctness—ensuring your program produces the right results. These problems arise from the complexities of how modern CPUs and compilers reorder instructions and manage memory caches. In our next module, we will dive deep into the Java Memory Model (JMM) to understand and control these effects.