Threads & Synchronization Interview Questions & Answers
35 questions Updated 2026-06-18
Java concurrency interview questions — threads vs Runnable, synchronized, volatile, the memory model, deadlock, wait/notify, executors, futures, CompletableFuture and atomic classes.
Read the in-depth guideJava Concurrency — Threads, Synchronization & the Executor FrameworkTwo basic approaches, plus the modern preferred one:
- Extend
Threadand overriderun()— simple, but uses up your single inheritance slot. - Implement
Runnableand pass it to aThread— favored, because it separates the task from the thread and works with executors.
// Runnable (preferred)
Runnable task = () -> System.out.println("working");
new Thread(task).start();
// extending Thread
class Worker extends Thread { public void run() { } }
In real code you rarely create threads directly at all — you submit
Runnable/Callable tasks to an ExecutorService, which manages a pool
for you.
start() asks the JVM to create a new thread and invoke run() on it —
concurrent execution. Calling run() directly just executes the method on
the current thread, like any ordinary call — no concurrency at all.
Thread t = new Thread(() -> System.out.println(Thread.currentThread().getName()));
t.start(); // prints "Thread-0" — runs on a new thread
t.run(); // prints "main" — runs on the caller's thread
Also, calling start() twice on the same Thread throws
IllegalThreadStateException — a thread can be started only once.
A Thread moves through these Thread.State values:
- NEW — created but not started.
- RUNNABLE — eligible to run (running or waiting for CPU).
- BLOCKED — waiting to acquire a monitor lock.
- WAITING — waiting indefinitely (
wait(),join(),park()). - TIMED_WAITING — waiting with a timeout (
sleep,wait(ms)). - TERMINATED — finished or threw.
Thread t = new Thread(task);
t.getState(); // NEW
t.start();
t.getState(); // RUNNABLE
Transitions are driven by the scheduler and synchronization calls; you observe (not directly set) the state.
A race condition occurs when multiple threads access shared mutable state
concurrently and the outcome depends on the timing of their interleaving.
Operations that look atomic (like count++) are actually read-modify-write
sequences that can interleave and lose updates.
class Counter {
int count;
void inc() { count++; } // read, +1, write — NOT atomic
}
// 1000 threads each calling inc() once -> final count often < 1000
Fixes: make the operation atomic (synchronized, AtomicInteger), or avoid
shared mutable state. Race conditions are insidious because they're
intermittent and timing-dependent.
synchronized enforces mutual exclusion: only one thread can hold an
object's monitor lock at a time, so synchronized code runs serially. It also
establishes a happens-before relationship, guaranteeing memory visibility of
changes made under the lock.
class Counter {
private int count;
synchronized void inc() { count++; } // locks on `this`
void dec() {
synchronized (this) { count--; } // synchronized block
}
}
A synchronized instance method locks on this; a synchronized static method
locks on the Class object. Keep critical sections small to limit
contention.
volatile guarantees visibility and ordering: writes by one thread are
immediately visible to others (no per-thread caching), and it prevents
reordering around the access. What it does not provide is atomicity for
compound operations.
volatile boolean running = true; // flag — visibility is enough
void stop() { running = false; } // other threads see this promptly
volatile int count;
count++; // still a race — read-modify-write isn't atomic
Use volatile for flags/state published once (the classic stop-flag), and
synchronized/Atomic* when you need atomic compound updates.
volatile— only visibility + ordering for a single variable. No locking, no atomicity for compound ops. Cheap.synchronized— mutual exclusion + visibility. Makes whole code blocks atomic, can guard multiple variables, but threads can block on the lock.
volatile boolean flag; // visibility only
synchronized void transfer() { // atomicity across several fields
from -= amt; to += amt;
}
Quick rule: a single flag/reference written-then-read -> volatile; a
multi-step invariant or counter -> synchronized (or an atomic/lock).
A deadlock is two+ threads each holding a lock the other needs, so all wait forever. It requires four conditions (Coffman): mutual exclusion, hold-and-wait, no preemption, and circular wait.
// Thread 1: lock A then B | Thread 2: lock B then A -> deadlock
synchronized (a) { synchronized (b) { } }
synchronized (b) { synchronized (a) { } }
Prevention: acquire locks in a consistent global order, use timeouts
(tryLock), reduce lock scope, or avoid multiple locks entirely. Breaking any
one Coffman condition (usually circular wait, via lock ordering) prevents
deadlock.
- Starvation — a thread is perpetually denied the resources/CPU it needs (e.g. low-priority threads, or always losing a lock to greedier ones), so it never makes progress.
- Livelock — threads are actively running but keep responding to each other in a way that prevents progress (like two people stepping aside in the same direction repeatedly).
// livelock sketch: both threads keep "politely" backing off
while (other.isActive()) { backOff(); /* never proceeds */ }
Unlike deadlock (threads blocked), livelocked threads consume CPU. Fixes include randomized back-off, fairness policies, and bounded retries.
They coordinate threads on an object's monitor and must be called while
holding that object's lock (inside synchronized). wait() releases the lock
and suspends the thread until notified; notify() wakes one waiter,
notifyAll() wakes all.
synchronized (queue) {
while (queue.isEmpty()) { // ALWAYS loop, never a bare if
queue.wait(); // releases lock, waits
}
process(queue.remove());
}
// producer:
synchronized (queue) { queue.add(item); queue.notifyAll(); }
Always re-check the condition in a while loop (spurious wakeups + stale
conditions). Prefer notifyAll unless you're certain one waiter suffices.
Thread.sleep(ms)— a static method that pauses the current thread for a time without releasing any locks it holds. For timing/throttling.Object.wait()— an instance method that releases the object's lock and waits to be notified; must be called insidesynchronized. For coordination between threads.
synchronized (lock) {
Thread.sleep(100); // keeps holding `lock` the whole time
lock.wait(); // releases `lock` while waiting
}
Key distinction: sleep keeps the lock, wait gives it up. Using sleep for
inter-thread coordination is a classic anti-pattern.
An ExecutorService manages a pool of reusable threads and a task queue, so
you submit work instead of creating threads by hand. This caps the thread count,
reuses threads (avoiding creation overhead), and decouples task submission from
execution.
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Integer> f = pool.submit(() -> compute());
pool.execute(() -> fireAndForget());
pool.shutdown(); // stop accepting; finish queued tasks
pool.awaitTermination(1, TimeUnit.MINUTES);
Creating a thread per task doesn't scale — unbounded threads exhaust memory and
thrash the scheduler. Always shutdown() a pool to release its threads.
newFixedThreadPool(n)— fixed number of threads, unbounded queue.newCachedThreadPool()— grows/shrinks on demand; reuses idle threads.newSingleThreadExecutor()— one thread, sequential task processing.newScheduledThreadPool(n)— for delayed/periodic tasks.newVirtualThreadPerTaskExecutor()(Java 21) — a virtual thread per task.
var scheduled = Executors.newScheduledThreadPool(2);
scheduled.scheduleAtFixedRate(this::poll, 0, 5, TimeUnit.SECONDS);
In production many teams construct ThreadPoolExecutor directly to control
the queue bounds and rejection policy, since newFixedThreadPool's unbounded
queue can hide backpressure problems.
Runnable—run()returns nothing and can't throw checked exceptions.Callable<V>—call()returns a value and may throw checked exceptions.Future<V>— a handle to a pending result;get()blocks until it's ready,isDone()/cancel()manage it.
Callable<Integer> task = () -> 6 * 7;
Future<Integer> f = pool.submit(task);
Integer result = f.get(); // blocks until done -> 42
Future.get() is blocking, which is its main weakness — addressed by
CompletableFuture's non-blocking composition.
CompletableFuture is a Future you can compose and chain
asynchronously — attaching callbacks instead of blocking on get(), and
combining multiple async results.
CompletableFuture.supplyAsync(() -> fetchUser(id))
.thenApply(User::name) // transform when ready
.thenCompose(name -> lookupAsync(name)) // chain another async call
.exceptionally(ex -> "fallback") // handle errors
.thenAccept(System.out::println); // consume, non-blocking
CompletableFuture.allOf(f1, f2, f3).join(); // wait for several
It enables a non-blocking pipeline (thenApply/thenCompose/thenCombine),
built-in error handling, and easy fan-out/fan-in — far more flexible than the
blocking Future.
java.util.concurrent.atomic provides lock-free, thread-safe variables —
AtomicInteger, AtomicLong, AtomicReference, etc. They use CAS
(compare-and-swap) hardware instructions for atomic updates without locking.
AtomicInteger count = new AtomicInteger();
count.incrementAndGet(); // atomic ++
count.compareAndSet(5, 10); // set to 10 only if currently 5
ref.updateAndGet(v -> v + delta); // atomic functional update
Atomics outperform synchronized for simple counters/flags under contention
(no blocking), though heavy contention can cause CAS retry loops. For high
contention prefer LongAdder.
CAS is an atomic CPU instruction: "if this memory location still holds the expected value, replace it with the new value, atomically; otherwise do nothing and report failure." It's the foundation of lock-free algorithms.
// typical lock-free retry loop
int prev, next;
do {
prev = atomic.get();
next = prev + 1;
} while (!atomic.compareAndSet(prev, next)); // retry if another thread changed it
Because no lock is held, there's no blocking or deadlock — but it can spin under
contention, and it has the ABA problem (a value changing A->B->A looks
unchanged), which AtomicStampedReference solves with a version stamp.
ReentrantLock is an explicit lock with the same mutual-exclusion semantics as
synchronized but more features: tryLock() (with timeout), interruptible
locking, fairness policy, and multiple Condition objects.
ReentrantLock lock = new ReentrantLock();
lock.lock();
try { /* critical section */ }
finally { lock.unlock(); } // MUST unlock in finally
if (lock.tryLock(1, TimeUnit.SECONDS)) { ... } // give up after 1s
Trade-off: synchronized is simpler and auto-releases on block exit;
ReentrantLock is more powerful but you must unlock() in finally or you
leak the lock. Use it only when you need its extra capabilities.
A ReadWriteLock separates a read lock (shared — many readers at once) from
a write lock (exclusive). It boosts throughput for read-heavy data where
writes are rare, since concurrent reads don't block each other.
ReadWriteLock rw = new ReentrantReadWriteLock();
rw.readLock().lock(); try { return cache.get(k); } finally { rw.readLock().unlock(); }
rw.writeLock().lock(); try { cache.put(k, v); } finally { rw.writeLock().unlock(); }
Only one writer runs at a time and it excludes all readers. For very read-heavy
cases, StampedLock (Java 8) adds optimistic reads that are even faster.
ThreadLocal<T> gives each thread its own independent copy of a variable,
so there's no sharing and no synchronization needed. Common for per-thread
context: SimpleDateFormat (not thread-safe), user/request context, DB
connections.
static final ThreadLocal<SimpleDateFormat> FMT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
String today = FMT.get().format(new Date()); // safe per-thread instance
Caution in thread pools: threads are reused, so you must remove() the
value after use or it leaks into the next task (and can cause memory leaks).
The Java Memory Model (JMM) defines when writes by one thread become visible to another and what reorderings are allowed. The core concept is happens-before: if action A happens-before B, A's effects are guaranteed visible to B.
Happens-before edges include: program order within a thread; unlocking a monitor
-> subsequent locking of it; a volatile write -> subsequent read; Thread.start
-> the thread's actions; a thread's actions -> another's join.
volatile boolean ready;
int data;
// Thread A:
data = 42; ready = true; // volatile write publishes `data`
// Thread B:
if (ready) System.out.println(data); // sees 42 — happens-before guarantees it
Without a happens-before relationship, one thread may never see another's
writes (or see them reordered). This is why volatile/synchronized matter.
Thread-safe code behaves correctly when accessed by multiple threads
concurrently, without external synchronization, regardless of timing. You
achieve it by: avoiding shared mutable state, immutability,
synchronization, atomic variables, or thread-confinement (ThreadLocal).
// thread-safe by immutability — no state can change
record Point(int x, int y) { }
// thread-safe by synchronization
class SafeCounter { private int n; synchronized void inc() { n++; } }
Levels range from immutable (always safe) -> thread-safe (handles its own
sync) -> conditionally safe (some ops need external sync) -> not
thread-safe (ArrayList, HashMap). The cheapest safety is having no shared
mutable state at all.
Producers add items to a shared buffer, consumers remove them; they must
coordinate so producers wait when full and consumers wait when empty. The
idiomatic modern way is a BlockingQueue, which handles all the
waiting/notifying internally.
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100);
// producer
queue.put(task); // blocks if the queue is full
// consumer
Task t = queue.take(); // blocks if the queue is empty
put/take block automatically — no manual wait/notify. ArrayBlockingQueue
(bounded) gives natural backpressure; LinkedBlockingQueue can be bounded or
unbounded.
Both are synchronization aids that make threads wait for each other:
CountDownLatch— threads wait until a counter reaches zero; one-shot (can't be reset). "Wait for N tasks to finish."CyclicBarrier— a fixed number of threads wait for each other at a barrier point; reusable across rounds.
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++)
pool.submit(() -> { work(); latch.countDown(); });
latch.await(); // main thread blocks until all 3 finish
CountDownLatch is for "main waits for workers"; CyclicBarrier is for "workers
sync up repeatedly." Semaphore is a related tool that limits concurrent access
to N permits.
A daemon thread is a background thread that does not prevent the JVM from
exiting. The JVM shuts down once only daemon threads remain, abruptly stopping
them (their finally blocks may not run). Used for support tasks like GC,
housekeeping, and monitoring.
Thread t = new Thread(this::poll);
t.setDaemon(true); // must be set BEFORE start()
t.start();
User (non-daemon) threads, by contrast, keep the JVM alive until they finish.
Set the daemon flag before start() — changing it after throws
IllegalThreadStateException. Don't use daemons for work that must complete.
Interruption is a cooperative signal, not a forced stop. interrupt() sets a
thread's interrupt flag; the thread must check it (isInterrupted()) or be
in a blocking call that throws InterruptedException. There is no safe way to
forcibly kill a thread (Thread.stop() is deprecated and dangerous).
while (!Thread.currentThread().isInterrupted()) {
try {
doWork();
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag, then exit
break;
}
}
Best practice: when you catch InterruptedException and can't propagate it,
restore the interrupt status with Thread.currentThread().interrupt() so
callers still see it.
Collections.synchronizedMap locks the entire map for every operation, so
threads serialize even on unrelated keys — a bottleneck. ConcurrentHashMap
uses fine-grained locking / CAS on individual bins, allowing many threads to
read and write different buckets concurrently.
Map<String,Integer> m = new ConcurrentHashMap<>();
m.merge(word, 1, Integer::sum); // atomic compound update, no global lock
m.computeIfAbsent(k, x -> load(x)); // atomic
It also offers atomic compound methods (merge, computeIfAbsent,
putIfAbsent) and fail-safe iteration. The result is dramatically better
throughput under concurrency.
False sharing is a performance problem (not a correctness bug) where two threads modify different variables that happen to sit on the same CPU cache line. Each write invalidates the whole line in the other core's cache, forcing expensive cache-coherence traffic even though the variables are independent.
// a[0] and a[1] may share a cache line; two threads hammering them contend
long[] counters = new long[2];
// padding or @Contended separates hot fields onto different lines
Mitigations: pad hot fields apart, use @jdk.internal.vm.annotation.Contended,
or use LongAdder, which internally stripes counters across cells to avoid
contention.
Each thread has a priority from 1 (MIN_PRIORITY) to 10 (MAX_PRIORITY),
default 5. It's only a hint to the OS scheduler about relative importance —
not a guarantee. Behavior is platform-dependent and often ignored.
Thread t = new Thread(task);
t.setPriority(Thread.MAX_PRIORITY); // best-effort hint only
Don't build correctness on priorities — they can't ensure ordering and, on some platforms, do almost nothing. For real coordination use explicit synchronization or executor configuration.
The difference is which lock is acquired:
- A
synchronizedinstance method locks onthis(the object). Two threads on different instances don't contend. - A
synchronizedstatic method locks on theClassobject, shared by all instances.
class C {
synchronized void a() { } // locks on this instance
static synchronized void b() { } // locks on C.class (one global lock)
}
A subtle bug: a() and b() use different locks, so they can run
simultaneously even though both are "synchronized." Mixing instance and static
synchronization on shared state doesn't protect it.
Double-checked locking lazily initializes a singleton while only locking on the first call:
class Singleton {
private static volatile Singleton instance; // volatile is essential
static Singleton get() {
if (instance == null) { // 1st check (no lock)
synchronized (Singleton.class) {
if (instance == null) { // 2nd check (locked)
instance = new Singleton();
}
}
}
return instance;
}
}
Without volatile, another thread could see a partially constructed
object: instance = new Singleton() isn't atomic (allocate, construct, assign),
and reordering could publish the reference before construction finishes.
volatile forbids that reordering. (An enum or holder-class singleton avoids the
whole issue.)
- Blocking algorithms use locks; a thread that can't proceed is suspended (BLOCKED/WAITING) until the lock frees. Simple, but a slow/dead lock-holder stalls others (and risks deadlock).
- Non-blocking (lock-free) algorithms use CAS retry loops; a thread never suspends — it retries until it succeeds. No deadlock, better scalability, but harder to write and can livelock/spin.
// non-blocking counter
AtomicLong c = new AtomicLong();
c.incrementAndGet(); // CAS loop under the hood — never blocks
java.util.concurrent provides both: ReentrantLock/BlockingQueue (blocking)
and Atomic*/ConcurrentLinkedQueue (non-blocking).
Virtual threads (Java 21) are lightweight threads managed by the JVM, not the OS. Millions can exist at once because a blocked virtual thread is unmounted from its carrier OS thread, freeing it for other work. This makes the simple "thread-per-request" blocking style scale like async code.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var task : tasks) executor.submit(task); // a virtual thread each
}
They're cheap to create and ideal for I/O-bound workloads (many tasks blocking on network/DB). For CPU-bound work, platform threads sized to the cores are still the right tool — virtual threads don't add CPU.
Lock contention occurs when many threads compete for the same lock, so they serialize and queue up — throughput collapses as cores sit idle waiting. The fix is to reduce how often and how long threads hold shared locks.
Strategies:
- Shrink the critical section — hold the lock for as little code as possible.
- Lock striping — split one lock into many (what
ConcurrentHashMapdoes). - Use atomics / lock-free structures for simple state.
- Immutable or thread-local data to avoid sharing.
- Read-write locks when reads dominate.
// whole method locked // lock only the shared mutation
synchronized void m() { void m() {
var x = expensive(); var x = expensive();
shared.add(x); synchronized (shared) { shared.add(x); }
} }
Use the standard two-phase shutdown so in-flight tasks finish and threads are released (a pool's non-daemon threads otherwise keep the JVM alive):
pool.shutdown(); // stop accepting new tasks
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // interrupt running tasks
pool.awaitTermination(10, TimeUnit.SECONDS);
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
shutdown() is graceful (finish queued work); shutdownNow() interrupts active
tasks and returns the unstarted ones. Forgetting to shut down a pool is a common
resource/thread leak.
Practice tests are coming soon
Get notified when interactive mock interviews and quizzes launch.