Java Tutorial
🔍

Deadlock

Deadlock

A deadlock happens when two or more threads each hold a lock the other needs, and neither will ever let go — every thread involved is frozen forever, waiting for a resource that will never become free. It is one of the most notorious concurrency bugs precisely because the code that causes it often looks correct in isolation, and only fails once two operations run against each other in opposite order.

What Is Deadlock?

Deadlock is a permanent standstill, not a slowdown. Once it happens, the threads involved do not eventually recover on their own — they stay frozen until something external intervenes, whether that is an operator killing the process or the JVM restarting.

Deadlock requires four conditions at once — mutual exclusion, hold-and-wait, no preemption, and circular wait. Breaking any single one prevents it entirely.

Why Thread Safety Matters Here

A deadlocked thread does not throw an exception, does not log an error, and does not show up as a spike in any metric except one: it simply stops responding. In a web application, that usually means a request handler thread is gone for good, and if enough threads in a pool deadlock the same way, the whole service can appear to hang while consuming almost no CPU at all — a pattern that confuses teams the first time they see it, since a hung service that isn't burning CPU doesn't look like the resource exhaustion incidents most on-call engineers expect.

How It Works

The next diagram shows the classic two-thread, two-lock circular wait that causes a deadlock.

Thread A                                  Thread B
   |                                         |
   v                                         v
holds Lock 1                            holds Lock 2
   |                                         |
   v                                         v
waits for Lock 2  <--------------------->  waits for Lock 1
   |                                         |
   +---------------- circular wait ----------+
   (neither thread will ever release what it holds)

Each thread holds exactly what the other one needs next, and neither is willing — or able — to give it up first. Locking two resources in whatever order they happen to arrive in creates exactly this risk: if one thread locks A then B while another locks B then A at the same time, each can end up holding the lock the other is waiting for.

Lock.tryLock(timeout, unit) waits only up to the given duration instead of forever, which makes it possible to demonstrate a genuine deadlock scenario safely — the program terminates deterministically instead of hanging, and reports that neither side could complete.

1// File: DeadlockDetection.java 2import java.util.concurrent.CountDownLatch; 3import java.util.concurrent.TimeUnit; 4import java.util.concurrent.atomic.AtomicBoolean; 5import java.util.concurrent.locks.ReentrantLock; 6 7public class DeadlockDetection { 8 9 public static void main(String[] args) throws InterruptedException { 10 ReentrantLock accountA = new ReentrantLock(); 11 ReentrantLock accountB = new ReentrantLock(); 12 CountDownLatch bothHoldFirstLock = new CountDownLatch(2); 13 AtomicBoolean aToBSucceeded = new AtomicBoolean(); 14 AtomicBoolean bToASucceeded = new AtomicBoolean(); 15 16 Thread t1 = new Thread(() -> { 17 accountA.lock(); 18 bothHoldFirstLock.countDown(); 19 awaitQuietly(bothHoldFirstLock); 20 try { 21 aToBSucceeded.set(tryLockQuietly(accountB)); 22 } finally { 23 accountA.unlock(); 24 } 25 }); 26 27 Thread t2 = new Thread(() -> { 28 accountB.lock(); 29 bothHoldFirstLock.countDown(); 30 awaitQuietly(bothHoldFirstLock); 31 try { 32 bToASucceeded.set(tryLockQuietly(accountA)); 33 } finally { 34 accountB.unlock(); 35 } 36 }); 37 38 t1.start(); 39 t2.start(); 40 t1.join(); 41 t2.join(); 42 43 System.out.println("A to B acquired second lock: " + aToBSucceeded.get()); 44 System.out.println("B to A acquired second lock: " + bToASucceeded.get()); 45 } 46 47 private static boolean tryLockQuietly(ReentrantLock lock) { 48 try { 49 return lock.tryLock(500, TimeUnit.MILLISECONDS); 50 } catch (InterruptedException e) { 51 Thread.currentThread().interrupt(); 52 return false; 53 } 54 } 55 56 private static void awaitQuietly(CountDownLatch latch) { 57 try { 58 latch.await(); 59 } catch (InterruptedException e) { 60 Thread.currentThread().interrupt(); 61 } 62 } 63}
Output:
A to B acquired second lock: false
B to A acquired second lock: false

The CountDownLatch guarantees both threads already hold their first lock before either attempts the second, so this reliably reproduces the circular wait — each thread waits the full 500ms for a lock the other will not release until its own wait finishes, so both time out and return false. Results are stored and printed from main after both threads are joined, rather than printed from inside each thread, so the two output lines always appear in the same order regardless of how the threads were actually scheduled.

Code Examples

Locking two resources in whatever order they happen to arrive in is unsafe — this version can hang forever if both threads reach their first lock before either reaches its second.

1// File: DeadlockProneTransfer.java 2// Illustrative only - do not run this to completion. If both threads reach 3// their first lock before either reaches its second, this hangs forever. 4import java.util.concurrent.locks.ReentrantLock; 5 6public class DeadlockProneTransfer { 7 8 static void transfer(ReentrantLock from, ReentrantLock to, String label) { 9 from.lock(); 10 try { 11 to.lock(); 12 try { 13 System.out.println(label + " completed"); 14 } finally { 15 to.unlock(); 16 } 17 } finally { 18 from.unlock(); 19 } 20 } 21 22 public static void main(String[] args) { 23 ReentrantLock accountA = new ReentrantLock(); 24 ReentrantLock accountB = new ReentrantLock(); 25 26 Thread t1 = new Thread(() -> transfer(accountA, accountB, "A to B")); 27 Thread t2 = new Thread(() -> transfer(accountB, accountA, "B to A")); 28 29 t1.start(); 30 t2.start(); 31 } 32}

Locking both threads in the same order, regardless of which direction the operation is logically going, removes the circular wait entirely — neither thread can ever hold the lock the other needs, because both always ask for the locks in the same sequence.

1// File: DeadlockFreeTransfer.java 2import java.util.concurrent.locks.ReentrantLock; 3import java.util.concurrent.atomic.AtomicInteger; 4 5public class DeadlockFreeTransfer { 6 7 static void transfer(ReentrantLock first, ReentrantLock second, AtomicInteger completed) { 8 first.lock(); 9 try { 10 second.lock(); 11 try { 12 completed.incrementAndGet(); 13 } finally { 14 second.unlock(); 15 } 16 } finally { 17 first.unlock(); 18 } 19 } 20 21 public static void main(String[] args) throws InterruptedException { 22 ReentrantLock accountA = new ReentrantLock(); 23 ReentrantLock accountB = new ReentrantLock(); 24 AtomicInteger completed = new AtomicInteger(); 25 26 // Both threads lock accountA first, accountB second - regardless of 27 // which "direction" the transfer is - so a circular wait can never form 28 Thread t1 = new Thread(() -> transfer(accountA, accountB, completed)); 29 Thread t2 = new Thread(() -> transfer(accountA, accountB, completed)); 30 31 t1.start(); 32 t2.start(); 33 t1.join(); 34 t2.join(); 35 36 System.out.println("Transfers completed: " + completed.get()); 37 } 38}
Output:
Transfers completed: 2

Real-World Example

A banking service transfers money between two accounts, each guarded by its own lock — the transfer direction alone cannot be used to decide lock order, since two transfers can run in opposite directions concurrently.

1// File: BankAccount.java 2import java.util.concurrent.locks.ReentrantLock; 3 4public class BankAccount { 5 private final String accountId; 6 private double balance; 7 private final ReentrantLock lock = new ReentrantLock(); 8 9 public BankAccount(String accountId, double balance) { 10 this.accountId = accountId; 11 this.balance = balance; 12 } 13 14 public String getAccountId() { return accountId; } 15 public double getBalance() { return balance; } 16 public ReentrantLock getLock() { return lock; } 17 18 void debit(double amount) { balance -= amount; } 19 void credit(double amount) { balance += amount; } 20}
1// File: TransferService.java 2 3public class TransferService { 4 5 public void transfer(BankAccount from, BankAccount to, double amount) { 6 BankAccount first = from.getAccountId().compareTo(to.getAccountId()) < 0 ? from : to; 7 BankAccount second = first == from ? to : from; 8 9 first.getLock().lock(); 10 try { 11 second.getLock().lock(); 12 try { 13 from.debit(amount); 14 to.credit(amount); 15 } finally { 16 second.getLock().unlock(); 17 } 18 } finally { 19 first.getLock().unlock(); 20 } 21 } 22}
1// File: TransferServiceDemo.java 2 3public class TransferServiceDemo { 4 public static void main(String[] args) throws InterruptedException { 5 BankAccount accountA = new BankAccount("ACC-100", 5000.0); 6 BankAccount accountB = new BankAccount("ACC-200", 3000.0); 7 TransferService service = new TransferService(); 8 9 Thread t1 = new Thread(() -> service.transfer(accountA, accountB, 500.0)); 10 Thread t2 = new Thread(() -> service.transfer(accountB, accountA, 200.0)); 11 12 t1.start(); 13 t2.start(); 14 t1.join(); 15 t2.join(); 16 17 System.out.println(accountA.getAccountId() + ": " + accountA.getBalance()); 18 System.out.println(accountB.getAccountId() + ": " + accountB.getBalance()); 19 } 20}
Output:
ACC-100: 4700.0
ACC-200: 3300.0

TransferService.transfer() derives lock order from comparing account IDs, not from which account is from and which is to — so t1's "A to B" transfer and t2's "B to A" transfer both lock accountA first regardless of direction, making a circular wait impossible. A mistake that appears often in fresher pull requests is locking accounts in whatever order the from and to parameters happen to arrive in, which is exactly what creates a circular wait the moment two transfers run in opposite directions concurrently. Deriving the lock order from a stable property of the accounts themselves, such as comparing their IDs, guarantees every thread agrees on the same order no matter which direction its own transfer is going.

Best Practices

Establish and document a consistent lock ordering — typically derived from a stable identity like an ID or a hash — for any code that ever needs to hold more than one lock at a time. This is the standard way multiple locks are used safely together across an entire codebase, not just within one class.

Prefer tryLock() with a timeout over lock() for operations where hanging indefinitely is unacceptable, so a stuck operation fails visibly instead of silently freezing a thread forever, and treat a hung application as diagnosable with jstack or ThreadMXBean.findDeadlockedThreads(), both of which report exactly which threads are deadlocked and which locks each is waiting on.

Keep the amount of code executed while holding a lock as small as possible, and avoid holding one lock while waiting on another whenever the logic can be restructured to avoid it — a deadlock can only occur when a thread holds one lock while requesting another.

Always release a lock in a finally block, covered in this article's Common Mistakes section — an exception between lock() and unlock() with no finally leaves the lock held forever. ReentrantLock, used throughout this article, and CountDownLatch, used to make the detection demo reliable, are both covered in more depth in this section's other articles on locking and coordination utilities.

Common Mistakes

Deriving lock order from which parameter is "first" or "from," rather than from a stable identity, is the single most common cause of a real deadlock — covered in depth in this article's real-world example above.

Forgetting to release a lock in a finally block is a second, easy-to-miss mistake — unlike a synchronized block, an explicit Lock is not automatically released if the thread holding it dies.

1// File: MissingFinallyMistake.java 2import java.util.concurrent.TimeUnit; 3import java.util.concurrent.locks.ReentrantLock; 4 5public class MissingFinallyMistake { 6 public static void main(String[] args) throws InterruptedException { 7 ReentrantLock lock = new ReentrantLock(); 8 9 Thread faulty = new Thread(() -> { 10 lock.lock(); 11 throw new RuntimeException("Something went wrong"); 12 // lock.unlock() is never reached 13 }); 14 15 faulty.setUncaughtExceptionHandler((t, e) -> {}); 16 faulty.start(); 17 faulty.join(); 18 19 boolean acquired = lock.tryLock(200, TimeUnit.MILLISECONDS); 20 System.out.println("Lock still available: " + acquired); 21 } 22}
Output:
Lock still available: false

The faulty thread dies from the uncaught exception without ever calling unlock() — and because ReentrantLock has no connection to thread liveness the way a synchronized monitor does, the lock stays held forever. main's tryLock() waits out its full 200ms and still cannot acquire it.

Interview Questions

Q1. What is a deadlock, and what are the four necessary conditions for it to occur?

A deadlock is a state where two or more threads are each waiting for a resource the other holds, with none able to proceed. The four Coffman conditions — mutual exclusion, hold-and-wait, no preemption, and circular wait — must all hold simultaneously; preventing any single one prevents deadlock. Interviewers listen for whether you can name all four, not just describe the symptom.

Q2. How does consistent lock ordering prevent deadlock?

If every thread that needs multiple locks always acquires them in the same agreed-upon order, a circular wait can never form — whichever thread gets the first lock in the sequence, every other thread will simply wait for it in the same order, rather than each holding a piece the other needs. The nuance being tested is whether you can explain WHY it works, not just recite it as a rule.

Q3. Does a ReentrantLock get automatically released if the thread holding it dies?

No. Unlike a synchronized block's intrinsic monitor lock, which the JVM releases automatically when the owning thread exits, an explicit Lock like ReentrantLock stays held indefinitely unless unlock() is called — exactly as demonstrated in this article's MissingFinallyMistake example. This is a favorite service-company question precisely because the correct answer is counterintuitive to anyone used to synchronized.

Q4. What is the difference between Lock.lock() and Lock.tryLock() in terms of deadlock risk?

lock() waits indefinitely, so a genuine deadlock leaves the calling thread frozen forever. tryLock(), especially with a timeout, returns after a bounded wait even if the lock could not be acquired, converting a potential permanent hang into a detectable, recoverable failure. Interviewers want to hear that this converts an unbounded risk into a bounded, observable one, not that it "fixes" deadlock outright.

Q5. Can a deadlock occur with just one lock?

Not in the classic circular-wait sense, which requires at least two threads and two resources. A thread attempting to re-acquire a non-reentrant lock it already holds can effectively self-deadlock, though ReentrantLock specifically is designed to avoid exactly that case for the same thread. This question is a common trap for candidates who answer "no" too quickly without the reentrancy caveat.

Q6. What tools can be used to detect a deadlock in a running Java application?

jstack produces a thread dump that explicitly flags deadlocked threads and the locks involved. ThreadMXBean.findDeadlockedThreads() does the same programmatically. GUI tools like VisualVM or JConsole surface the same information visually. Product-based interviewers often follow up by asking how you'd find this in a live production incident, not just in a local IDE.

Q7. Why should a lock always be released in a finally block?

Because an exception thrown after lock() but before unlock() would otherwise leave the lock held forever, exactly as this article's Common Mistakes section demonstrates — a finally block guarantees unlock() runs regardless of how the protected code exits. The nuance interviewers listen for is whether you connect this specifically to Lock's lack of automatic release, not synchronized, which does not have this problem.

FAQs

Is deadlock unique to explicit Lock objects, or can synchronized blocks deadlock too?

synchronized blocks can deadlock just as easily — the classic two-lock deadlock scenario is often taught using synchronized specifically. The risk comes from holding one lock while waiting for another, regardless of which locking mechanism is used.

Does using tryLock() with a timeout eliminate deadlock risk entirely?

Not entirely — the underlying circular-wait condition can still occur. What changes is the consequence: instead of hanging forever, the operation fails after the timeout and can be retried, logged, or reported, which is usually far preferable to a silent, permanent freeze.

Can deadlock occur with database locks, not just in-memory Java locks?

Yes. Two database transactions can deadlock over row or table locks exactly the same way two threads can over Java locks — most database systems include their own deadlock detection that automatically aborts one of the involved transactions.

Does a deadlocked thread consume CPU while stuck?

No. A thread waiting on lock() or blocked entering a synchronized block is parked by the JVM, not busy-spinning, so a deadlocked thread consumes essentially no CPU while it waits.

Is deadlock detection something the JVM does automatically at runtime?

No, not by default — the JVM will let deadlocked threads wait forever with no automatic intervention. Detecting a deadlock requires external tooling like jstack or ThreadMXBean, covered in this article's Interview Questions section.

Can more than two threads be involved in a single deadlock?

Yes. A circular wait can span any number of threads and locks — thread A waiting on a lock held by B, B waiting on one held by C, and C waiting on one held by A is just as much a deadlock as the classic two-thread case.

Does virtual threads change how deadlock works?

No, the fundamental mechanics are unchanged — a virtual thread blocked forever waiting for a lock it will never receive is deadlocked exactly as a platform thread would be. Since blocking inside a synchronized block pins a virtual thread to its carrier, a deadlock involving synchronized specifically also ties up that carrier thread for as long as the deadlock persists.

Summary

Deadlock happens when a circular wait forms among threads each holding a lock another needs, and the standard, reliable fix is consistent lock ordering — deriving the order from a stable identity rather than from parameter position, exactly as this article's bank transfer example does. tryLock() with a timeout is the safety net worth reaching for when a hang is unacceptable, and it is also what makes it possible to demonstrate a real deadlock scenario safely, without a program that actually hangs forever.

The habit worth carrying forward from this article is treating "which lock do I acquire first" as a question with one fixed answer across the entire codebase, not a decision made fresh at each call site — and always releasing a lock in a finally block, since an explicit Lock offers no safety net of its own if a thread dies while still holding one.

What to Read Next