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.
The Short Answer
A deadlock happens when two or more threads are waiting forever for locks held by each other.
The Classic Money Transfer Problem
Imagine two threads transferring money between the same two accounts in opposite directions.
Thread 1
Thread 2
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.
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.
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);
}
}
}Mental Model: Why Ordering Works
Without Ordering
With Ordering
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.
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;
}
}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.
// Bad idea: slow work inside lock
synchronized (account) {
callExternalService();
writeAuditLog();
account.withdraw(amount);
}Prefer doing slow or unrelated work outside the lock.
// 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.
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.
@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.
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
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.