🧵 Concurrency Fundamentals · Advanced

Deadlock in Java

Circular waiting on locks, and lock ordering to prevent it.

🧩 The mysteryThread 1 holds account A and wants B. Thread 2 holds B and wants A. CPU: 0%. Progress: none. Duration: forever. Meet deadlock.

A cycle of waiting

A deadlock happens when threads wait for each other in a cycle: T1 holds lock A and wants B, while T2 holds B and wants A. Neither can proceed. With synchronized they wait forever: the JVM never breaks a deadlock and throws no exception.

Watch it happen

1. T1 calls transfer(a, b) and locks a. 2. T2 calls transfer(b, a) and locks b. 3. T1 wants b: BLOCKED. 4. T2 wants a: BLOCKED. Same code, same two locks, opposite order: a perfect cycle.

void transfer(Account from, Account to) {
    synchronized (from) {
        synchronized (to) { move(from, to); }
    }
}
// transfer(a, b) || transfer(b, a)

Four ingredients

Deadlock needs all four: mutual exclusion (one holder per lock), hold and wait (holding one lock while requesting another), no preemption (locks can't be taken away), and circular wait. Remove any one and deadlock is impossible. Lock ordering kills circular wait; tryLock with a timeout kills hold-and-wait.

Fixing transfer()

✗ Argument order
synchronized (from) {
    synchronized (to) {
        move(from, to);
    }
}

Lock order depends on how the caller passes the arguments, so opposite calls form a cycle.

✓ Global order
Account first =
    from.id() < to.id() ? from : to;
Account second = first == from ? to : from;
synchronized (first) {
    synchronized (second) { move(from, to); }
}

Every thread locks the smaller id first, so a cycle can't form.

🤔 Think first

Quick fixes?

Could adding Thread.sleep(10) between the two locks, or catching a DeadlockException, fix it?

Think about it, then reveal the answer

Neither. Sleeping only shifts the timing: the deadlock gets rarer, never impossible. And Java has no DeadlockException; deadlocked threads simply stay blocked. Only a consistent lock order (or backing off with tryLock) removes the cycle.

⚠️ The trap

Order must be global

Each method below looks fine on its own. But credit() locks x then y, while debit() locks y first. Run them together on the same pair and you get a circular wait. A lock order only works if every code path follows it.

void credit(Account x, Account y) {
    synchronized (x) {
        synchronized (y) { move(x, y); }
    }
}
void debit(Account x, Account y) {
    synchronized (y) {   // y first!
        synchronized (x) { move(y, x); }
    }
}
💼 In the real world

Diagnosing a freeze

Symptom: the app stops responding and CPU drops to near zero. Take a thread dump with jstack <pid>: it prints "Found one Java-level deadlock" and names each thread, the lock it holds and the lock it wants. Then fix the ordering in code: restarting only resets the clock.

Key takeaways

  1. Circular wait on locks freezes the threads involved
  2. Break the cycle: always acquire locks in one consistent order
  3. tryLock with a timeout lets a thread back off
  4. Thread dumps (jstack) report Java-level deadlocks
🤯 Did you know?

Databases like PostgreSQL detect deadlocks between transactions and abort one victim to break the cycle. The JVM never does that for synchronized.

Practice questions

transfer(a, b) and transfer(b, a) from the card run at the same time. The app freezes with 0% CPU. What happened?

  1. Deadlock: each thread holds one account's lock and waits for the other
  2. Livelock: the threads keep retrying
  3. A race condition corrupted the balances
  4. The thread pool ran out of memory
Check your answer

Deadlock: each thread holds one account's lock and waits for the other. Thread 1 locks a then wants b; thread 2 locks b then wants a. That circular wait never resolves on its own.

What is the best fix for the transfer deadlock?

  1. Always lock the account with the smaller id first
  2. Add Thread.sleep(10) between the two synchronized blocks
  3. Make the balance fields volatile
  4. Catch DeadlockException and retry
Check your answer

Always lock the account with the smaller id first. A consistent global order (e.g. by account id) makes a cycle impossible. Sleeping only makes the deadlock rarer, and Java has no DeadlockException.

Next: threads that never block, burn 100% CPU, and still get nothing done. Livelock: deadlock's hyperactive cousin.