The Shared Mutability Disaster
Concurrency bugs have a reputation for being mystical. They are not — they are the entirely predictable consequence of one combination: state that is shared between threads and state that mutates. Either alone is safe. Shared immutable data cannot be corrupted; unshared mutable data has no one to race with. This page makes the disaster visible, twice, and names the two demons: the race condition and the deadlock.
Demon One: The Race Condition
Here is the canonical crime scene — two threads incrementing one counter:
class Counter {
private long count = 0;
void increment() { count++; } // looks atomic; is not
long value() { return count; }
}
// Two threads, 100,000 increments each:
var counter = new Counter();
Thread t1 = new Thread(() -> { for (int i = 0; i < 100_000; i++) counter.increment(); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 100_000; i++) counter.increment(); });
t1.start(); t2.start(); t1.join(); t2.join();
System.out.println(counter.value()); // 200000? Almost never.
count++ compiles to three steps: read count, add one, write it back. Interleave two threads at the wrong instant and both read the same old value, both add one, and both write back — two increments, one effect. The result is silently short, differently each run:
Nothing crashes. No exception is thrown. The data is simply, quietly wrong — which is far worse, because the corruption surfaces later, elsewhere, as an inexplicable total. This nondeterminism is also why the bug hides under debuggers and print statements: anything that perturbs timing changes the interleaving.
Demon Two: The Deadlock
Locks fix races — and introduce their own failure mode. Give two threads two locks and opposite acquisition orders:
// Thread 1 // Thread 2
synchronized (accountA) { synchronized (accountB) {
synchronized (accountB) { synchronized (accountA) {
transfer(accountA, accountB, 10); transfer(accountB, accountA, 25);
} }
} }
Thread 1 holds A and wants B; thread 2 holds B and wants A. Neither can proceed, neither will release, and both wait forever — no error, no timeout, just a program that stops making progress. The classic conditions (mutual exclusion, hold-and-wait, no preemption, circular wait) suggest the classic cure: impose a global lock ordering — always acquire accountA before accountB (e.g. order by account ID) and the cycle becomes impossible.
The Root Cause Is the Design, Not the Threads
Both demons need shared mutable state to exist. The strategic fixes, in order of preference:
- Don't share: give each thread its own data and communicate by handing over messages — a value sent is a value no longer touched. (This is the same move that scaled objects to machines in Event-Driven Programming.)
- Don't mutate: immutable objects —
records,finalfields, copy-on-write — can be shared with any number of threads with zero coordination, because there is nothing to race on (the immutability argument from Advanced Java becomes decisive here). - Share-and-mutate under discipline: when you truly must, guard the state with synchronisation — the subject of The Concurrent Toolbelt — and keep the guarded region as small as the invariant allows.