Concurrency

Deadlock: interview questions and how to answer them

A deadlock is a cycle in the wait-for graph: every participant holds a resource the next one needs, so none can proceed and none will ever give up.

Written and reviewed by Sahil Srivastav

ConcurrencyDebuggingThread dumps

What it actually is

Deadlock requires four conditions to hold at once — mutual exclusion, hold-and-wait, no preemption, and circular wait. The framing is useful because it tells you exactly where the fixes are: you break a deadlock by removing one of the four, and only two of them are usually removable in real systems.

Mutual exclusion is normally the point of the lock, so you cannot drop it. No preemption you can partially remove with timeouts, which is what a database's deadlock detector effectively does — it preempts a victim by aborting it. Hold-and-wait you can remove by acquiring everything you need up front, in one statement, or by releasing before acquiring the next. Circular wait you remove by imposing a global order on lock acquisition, and this is the fix that actually holds under change.

Deadlock is not the same as a hang. A thread blocked on a slow network call looks similar in a dashboard but there is no cycle — it will finish or time out. A livelock is different again: threads are running and making no progress. Deadlock has a precise signature, which is why both the JVM and the database can detect it automatically: they walk the wait-for graph and find the loop.

Why it matters in production

In a database, deadlocks are detected and one transaction is killed, so the immediate symptom is an error rate rather than a freeze — `deadlock detected` in Postgres, error 1213 in MySQL. That sounds survivable, and at low rates it is. Under load it is not, because the deadlocking paths are usually the hot ones, and every abort wastes the work already done and then retries into the same cycle. Throughput falls while CPU stays high.

In application threads there is no detector that helps you: the JVM will report a deadlock in a thread dump but will not break it. Two threads deadlocked on synchronized monitors are gone for the lifetime of the process, and if they are pool threads, the pool shrinks by two. Repeat that under traffic and the service stops serving without ever crashing — the worst failure shape for alerting, because the process is alive and the health check may still pass.

Interviewers like deadlock because the wrong answer is so specific. A candidate who says "retry the transaction" has described the mitigation. A candidate who says "lock in a consistent order and tell me why the transfer path violated it" has described the fix.

How it works

Circular wait is the condition you should target

Assign every lockable resource a total order — account id, table name, a documented numeric rank — and require all code to acquire in ascending order. A cycle needs at least one participant to acquire out of order, so a globally respected order makes deadlock structurally impossible rather than merely unlikely.

Hold-and-wait disappears if you take the set atomically

`SELECT ... WHERE id IN (a, b) ORDER BY id FOR UPDATE` acquires both locks in one statement in a fixed order, so there is no moment where you hold one and wait for the other. In application code the equivalent is a tryLock-all-or-release-all loop, which trades deadlock for the possibility of starvation.

Index and gap locks create deadlocks between statements that look unrelated

In MySQL InnoDB under repeatable read, an `UPDATE` matching no rows still takes gap locks, and two inserts into the same gap from different directions can deadlock with no obvious shared row. The deadlock report names index records, not business entities, which is why reading it — rather than guessing from the application code — is the first step.

Lock upgrades are a hidden cycle

Two transactions that both hold a shared lock and both want exclusive are deadlocked with no ordering mistake anywhere. The rule that prevents it is to take the lock you will eventually need on the first acquisition: if you intend to write, read with `FOR UPDATE`.

Detection: the JVM and the database both draw the graph for you

`jcmd <pid> Thread.print` marks "Found one Java-level deadlock" with both threads and both monitors. Postgres logs the cycle with `DETAIL: Process A waits for ShareLock on transaction B`. MySQL keeps the last one in `SHOW ENGINE INNODB STATUS` under LATEST DETECTED DEADLOCK. Reading these is faster than any amount of reasoning about the code.

Implementing it

Write down the lock order as a rule, not as a comment in one file, because the invariant is global. "Locks on `accounts` are always acquired in ascending id order" is a sentence that belongs in the module's documentation and in code review.

Set `lock_timeout` in Postgres and keep `innodb_lock_wait_timeout` low enough in MySQL that a pathological wait fails rather than parking a connection. A bounded failure is debuggable; an unbounded wait takes the pool with it.

Retry deadlock victims — they are genuinely retryable — but treat a rising deadlock rate as a design defect rather than a tuning knob. Log the two statements involved so the ordering bug is identifiable from production data.

Prefer `java.util.concurrent` locks with `tryLock(timeout)` over `synchronized` on paths that acquire more than one lock, because `synchronized` has no timeout and no way out.

# Postgres: the log tells you the cycle outright
# ERROR:  deadlock detected
# DETAIL:  Process 2241 waits for ShareLock on transaction 8837;
#          blocked by process 2255.
#          Process 2255 waits for ShareLock on transaction 8836;
#          blocked by process 2241.
# HINT:  See server log for query details.

# JVM: find the cycle without guessing
jcmd $(pgrep -f myapp.jar) Thread.print | sed -n '/Found one Java-level deadlock/,+40p'

# MySQL: last detected cycle, with both index records named
mysql -e 'SHOW ENGINE INNODB STATUS\G' | sed -n '/LATEST DETECTED DEADLOCK/,/^---/p'

Interview questions and how to answer them

Two transfer requests, A→B and B→A, deadlock. Explain precisely why, and fix it.

The first locks A and then asks for B; the second locks B and then asks for A. Each holds what the other needs, so the wait-for graph has a cycle and the detector kills one. The cause is acquisition order, not the number of locks. The fix is a canonical order independent of business direction — always lock the lower account id first — which I would implement as a single `SELECT ... WHERE id IN ($1,$2) ORDER BY id FOR UPDATE` so the ordering is enforced by the statement rather than by whoever edits the service next.

Which of the four Coffman conditions can you actually remove in production, and how?

Circular wait, reliably, by imposing a total order on lock acquisition — that is the structural fix. Hold-and-wait, often, by acquiring the whole lock set in one atomic step or by not holding anything while waiting. No preemption, partially, with lock timeouts and deadlock detection, which preempt by aborting a victim. Mutual exclusion is almost never removable, because it is the property you wanted — though occasionally you can sidestep it by making the resource immutable or per-thread.

How do you confirm a deadlock in a live JVM?

`jcmd <pid> Thread.print`, or `jstack`, and look for "Found one Java-level deadlock" — the JVM walks the monitor wait-for graph and prints both threads with the locks each holds and wants. If instead I see many threads in `WAITING` on the same monitor with no cycle, that is contention or a lock held across I/O, not a deadlock, and the fix is different. Note that `ReentrantLock` deadlocks are also reported, but locks taken via other mechanisms — a semaphore, a database row — are not, so a clean thread dump does not prove there is no cycle.

Can you deadlock with only one lock?

With one non-reentrant lock, yes: a method that takes the lock and calls another method that takes the same lock will block forever. That is why `synchronized` and `ReentrantLock` are reentrant. You can also deadlock with one lock and one other resource — hold the lock, wait on a bounded queue that only a thread needing that lock can drain, and the cycle closes through the queue rather than through a second lock. Thread-pool starvation with nested tasks is the same shape.

Your deadlock rate rose after adding an index. Why might that be?

Because locking follows the access path. A new index can change the plan so a statement now locks different index records in a different order, or takes gap locks where a sequential scan previously did not — in InnoDB, range predicates on a non-unique index lock gaps under repeatable read. The deadlock was always latent in the ordering; the plan change exposed it. I would read the latest deadlock report to see which index records are named, then fix the ordering rather than dropping the index.

Answers that lose the round

  • Offering "retry on deadlock" as the fix rather than as the mitigation while the ordering bug stays in place
  • Saying deadlocks are caused by too many locks; they are caused by inconsistent acquisition order, and two locks are enough
  • Claiming the JVM recovers from deadlock — it detects and reports, it never breaks the cycle
  • Describing a thread stuck on a slow query as a deadlock, which shows the candidate cannot distinguish a cycle from a wait
  • Ignoring the deadlock report and reasoning from source code, which misses gap-lock and index-order deadlocks entirely
  • Adding a global lock to remove the cycle, converting a correctness bug into a throughput ceiling
  • Assuming a single-statement `UPDATE ... WHERE id IN (...)` is order-safe without `ORDER BY`; the plan chooses the order, and two plans can differ

Practise deadlock in a real repository

Gronex ships this as a runnable repository: a transfer service whose lock order depends on transfer direction. The test suite runs opposing transfers concurrently and fails on the deadlock, so only a canonical ordering passes — a retry loop does not.

FAQ

Is deadlock the same as livelock?

No. In a deadlock the threads are blocked and consume no CPU; the wait-for graph has a cycle and nothing changes. In a livelock the threads are running, repeatedly retrying, and still making no progress — a tryLock-and-back-off loop where both participants keep yielding to each other. Deadlock is detectable by graph analysis; livelock is not, and usually shows up as high CPU with flat throughput.

Why does the database kill one transaction instead of waiting?

Because a cycle never resolves itself. The detector periodically walks the wait-for graph — in Postgres after `deadlock_timeout`, one second by default — and if it finds a loop it aborts the cheapest victim to let the others proceed. Waiting longer would only convert a fast error into a hang.

Do read-only transactions deadlock?

Plain MVCC reads do not take conflicting locks, so ordinary read-only transactions do not participate in cycles. But a read that takes explicit locks — `FOR SHARE`, `FOR UPDATE`, or `SERIALIZABLE` predicate locks in Postgres SSI — absolutely can, and an SSI serialisation failure is a closely related abort with a different error code.

Related

More backend concepts