Skip to content

Java · Concurrency

Java Synchronization — synchronized, Locks, wait/notify & Deadlock

11 min read Updated 2026-06-20 Share:

Practice Synchronization & Locks interview questions

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.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel