Not locking: confinement, immutability, safe collections, deadlock
◈ 5 cardsThe cheapest correct alternatives to a lock — locals, immutability, atomics, concurrent maps — and the two-locks-two-orders cycle that wedges a server.
A lock is the last option, not the first
synchronized costs contention, it costs the ability to reason locally, and every lock you add is a chance at deadlock. Before reaching for one, ask whether the state has to be shared at all. Four alternatives, cheapest first.
Locals. A method's local variables live on the calling thread's stack. Every invocation gets a fresh set, and no other thread can name them. State that fits in a local is thread-safe for free — which is why a connection handler should build its response in locals and touch fields only when it genuinely must.
Immutability. An object whose fields are all private final and which exposes no mutator cannot be seen in an inconsistent state, because it has only one state. String is immutable and therefore safe to pass anywhere; StringBuilder is not. Making your own request or response objects immutable is usually less work than working out which of their methods need locks.
Confinement. A thread-unsafe object can be used safely if exactly one thread can reach it. Give each handler its own buffer rather than sharing one and guarding it. The rule is that the reference must never leak — the moment you hand it to another object that other threads touch, the confinement is gone.
Atomics and concurrent collections. java.util.concurrent.atomic provides AtomicInteger, AtomicLong, AtomicBoolean and friends, whose read-modify-write operations are single atomic machine-level steps — the right tool for a hit counter, and faster than a lock. For maps, ConcurrentHashMap is safe for concurrent access without any external synchronization.
The limit of "thread-safe"
A thread-safe collection promises that each individual call is atomic. It promises nothing about two calls in a row. if (!map.containsKey(k)) map.put(k, v) is two atomic operations with a hole between them, and two threads will both find the key absent. That is why ConcurrentMap ships compound operations that close the hole in one call: putIfAbsent, computeIfAbsent, and merge. Reach for those before reaching for a lock. Iterating the whole map is likewise many operations, and a snapshot-consistent iteration still needs external coordination.
Deadlock: two locks, two orders
A lock can also stop a program dead. Deadlock is two threads each holding a lock the other needs and neither willing to let go. To the operating system nothing is wrong — the process is alive and idle — but no request will ever complete again.
It is rare, timing-dependent, and therefore vicious: it passes every test, then wedges a server handling hundreds of requests a second within the hour. The cure is structural rather than defensive. Hold as few locks as possible; never call unknown code while holding one; and when two locks are genuinely unavoidable, define one global acquisition order and make every thread follow it. If every thread takes the log monitor before the tally monitor, no cycle can form, and there is nothing left to debug.
Worked example — the server that wedged itself
A request handler updates a per-host tally and then writes the access log, holding both monitors so the pair stays consistent: synchronized (tally) { synchronized (log) { … } }. A second feature — an hourly summariser — writes a header to the log and then reads the tally to fill it in: synchronized (log) { synchronized (tally) { … } }. Both are individually correct and both are individually tested.
Run them together and there is one interleaving in a million where the handler holds tally and wants log at the moment the summariser holds log and wants tally. Neither thread can advance; every other connection then piles up behind the log monitor and the server stops without a single exception in the error log. The fix is one line long: make the summariser take tally first, like everyone else.
source JDK javadoc java.util.concurrent.ConcurrentHashMap