Synchronization restricts a block of code so that only one thread at a time can execute it (mutual exclusion) and ensures changes made by one thread are visible to others. It exists to fix the race condition: when two threads access shared mutable state concurrently and at least one writes, the final result depends on unpredictable timing.
int count = 0;
void inc() { count++; } // NOT atomic: read, add, write
count++ is three steps; two threads can both read the same value and one
update is lost. The vulnerable region — the code touching shared state — is the
critical section, and synchronization serializes access to it.
A critical section is a region of code that accesses shared mutable state and must not be executed by more than one thread simultaneously. Protecting it with a lock guarantees that interleaved access can't corrupt the data.
synchronized (lock) {
// critical section: only one thread in here at a time
balance += amount;
}
The goal is to make the critical section as small as correctness allows — hold the lock just long enough to read/modify the shared data, no longer, so other threads aren't blocked unnecessarily.
synchronized acquires an object's intrinsic lock (also called its
monitor) before entering, and releases it on exit — even if an exception is
thrown. While one thread holds the lock, any other thread trying to synchronize
on the same object blocks.
synchronized void m() { ... } // locks on 'this'
synchronized (lockObj) { ... } // locks on lockObj
static synchronized void s() { ... } // locks on the Class object
It can be applied to a method (locks the receiver, or the Class for static
methods) or a block (you choose the lock object). Two threads synchronizing
on different objects don't block each other at all.
A synchronized method implicitly locks the whole method body on this
(or the Class for static methods). A synchronized block lets you lock only
a portion of the method, and on any object you choose.
synchronized void update() { // entire method under lock
prepare(); // doesn't need the lock — wasteful
shared.modify();
}
void update2() {
prepare(); // runs concurrently
synchronized (lock) { // only the critical part is locked
shared.modify();
}
}
Blocks give finer granularity (shorter lock hold time, higher concurrency)
and let you avoid locking on this, which is the safer practice.
It depends on whether the method is static:
| Method kind | Lock acquired |
|---|---|
instance synchronized method |
the instance (this) |
static synchronized method |
the Class object (e.g. Foo.class) |
synchronized (obj) block |
whatever obj you name |
class Foo {
synchronized void a() {} // locks 'this'
static synchronized void b() {} // locks Foo.class
}
Crucially, the instance lock and the Class lock are different monitors —
so a() and b() can run concurrently on the same object. Mixing static
and instance synchronization without realizing this is a common bug.
Reentrant means a thread that already holds a lock can acquire it again without deadlocking itself. The JVM tracks an owner and a hold count; re-entering increments it, exiting decrements it, and the lock frees only when the count hits zero.
synchronized void outer() {
inner(); // re-acquires the same lock — fine
}
synchronized void inner() { ... }
Without reentrancy, outer() calling inner() (both locking this) would
block forever. This is why a synchronized method can freely call other
synchronized methods on the same object. ReentrantLock provides the same
behavior explicitly.
A lock establishes a happens-before relationship: everything a thread does before releasing a lock is visible to any thread that subsequently acquires the same lock. So locks aren't just about mutual exclusion — they also flush writes to main memory and invalidate stale cached reads.
synchronized (lock) { data = 42; } // write happens-before release
// another thread:
synchronized (lock) { read(data); } // sees 42 — acquire sees prior release
Without synchronization (or volatile), one thread's write to a field may
never become visible to another due to CPU caching and reordering. This
visibility guarantee is why you can't fix a race with synchronized on writes
but unsynchronized reads — both sides must use the lock.
They coordinate threads on an object's monitor. wait() releases the
lock and parks the thread until another thread calls notify()/notifyAll()
on the same object; the woken thread must re-acquire the lock before
continuing. notify() wakes one waiting thread, notifyAll() wakes all.
synchronized (queue) {
while (queue.isEmpty()) // guard in a loop, not an if
queue.wait(); // releases lock, sleeps until notified
process(queue.remove());
}
// producer:
synchronized (queue) { queue.add(x); queue.notifyAll(); }
You must hold the monitor to call any of them, or you get
IllegalMonitorStateException. Prefer notifyAll() unless you can prove a
single waiter is always the right one.
Because a thread can return from wait() for reasons other than the condition
becoming true: spurious wakeups (the JVM is allowed to wake a thread for no
reason) and the fact that with notifyAll() multiple threads wake and race,
so by the time one re-acquires the lock the condition may be false again.
// WRONG — assumes the condition holds after waking
if (queue.isEmpty()) queue.wait();
// RIGHT — re-checks after every wakeup
while (queue.isEmpty()) queue.wait();
The while loop re-tests the predicate after re-acquiring the lock, so the
thread only proceeds when the condition is genuinely satisfied. Using if
instead of while here is one of the most common concurrency bugs.
When you call wait(), notify(), or notifyAll() on an object without
holding that object's monitor — i.e. outside a synchronized block/method
locked on the same object.
Object lock = new Object();
lock.wait(); // IllegalMonitorStateException — no lock held
synchronized (lock) {
lock.wait(); // fine — we own the monitor
}
The rule exists because these methods atomically manipulate the wait set and the
lock, which only makes sense if the caller already owns the monitor. A frequent
mistake is calling notify() on a different object than the one threads are
waiting on — same exception.
java.util.concurrent.locks.Lock is an explicit lock object whose API
offers capabilities intrinsic locks lack: tryLock() (non-blocking or
timed), lockInterruptibly() (abandon a lock attempt on interrupt),
fairness policies, and multiple Conditions per lock.
Lock lock = new ReentrantLock();
lock.lock();
try {
// critical section
} finally {
lock.unlock(); // MUST unlock in finally
}
The trade-off: it's manual — you must unlock() in a finally, or a thrown
exception leaks the lock forever. Use synchronized for simple cases (it can't
leak); reach for Lock when you need try/timeout/interruptible acquisition or
separate wait conditions.
Unlike synchronized, an explicit Lock is not released automatically when
a block exits or an exception propagates. If you lock() and then the body
throws, the lock stays held — every other thread blocks forever (a deadlock you
caused by leaking the lock).
lock.lock();
try {
risky(); // may throw
} finally {
lock.unlock(); // always runs, even on exception
}
Also acquire the lock outside the try (or as its first statement) so you
never call unlock() for a lock you didn't get. This boilerplate is the price
of the extra flexibility Lock gives you.
tryLock() attempts to acquire the lock without blocking: it returns true
if it got the lock, false immediately if another thread holds it. The timed
overload tryLock(time, unit) waits up to a bound, then gives up.
if (lock.tryLock()) {
try { doWork(); } finally { lock.unlock(); }
} else {
// lock busy — do something else instead of blocking
}
if (lock.tryLock(2, TimeUnit.SECONDS)) { ... } // bounded wait
This enables deadlock avoidance (back off if you can't get all needed locks)
and responsiveness (don't hang indefinitely). Only call unlock() when
tryLock() actually returned true.
lockInterruptibly() acquires the lock but responds to interruption while
waiting: if another thread interrupts the blocked thread, it throws
InterruptedException instead of waiting indefinitely. Plain synchronized and
lock() are not interruptible — a thread stuck waiting on them can't be
cancelled.
try {
lock.lockInterruptibly();
try { doWork(); } finally { lock.unlock(); }
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag, bail out
}
This matters for cancellable tasks and graceful shutdown: a worker waiting for a contended lock can be told to stop rather than hanging the whole system.
A fair lock grants access in FIFO order — the longest-waiting thread
acquires it next. The default ReentrantLock (and synchronized) is
unfair: it may let a newly-arriving thread "barge" ahead of queued waiters.
Lock fair = new ReentrantLock(true); // FIFO, no starvation
Lock unfair = new ReentrantLock(); // default, higher throughput
Fairness prevents starvation but costs throughput, because barging lets the lock stay hot on a CPU that already has it cached and avoids context switches. Use a fair lock only when you have measured starvation; otherwise the faster unfair default is preferred.
A ReadWriteLock (impl: ReentrantReadWriteLock) splits one lock into a
read lock and a write lock. Multiple threads may hold the read lock
simultaneously (reads don't conflict), but the write lock is exclusive —
no other readers or writers while a writer holds it.
ReadWriteLock rw = new ReentrantReadWriteLock();
rw.readLock().lock();
try { return cache.get(key); } finally { rw.readLock().unlock(); }
rw.writeLock().lock();
try { cache.put(k, v); } finally { rw.writeLock().unlock(); }
It shines for read-heavy, write-rare data (caches, config) because readers no longer serialize against each other. Under heavy writes it offers little over a plain lock, and writers can starve unless you choose a fair policy.
StampedLock (Java 8) is a more performant alternative offering three modes:
writing, reading, and a unique optimistic read. Each acquisition returns a
stamp you pass to the release method. The optimistic read takes no lock
— you read, then validate() the stamp to check no writer intervened.
long stamp = sl.tryOptimisticRead(); // no locking
int x = field;
if (!sl.validate(stamp)) { // a writer slipped in?
stamp = sl.readLock(); // fall back to a real read lock
try { x = field; } finally { sl.unlockRead(stamp); }
}
It's faster under read-heavy load but not reentrant and trickier to use
correctly. Use it as a tuned replacement for ReadWriteLock only when profiling
justifies the complexity.
A Condition is the Lock equivalent of wait/notify: await() parks and
releases the lock, signal()/signalAll() wake waiters. The key advantage is
that one Lock can have multiple Conditions, so you can wait on and
signal distinct predicates separately instead of one shared wait set.
Lock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
// producer waits on notFull / signals notEmpty; consumer the reverse
As with wait, you must hold the lock and loop on the predicate. Separate conditions let a bounded buffer signal only the threads that can actually proceed, avoiding the "wake everyone" waste of a single monitor.
A deadlock is two or more threads each waiting for a lock the other holds, so none can ever proceed. It requires all four Coffman conditions simultaneously:
| Condition | Meaning |
|---|---|
| Mutual exclusion | a resource is held exclusively |
| Hold and wait | a thread holds one lock while requesting another |
| No preemption | locks can't be forcibly taken away |
| Circular wait | a cycle of threads each waiting on the next |
// Thread 1: synchronized(A){ synchronized(B){} }
// Thread 2: synchronized(B){ synchronized(A){} } // opposite order -> deadlock
Break any one condition to prevent it — the practical lever is eliminating circular wait via consistent lock ordering.
Impose a global, consistent order in which all threads acquire locks. If every thread always grabs locks in the same sequence, a cycle is impossible, so circular wait — and thus deadlock — can't occur.
// order by a stable key (e.g. account id) so both threads agree
Account first = a.id < b.id ? a : b;
Account second = a.id < b.id ? b : a;
synchronized (first) {
synchronized (second) { transfer(a, b); }
}
Other tactics: use tryLock with a timeout and back off on failure, shrink lock
scope so you hold only one lock at a time, or replace fine-grained locks with a
single coarse lock. Lock ordering is the most common and reliable answer.
Starvation is when a thread is perpetually denied a resource it needs — e.g. low-priority threads never scheduled, or threads losing every race for an unfair lock. Livelock is when threads are active but make no progress: they keep responding to each other and repeatedly retrying without ever advancing.
// livelock: both keep yielding to each other, neither proceeds
while (!tryLock(a) || !tryLock(b)) { unlockAll(); /* retry immediately */ }
Unlike deadlock, livelocked threads aren't blocked — they burn CPU. Fixes include randomized back-off before retrying, fair locks to cure starvation, and ensuring some thread eventually wins.
Prefer a private final lock object dedicated to guarding the state. Avoid
locking on this (callers can lock your object and interfere), on a
String literal or Boolean/boxed Integer (interned/cached — other
unrelated code may lock the same instance), or on a mutable reference (the lock
identity changes).
private final Object lock = new Object(); // good: encapsulated
void update() { synchronized (lock) { ... } }
A private lock means only your class controls the monitor, so no external
code can cause deadlock or contention on it. Locking on this or a public field
leaks your synchronization policy to the world.
Collections.synchronizedList/Map/Set(...) wrap a collection so each
individual method is synchronized on the wrapper. That makes single calls
thread-safe, but compound operations (check-then-act, iteration) are not
atomic and still need external locking.
List<String> list = Collections.synchronizedList(new ArrayList<>());
list.add("x"); // atomic
synchronized (list) { // must lock manually to iterate
for (String s : list) { ... } // else ConcurrentModificationException
}
They also serialize every access on one lock, hurting throughput. For
concurrent use, prefer java.util.concurrent collections like
ConcurrentHashMap or CopyOnWriteArrayList, which use finer-grained or
lock-free strategies.
Granularity is how much data a single lock protects. Coarse-grained — one lock for everything — is simple and safe but kills concurrency, since unrelated operations serialize. Fine-grained — many small locks — allows more parallelism but risks deadlock and adds overhead.
// coarse: one lock, no concurrency between buckets
synchronized (map) { ... }
// fine: lock per bucket/segment -> independent buckets run in parallel
synchronized (bucketLocks[hash & MASK]) { ... }
ConcurrentHashMap is the classic example of fine-grained locking done right
(lock striping / per-bin locks). The engineering trade-off: start coarse for
correctness, then split locks only where contention is measured, since
finer locks compound deadlock and complexity risk.
More Concurrency interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.