Why synchronization exists
The moment two threads touch the same mutable field and at least one of them writes,
your program has a race condition: the result depends on the unpredictable timing of
the scheduler. Synchronization is Java's answer, and it gives you two guarantees at once —
mutual exclusion (only one thread in the protected region) and visibility (writes
by one thread become observable to others). Miss either guarantee and you get bugs that
pass every test and then corrupt data in production at 3 a.m. This guide walks through the
mechanisms — from the humble synchronized keyword to explicit Locks — and the judgment
that keeps concurrent code correct.
Race conditions and the critical section
A critical section is any region of code that reads-then-writes shared state. The
canonical example is count++, which looks atomic but is really three operations: read
the current value, add one, write it back. Two threads can both read 5, both compute
6, and one increment silently vanishes — a lost update.
int count = 0;
void inc() { count++; } // read, add, write — three steps, not atomic
The fix is to serialize access so only one thread executes the critical section at a time. The design goal is to keep that section as small as correctness allows: hold the lock just long enough to mutate the shared data, no longer, so you don't needlessly block every other thread.
The synchronized keyword
synchronized acquires an object's intrinsic lock (its monitor) on entry and
releases it on exit — even when an exception unwinds the stack, which is why it can never
leak a lock. While one thread holds a given monitor, every other thread that tries to
synchronize on the same object blocks until it's free.
synchronized void m() { ... } // locks on 'this'
synchronized (lockObj) { ... } // locks on lockObj you chose
static synchronized void s() { ... } // locks on the Class object (Foo.class)
A subtle trap lives in that third line: the instance monitor (this) and the Class
monitor (Foo.class) are different locks. An instance synchronized method and a
static synchronized method on the same class do not exclude each other, so mixing
the two without realizing it leaves a race wide open.
Methods vs blocks
A synchronized method locks its entire body. A synchronized block lets you lock
only the portion that actually touches shared state, on any object you choose — which
is almost always the better tool.
void update() {
prepare(); // pure work — runs concurrently, no lock needed
synchronized (lock) { // only the critical part is serialized
shared.modify();
}
}
Blocks give you finer granularity: a shorter hold time means higher concurrency. They
also let you avoid locking on this, which matters because anyone holding a reference to
your object can lock it and interfere with — or deadlock against — your synchronization.
Reentrancy
Intrinsic locks are reentrant: a thread that already owns a lock can acquire it again without deadlocking itself. The JVM tracks an owner and a hold count; re-entering increments the count, exiting decrements it, and the lock is released only when the count returns to zero.
synchronized void outer() {
inner(); // re-acquires the same 'this' lock — fine, count goes 1 -> 2
}
synchronized void inner() { ... }
Without reentrancy, outer() calling inner() (both locked on this) would block
forever waiting on a lock it already holds. Reentrancy is what lets synchronized methods
freely call each other, and ReentrantLock provides the same semantics explicitly.
Happens-before: the visibility half
Locks are not only about exclusion. Acquiring and releasing a monitor establishes a happens-before relationship: everything a thread does before releasing a lock is guaranteed visible to any thread that subsequently acquires the same lock. The release flushes writes toward main memory; the acquire invalidates stale cached reads.
synchronized (lock) { data = 42; } // write happens-before the release
// ... another thread, later:
synchronized (lock) { use(data); } // acquire sees the prior release -> reads 42
The catch: both sides must use the same lock. Synchronizing your writes but reading the field without the lock breaks the chain — the reader may see a stale value forever, because the CPU is free to cache and reorder without a happens-before edge. This is the reason "I only synchronized the writer" is never a real fix.
Coordinating threads with wait/notify
When a thread needs to wait for a condition another thread will produce, it uses the
monitor's wait set. wait() releases the lock and parks the thread; another thread
calls notify() (wakes one) or notifyAll() (wakes all) on the same object, and the
woken thread must re-acquire the lock before it continues.
synchronized (queue) {
while (queue.isEmpty()) // guard in a loop — see next section
queue.wait(); // releases the monitor, sleeps until notified
process(queue.remove());
}
// producer:
synchronized (queue) { queue.add(x); queue.notifyAll(); }
You must hold the monitor to call wait, notify, or notifyAll — otherwise you get
IllegalMonitorStateException. That rule exists because these methods atomically
manipulate both the lock and the wait set, which is only meaningful if you already own the
monitor. A common variant of the bug is notifying on a different object than the one the
waiters parked on.
Why wait() lives in a while loop
Never guard wait() with an if. A thread can return from wait() even when the
condition is still false, for two reasons: spurious wakeups (the JVM is permitted to
wake a thread for no reason at all) and the fact that notifyAll() wakes multiple
threads that then race — by the time a given thread re-acquires the lock, another may have
already consumed the thing it was waiting for.
// WRONG — assumes the predicate holds after waking
if (queue.isEmpty()) queue.wait();
// RIGHT — re-tests the predicate after every wakeup
while (queue.isEmpty()) queue.wait();
The while loop re-checks the predicate after re-acquiring the lock, so the thread
proceeds only when the condition is genuinely true. Using if here is one of the most
frequently shipped concurrency bugs.
Lock vs synchronized
The java.util.concurrent.locks.Lock interface (chiefly ReentrantLock) is an
explicit lock object offering capabilities intrinsic locks can't: non-blocking and
timed acquisition (tryLock), interruptible acquisition (lockInterruptibly),
configurable fairness, and multiple Conditions per lock. The price is that it's
manual — you must release it yourself.
Lock lock = new ReentrantLock();
lock.lock();
try {
// critical section
} finally {
lock.unlock(); // MUST be in finally — Lock is NOT auto-released
}
Unlike synchronized, an explicit lock is not freed when a block exits or an exception
propagates. Omit the finally and a thrown exception leaks the lock forever, deadlocking
every other thread. Acquire the lock outside the try (or as its first statement) so
you never unlock() a lock you didn't get. Rule: use synchronized for simple mutual
exclusion; reach for Lock only when you need its extra powers.
tryLock, timeouts, and interruptible acquisition
Two of those extra powers directly attack hangs and deadlock. tryLock() grabs the lock
without blocking — returning true on success, false immediately if it's held — and
the timed overload waits up to a bound before giving up. lockInterruptibly() lets a
waiting thread be cancelled via interruption instead of hanging indefinitely; plain
lock() and synchronized are not interruptible.
if (lock.tryLock(2, TimeUnit.SECONDS)) { // bounded wait, then back off
try { doWork(); } finally { lock.unlock(); }
} else {
// couldn't get it — retry, log, or take an alternative path
}
tryLock enables deadlock avoidance: if a thread can't acquire every lock it needs,
it releases what it has and backs off rather than waiting in a cycle. lockInterruptibly
enables graceful shutdown of workers stuck on contended locks. Only call unlock() when
acquisition actually succeeded.
Fairness
A fair lock hands ownership to the longest-waiting thread (FIFO order). The
default ReentrantLock, like synchronized, is unfair: a newly arriving thread may
"barge" ahead of queued waiters.
Lock fair = new ReentrantLock(true); // FIFO order, no starvation
Lock unfair = new ReentrantLock(); // default — higher throughput
Fairness prevents starvation but costs throughput, because barging keeps the lock hot on a CPU that already has it cached and avoids context switches. Choose fairness only after you've measured starvation; otherwise the faster unfair default wins.
ReadWriteLock and Condition
When data is read often and written rarely, a single mutual-exclusion lock wastes
parallelism by serializing readers against each other. A ReadWriteLock splits the lock
in two: many threads may hold the read lock at once, but the write lock is
exclusive.
ReadWriteLock rw = new ReentrantReadWriteLock();
rw.readLock().lock();
try { return cache.get(key); } finally { rw.readLock().unlock(); } // shared
rw.writeLock().lock();
try { cache.put(k, v); } finally { rw.writeLock().unlock(); } // exclusive
A Lock also replaces wait/notify with Condition objects, and crucially one
lock can own several conditions. A bounded buffer can keep a notFull and a notEmpty
condition, signalling only the threads that can actually proceed instead of waking
everyone. As with wait, you must hold the lock and loop on the predicate when calling
await().
Deadlock and how to prevent it
A deadlock is two or more threads each holding a lock the other needs, so none ever proceeds. It requires all four Coffman conditions at once: mutual exclusion, hold-and-wait, no preemption, and circular wait. Break any one and deadlock is impossible — and the practical lever is the last one. Eliminate circular wait by imposing a global, consistent lock-acquisition order.
// Both threads grab locks in the same order keyed on a stable id — no cycle possible
Account first = a.id < b.id ? a : b;
Account second = a.id < b.id ? b : a;
synchronized (first) {
synchronized (second) { transfer(a, b); }
}
If every thread always acquires locks in the same sequence, a waiting cycle cannot form.
Other tactics — timed tryLock with back-off, shrinking scope so you hold one lock at a
time, or collapsing to a single coarse lock — all help, but consistent lock ordering is
the most reliable answer.
What to lock on
Prefer a private final lock object dedicated to guarding your state. Don't lock on
this (external callers can lock your object and interfere), and never lock on a String
literal or a boxed Boolean/Integer — those are interned or cached, so unrelated code
may lock the very same instance. Don't lock on a reference you reassign, either: change the
reference and the lock identity changes underneath you.
private final Object lock = new Object(); // encapsulated, immutable identity
void update() { synchronized (lock) { ... } }
A private lock means only your class controls that monitor, so no outside code can
contend on it or create a deadlock through it. Locking on this or a public field leaks
your synchronization policy to the whole program.
Recap
Synchronization buys you two things together — mutual exclusion to stop race
conditions and happens-before visibility so writes are actually seen. synchronized
is the simple, leak-proof, reentrant default that locks an object's intrinsic monitor;
just remember instance and Class locks are distinct. Coordinate threads with
wait/notify inside a while loop to survive spurious wakeups. Step up to
ReentrantLock when you need tryLock, lockInterruptibly, fairness, or multiple
Conditions — and always unlock() in a finally. Use ReadWriteLock for
read-heavy data, prevent deadlock with consistent lock ordering, and always
synchronize on a private final object. Master these and concurrent Java stops being a
guessing game.