Concurrency + System Design

Deadlock prevention strategies

Deadlock happens when threads wait forever on each other's locks. The most practical prevention strategy is consistent lock ordering, plus timeouts, smaller lock scope, and better system design.

ConcurrencyDeadlockLocksJavaSystem Design

The Short Answer

A deadlock happens when two or more threads are waiting forever for locks held by each other.

The most practical prevention strategy is simple: always acquire multiple locks in a consistent global order.

The Classic Money Transfer Problem

Imagine two threads transferring money between the same two accounts in opposite directions.

Thread 1

locks Account A
waits for Account B

Thread 2

locks Account B
waits for Account A

Neither thread can continue. Thread 1 needs B, but Thread 2 holds B. Thread 2 needs A, but Thread 1 holds A.

Bad Example: Lock Order Depends on Call Order

This code looks reasonable, but it can deadlock if two transfers happen in opposite directions.

java
static void transfer(Account from, Account to, int amount) {
    synchronized (from) {
        synchronized (to) {
            from.withdraw(amount);
            to.deposit(amount);
        }
    }
}

The problem is that one thread may lock A then B, while another thread locks B then A.

Strategy 1: Consistent Lock Ordering

Give every lock a stable ordering rule. For accounts, we can lock by account ID.

java
static void transfer(Account from, Account to, int amount) {
    Account first = from.id() < to.id() ? from : to;
    Account second = from.id() < to.id() ? to : from;

    synchronized (first) {
        synchronized (second) {
            from.withdraw(amount);
            to.deposit(amount);
        }
    }
}
Now every thread locks the lower account ID first and the higher account ID second. That removes the circular wait.

Mental Model: Why Ordering Works

Without Ordering

Thread 1: A → B
Thread 2: B → A
Circular wait possible

With Ordering

Thread 1: A → B
Thread 2: A → B
One waits, but no cycle forms

Strategy 2: Use tryLock with Timeout

With ReentrantLock, you can avoid waiting forever. If a thread cannot get the second lock, it can release the first and retry later.

java
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class Account {
    private final Lock lock = new ReentrantLock();
    private int balance;

    boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return lock.tryLock(time, unit);
    }

    void unlock() {
        lock.unlock();
    }

    void withdraw(int amount) {
        balance -= amount;
    }

    void deposit(int amount) {
        balance += amount;
    }
}
java
static boolean transfer(Account from, Account to, int amount)
        throws InterruptedException {

    if (from.tryLock(100, TimeUnit.MILLISECONDS)) {
        try {
            if (to.tryLock(100, TimeUnit.MILLISECONDS)) {
                try {
                    from.withdraw(amount);
                    to.deposit(amount);
                    return true;
                } finally {
                    to.unlock();
                }
            }
        } finally {
            from.unlock();
        }
    }

    return false;
}

This does not magically make the transfer succeed, but it prevents a thread from waiting forever.

Strategy 3: Keep Lock Scope Small

The longer a thread holds a lock, the more likely other threads are to pile up behind it.

java
// Bad idea: slow work inside lock
synchronized (account) {
    callExternalService();
    writeAuditLog();
    account.withdraw(amount);
}

Prefer doing slow or unrelated work outside the lock.

java
// Better: keep only shared-state mutation inside lock
callExternalService();

synchronized (account) {
    account.withdraw(amount);
}

writeAuditLog();

Strategy 4: Avoid Nested Locks When Possible

If your design can avoid acquiring multiple locks at once, do that. Many deadlocks come from nested locking.

java
synchronized (lock1) {
    synchronized (lock2) {
        // higher deadlock risk
    }
}

Sometimes nested locking is necessary, like with money transfer. But when it is not necessary, prefer simpler ownership models.

Strategy 5: Use a Database Transaction

In real money movement systems, the safest solution is often not Java object locking. It is a database transaction.

java
@Transactional
public void transfer(long fromId, long toId, int amount) {
    Account from = accountRepository.findByIdForUpdate(fromId);
    Account to = accountRepository.findByIdForUpdate(toId);

    from.withdraw(amount);
    to.deposit(amount);
}

The database can provide transaction isolation, row locks, rollback, and durability. That matters when money is involved.

Strategy 6: Single Writer / Actor Model

Another way to avoid deadlocks is to avoid shared mutable state across threads.

Instead of many threads directly locking the same objects, route all operations for a given account, user, or entity through one owner.

This is common in event-driven systems. A queue or partition ensures that all events for the same key are processed in order by one worker.

How to Answer This in an Interview

I would first explain that deadlock usually happens when multiple threads acquire multiple locks in inconsistent order. Then I would prevent it by enforcing a global lock order, using timeouts with tryLock when appropriate, keeping lock scope small, and using database transactions or single-writer designs when the problem is really a data consistency problem.

Common Interview Follow-Ups

Is synchronized itself dangerous?

No. synchronized is not the problem. The danger usually comes from acquiring multiple locks in inconsistent order.

Does consistent lock ordering eliminate deadlocks?

It eliminates deadlocks caused by circular waits among those ordered locks. You still need to be careful with other blocking operations.

Is tryLock better than synchronized?

Not always. tryLock gives you more control, especially timeouts and retries, but synchronized is simpler when you do not need those features.

Should a money transfer use Java locks or database transactions?

For real financial systems, database transactions are usually the right boundary because they provide atomicity, rollback, isolation, and durability.

Final Takeaway

Deadlock prevention is mostly about design discipline: avoid nested locks when possible, acquire locks in a consistent order when you must use multiple locks, and use higher-level tools like database transactions or single-writer processing when they fit the problem.