Skip to content

Synchronization & Locks Interview Questions & Answers

24 questions Updated 2026-06-20 Share:

Java synchronization interview questions — the synchronized keyword and intrinsic locks, the Lock interface and ReentrantLock, ReadWriteLock, deadlock, and wait/notify coordination.

Read the in-depth guideJava Synchronization — synchronized, Locks, wait/notify & Deadlock(opens in new tab)
24 of 24

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 ways to practice

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

or
Join our WhatsApp Channel