Deadlocks, built on purpose and prevented four ways
A deadlock is the concurrency bug that shows up in production and never in your tests. Two threads each grab a lock, then each waits for the lock the other is holding, and neither will ever let go. The program does not crash. It does not error. It just stops, one frozen request, then another, until the thread pool is exhausted and the whole service hangs. The reason it hides is timing: it only triggers when two threads interleave in exactly the wrong way, which almost never happens under light load and reliably happens under heavy load. The cure is to understand it well enough to build one on demand.
The one idea
Deadlock needs four conditions to hold at once, and the classic one is that threads acquire multiple locks in inconsistent orders. Thread A locks account 1 then reaches for account 2. Thread B locks account 2 then reaches for account 1. If both get their first lock before either gets its second, each is now blocked waiting for a lock the other holds. A cycle. Break any link in that cycle and the deadlock cannot form. That single insight, it is a cycle in who-waits-for-whom, is what every prevention strategy attacks.
Build one that actually freezes
A money-transfer function that locks the source account, then the destination. Run two transfers in opposite directions, with a tiny pause to force the bad interleaving.
static void naiveTransfer(Account from, Account to, double amt) {
from.lock.lock();
try {
Thread.sleep(50); // give the other thread time to grab its first lock
to.lock.lock(); // ... and now we both wait forever
try { from.balance -= amt; to.balance += amt; }
finally { to.lock.unlock(); }
} finally { from.lock.unlock(); }
}
// thread 1: transfer(a, b) locks a then b
// thread 2: transfer(b, a) locks b then a
Run the pair and join with a timeout so you can detect the freeze instead of hanging your own program:
naive (opposite lock order): DEADLOCKED
The transfers never complete. Each thread holds one account's lock and waits for the other's. This is not a rare theoretical event, the sleep just makes reliable what production makes occasional.
Four ways to prevent it
1. Lock ordering. Impose a global order on locks and always acquire them in that order, regardless of the operation. Here, always lock the lower account id first. Now two opposite transfers both try for account 1 first, so one wins and proceeds while the other simply waits its turn, no cycle possible.
Account first = from.id < to.id ? from : to;
Account second = from.id < to.id ? to : from;
first.lock.lock();
try { second.lock.lock(); /* ... transfer ... */ }
ordered (lock lower id first): completed
Same two transfers, same interleaving, now they finish. This is the most common fix in real systems: pick a canonical order for every set of locks and never deviate.
2. Try-lock with a timeout. Instead of blocking forever on the second lock, use tryLock with a deadline. If you cannot get it in time, release the first lock, back off a random amount, and retry. This breaks the "hold and wait" condition: a thread never clings to one lock indefinitely while begging for another.
if (from.lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (to.lock.tryLock(100, TimeUnit.MILLISECONDS)) { /* transfer */ }
else { /* give up, release, retry later */ }
} finally { from.lock.unlock(); }
}
3. One coarser lock. If two resources are always used together, guard them with a single lock instead of two. You cannot deadlock on one lock, there is no second lock to form a cycle with. The cost is less parallelism, since unrelated transfers now serialize, so this suits low-contention paths.
4. Do not hold a lock across another acquisition. Often you can compute under one lock, release it, then take the next. Or drop locks entirely for the shared state and use atomic operations or an immutable copy-on-write structure. No thread ever holds two locks at once, so the cycle cannot start.
The one thing that matters across all four: they each remove one of the four necessary conditions. Lock ordering kills the circular wait. Try-lock kills hold-and-wait. A single lock kills the need for multiple resources. Lock-free code kills mutual exclusion itself.
Where this shows up
Every database has a deadlock detector because transactions grab row and table locks in orders the query planner cannot fully control; when it spots a cycle it kills one transaction and asks it to retry, prevention by detection rather than by ordering. Operating systems order their internal locks rigidly for exactly this reason. And the same trap appears without any lock keyword in sight: two synchronized methods calling into each other, two goroutines on unbuffered channels, two async tasks awaiting each other. The shape is always the cycle.
If you want to build the lock-free structures, the wait-free algorithms, and the deadlock detector itself, that is the path the concurrency track on IWTLP takes through Java's memory and threading model.