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 Framework

Two basic approaches, plus the modern preferred one:

  • Extend Thread and override run() — simple, but uses up your single inheritance slot.
  • Implement Runnable and pass it to a Thread — 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.
  • synchronizedmutual 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 inside synchronized. 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.

  • Runnablerun() 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 synchronized instance method locks on this (the object). Two threads on different instances don't contend.
  • A synchronized static method locks on the Class object, 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 ConcurrentHashMap does).
  • 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.