Deadlock, livelock & safe design strategies
Named hazards and the two strategies that eliminate most concurrency bugs before they start.
The classic hazards
Deadlock: thread A holds lock 1 and waits for lock 2; thread B holds lock 2 and waits for lock 1. Both block forever.
// Thread A: synchronized(lock1) { synchronized(lock2) { … } }
// Thread B: synchronized(lock2) { synchronized(lock1) { … } }
// — classic deadlock — both threads wait forever
The standard fix: always acquire locks in the same order across all threads. If thread A and thread B both take lock1 first, then lock2, only one can win lock1 — the other waits safely.
Livelock: threads keep responding to each other but neither makes progress — like two people in a corridor stepping aside simultaneously in the same direction, forever.
Starvation: a low-priority thread never gets CPU time because higher-priority threads monopolise it.
Two strategies that prevent most bugs
1. Immutability — if shared objects cannot be mutated, no synchronisation is needed:
// String, Integer, LocalDate, BigDecimal, all records — safe to share
final String config = "read-only"; // no lock ever needed
2. Thread confinement — each object belongs to exactly one thread; never shared:
// ThreadLocal gives each thread its own instance
ThreadLocal<SimpleDateFormat> fmt = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
fmt.get().format(new Date()); // safe — each thread has its own SDF
For new code, reach first for immutable types (records, List.of, Map.of, BigDecimal, java.time types) and confine mutable state to a single thread. Reach for synchronized or AtomicInteger only when state genuinely must cross thread boundaries.
Choosing the right tool:
| Need | Tool |
|---|---|
| Simple counter | AtomicInteger |
| Guard a block of statements | synchronized |
| Shared map | ConcurrentHashMap |
| Producer/consumer | BlockingQueue |
| Chain async operations | CompletableFuture |
| Avoid sharing entirely | immutability / ThreadLocal |