Java Concurrency Interview Questions and Answers

36 Java concurrency interview questions with concise answers and examples covering thread states, synchronization, visibility, race conditions, executors and concurrent collections.

These Java concurrency interview questions cover thread lifecycle, synchronization, visibility, atomic operations and task execution. Explain the guarantee each mechanism provides, then identify what it does not guarantee. Concurrency means tasks make overlapping progress; parallelism means work actually executes simultaneously.

Most low-level examples below use platform threads. Virtual-thread differences are identified where they affect the answer. Treat timing and scheduling output as observations from an example rather than promises about execution order.

1. Basic Concepts

1.1. How Do Processes, Threads, and Context Switches Differ?

A process has its own address space and operating-system resources. Threads within a process share its heap but have separate call stacks. Java platform threads are scheduled by the operating system; virtual threads are scheduled by the JVM onto carrier threads.

Process A: heap A ─ thread 1, thread 2
Process B: heap B ─ thread 3

A context switch changes which thread uses a processor and can incur scheduling overhead. Shared mutable data between threads needs coordination. Separate processes can also coordinate through files, sockets, or shared memory; process isolation alone does not remove every concurrency problem.

1.2. How Can We Start a Java Thread?

Create a Runnable task and pass it to a platform thread, or start a virtual thread for a task that spends much of its time waiting on blocking operations. Calling start() schedules a new thread; calling run() directly executes the method on the current thread.

Runnable task = () -> System.out.println(Thread.currentThread());
Thread platform = new Thread(task);
platform.start();
platform.join();

Thread virtual = Thread.startVirtualThread(task);
virtual.join();

Both tasks can run concurrently with the caller. Their print order is unspecified. Use an executor when managing many tasks rather than constructing one platform thread for each task.

1.3. What Are the Java Thread States?

Diagram of Java thread states and their transitions

Thread.State reports NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, or TERMINATED. RUNNABLE covers both executing and ready-to-run work; Java does not expose a separate running state. BLOCKED means waiting to enter a monitor. WAITING and TIMED_WAITING include waits such as join() or sleep().

Thread worker = new Thread(() -> {});
System.out.println(worker.getState()); // NEW
worker.start();
worker.join();
System.out.println(worker.getState()); // TERMINATED

An intermediate state is timing dependent, so do not assert that a sampling call will observe it. Completing run() terminates the thread. Avoid the deprecated stop() and suspend() methods.

1.4. Does Thread Priority Guarantee Execution Order?

No. Platform-thread priorities are scheduler hints whose effect depends on the operating system. A new platform thread normally inherits its creator’s priority, subject to the allowed range. Virtual threads do not use priority to schedule work.

Thread worker = new Thread(() -> System.out.println("work"));
worker.setPriority(Thread.NORM_PRIORITY);
System.out.println(worker.getPriority()); // 5
worker.start();
worker.join();

The printed priority describes a setting, not an execution-order promise. Use synchronization or a task dependency when order matters.

1.5. What Is the Difference Between a Daemon and a Non-Daemon Thread?

A live non-daemon thread keeps ordinary JVM shutdown from starting. A daemon thread does not. Daemon status is not a priority level and does not guarantee that cleanup code will run before shutdown.

Thread worker = new Thread(() -> System.out.println("background"));
worker.setDaemon(true); // before start()
worker.start();
System.out.println(worker.isDaemon()); // true

Set daemon status before starting a platform thread; changing it afterward throws IllegalThreadStateException. Virtual threads are always daemon threads. Complete essential work through explicit lifecycle management instead of relying on daemon completion.

1.6. What Does a Fair ReentrantLock Promise?

A fair ReentrantLock favors the longest-waiting thread under contention. It does not guarantee fair CPU scheduling or strict order when a thread uses an untimed tryLock() call.

java.util.concurrent.locks.ReentrantLock lock =
    new java.util.concurrent.locks.ReentrantLock(true);
lock.lock();
try {
    // protect shared state
} finally {
    lock.unlock();
}

Fairness can reduce throughput, so use it only when starvation risk matters for the workload.

2. Thread Synchronization

2.1. How Do Synchronized Methods and Blocks Work?

A synchronized instance method locks this for its body; a static synchronized method locks the class object. A synchronized block lets you name the monitor and limit the protected region. All operations on the same shared invariant must use the same locking rule.

class Account {
    private int balance;
    synchronized void deposit(int amount) { balance += amount; }
    synchronized int balance() { return balance; }
}

The monitor prevents concurrent execution of these methods on the same account, and unlocking makes changes visible to a later lock of that monitor. It does not stop unsynchronized code from reading an exposed field; keep protected fields private.

2.2. When Is a Synchronized Block Useful?

A block can hold the lock only while updating shared state, leaving unrelated work outside it. The smaller region may reduce contention, but only if every access to the protected invariant follows the same protocol.

class Basket {
    private int items;
    void add() {
        String logMessage = "adding item"; // no shared state here
        synchronized (this) { items++; }
        System.out.println(logMessage);
    }
    synchronized int size() { return items; }
}

The increment and read use the same monitor. The printing order across threads is unspecified.

2.3. How Does synchronized Use an Object Monitor?

Every Java object has an associated monitor. A synchronized instance method acquires the receiver object’s monitor before executing its body. A static synchronized method instead uses the Class object’s monitor. The lock is released when the method returns or throws an exception.

class Counter {
    private int value;

    synchronized void increment() {
        value++;
    }

    synchronized int get() {
        return value;
    }
}

Two threads calling increment() on the same Counter cannot execute its body at the same time. A call to an ordinary, unsynchronized method does not wait for that monitor. Synchronization also establishes a happens-before relationship: an unlock is visible to a later lock of the same monitor. The example protects both the write and the read; synchronizing only the write would not make an unsynchronized read safe.

2.4. Why Can’t an Abstract Method Be Synchronized?

An abstract method has no body in which to acquire and release a monitor, so Java rejects that modifier combination. A concrete implementation can be synchronized.

abstract class Ledger {
    abstract void record(int amount);
}
class SafeLedger extends Ledger {
    private int total;
    @Override synchronized void record(int amount) { total += amount; }
}

The lock applies to calls of SafeLedger.record() on the same instance, including calls through a Ledger reference.

3. Difference between

3.1. How Do yield(), join(), and sleep() Differ?

yield() hints that the scheduler may let another thread run; it gives no ordering guarantee. sleep() pauses the current thread for at least an approximate duration without releasing monitors it holds. join() waits for a particular thread to terminate.

Thread worker = new Thread(() -> System.out.println("work"));
worker.start();
worker.join(); // work has finished when this returns normally
Thread.sleep(10); // current thread pauses
Thread.yield();   // scheduler may ignore this hint

join() and sleep() can throw InterruptedException. Use join() or a task result when completion matters; neither sleeping nor yielding proves a task has finished.

3.2. How Do notify() and notifyAll() Differ?

Both methods are called while holding an object’s monitor. notify() chooses one thread waiting on that monitor; notifyAll() wakes all of them. A woken thread still has to reacquire the monitor before it can continue, so neither method guarantees which thread runs next.

notify()    → one waiting thread becomes eligible to reacquire the monitor
notifyAll() → every waiting thread becomes eligible; each rechecks its condition
Deque<String> queue = new ArrayDeque<>();
String job = "task-1";
synchronized (queue) {
    queue.add(job);
    queue.notifyAll();
}

When producers and consumers wait on the same monitor for different conditions, waking everyone lets each thread recheck its own condition. Each waiter must use a loop around wait() because wakeups do not prove its condition is now true. Prefer BlockingQueue for a producer-consumer design instead of building this protocol by hand.

3.3. How Do Callable and Runnable Differ?

Runnable.run() returns no result and cannot declare a checked exception. Callable.call() returns a value and may declare an exception. Submit either to an executor; use a Future when the caller needs to observe completion or failure.

Runnable log = () -> System.out.println("started");
Callable<Integer> answer = () -> 2 + 3;
try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
    executor.submit(log);
    System.out.println(executor.submit(answer).get()); // 5
}

The call to get() waits for the Callable result. If the task fails, get() reports an ExecutionException whose cause is the task’s exception.

3.4. How Do Object and Class Monitors Differ?

A synchronized instance method uses its receiver’s monitor. Two different instances have different monitors. A static synchronized method uses the single Class object monitor for that class.

class Gate {
    synchronized void instanceWork() { /* locks this Gate instance */ }
    static synchronized void classWork() { /* locks Gate.class */ }
}

The class monitor and an instance monitor are distinct, so calling these two methods does not mutually exclude the other. Choose one consistent monitor for each shared invariant.

4. Deadlock, Livelock, and Race Condition

4.1. What Causes Deadlock?

Diagram of two threads waiting on locks held by each other

Deadlock occurs when threads wait in a cycle for resources held by each other. For example, one thread holds left and waits for right, while another holds right and waits for left.

Thread A holds left  → waits for right
Thread B holds right → waits for left

The lock-order cycle prevents both from progressing. Give every multi-lock operation the same acquisition order, or use a timed lock attempt with a well-defined retry policy.

4.2. How Do Deadlock, Livelock, and Starvation Differ?

Deadlocked threads cannot move because each waits for another. Livelocked threads keep changing state, such as repeatedly backing off for each other, but still make no useful progress. A starving thread remains able to run in principle but repeatedly loses access to a needed resource.

deadlock   → both wait forever
livelock   → both retry forever without completing
starvation → one thread repeatedly misses its turn

Consistent lock order helps prevent deadlock. Bounded retries or randomized backoff can address some livelocks; fair access policies can reduce starvation, though scheduling guarantees must be checked for the specific API.

4.3. How Can a Program Demonstrate Deadlock?

Deadlock occurs when each thread holds a lock the other needs, so neither can continue. In this example, each thread acquires one lock, pauses to let the other thread acquire its first lock, then tries to acquire the second lock.

Object left = new Object();
Object right = new Object();
java.util.concurrent.CountDownLatch bothLocked =
    new java.util.concurrent.CountDownLatch(2);

Thread first = new Thread(() -> {
    synchronized (left) {
        bothLocked.countDown();
        try { bothLocked.await(); } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return;
        }
        synchronized (right) { System.out.println("first done"); }
    }
}, "first");
Thread second = new Thread(() -> {
    synchronized (right) {
        bothLocked.countDown();
        try { bothLocked.await(); } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return;
        }
        synchronized (left) { System.out.println("second done"); }
    }
}, "second");
first.setDaemon(true);
second.setDaemon(true);
first.start();
second.start();

Do not use this pattern in an application: the threads cannot complete. The daemon setting merely lets this demonstration exit. Acquire multiple locks in the same global order to prevent this particular deadlock. A thread dump can show the two threads and the monitors each holds and awaits.

4.4. What Is a Race Condition, and How Can We Fix One?

A race condition means the result depends on how operations from different threads interleave. Incrementing an ordinary field reads, adds, and writes; two threads can read the same old value and lose one increment.

java.util.concurrent.atomic.AtomicInteger count =
    new java.util.concurrent.atomic.AtomicInteger();
Thread a = new Thread(count::incrementAndGet);
Thread b = new Thread(count::incrementAndGet);
a.start();
b.start();
a.join();
b.join();
System.out.println(count.get()); // 2

The atomic operation prevents a lost update here. For a larger invariant involving several fields, protect the whole read-modify-write operation with a common lock or another suitable coordination mechanism. Repeated test runs may reveal a race, but a passing run cannot prove it absent.

5. Thread Safety

5.1. What Makes a Class Thread Safe?

A class is thread safe when its documented operations behave correctly under concurrent use without requiring callers to add synchronization. The guarantee applies to the operations the class actually documents; it does not automatically make a sequence of separate calls atomic.

class Counter {
    private int value;

    synchronized void increment() { value++; }
    synchronized int get() { return value; }
}

Each method uses the same monitor, so the increment is atomic and a later synchronized read sees the updated value. A caller that needs to test a value and increment it as one operation would need a new method that performs both actions under the lock.

5.2. What Does ThreadLocal Store?

A ThreadLocal associates a separate value with each thread using the same variable. It can hold per-thread context, but it does not synchronize access to a shared object placed inside that context.

ThreadLocal<String> requestId = new ThreadLocal<>();
requestId.set("req-42");
try {
    System.out.println(requestId.get()); // req-42 on this thread
} finally {
    requestId.remove();
}

Remove values when work ends, particularly on reused pool threads, so later tasks do not inherit stale context. Values do not automatically follow work to another thread. For structured context propagation, consider ScopedValue where suitable.

5.3. How Does InheritableThreadLocal Differ From ThreadLocal?

A new child thread receives an initial value from its parent’s InheritableThreadLocal. A plain ThreadLocal does not copy a parent value. The inheritance happens when the child thread is created, so thread pools are a poor fit: their existing workers do not receive each submitting task’s current value.

InheritableThreadLocal<String> context = new InheritableThreadLocal<>();
context.set("parent");
Thread child = new Thread(() -> System.out.println(context.get()));
child.start();
child.join(); // prints parent
context.remove();

The value is a reference. Copying the reference does not make a mutable object thread safe.

5.4. What Does volatile Guarantee?

Diagram of thread-local working memory and shared main memory

A write to a volatile field is visible to a later read of that field, and the write happens before that read. It also restricts reordering around the access. It does not make a compound operation such as count++ atomic.

class StopFlag {
    private volatile boolean stopped;

    void stop() { stopped = true; }
    boolean isStopped() { return stopped; }
}

This flag works for one thread requesting that another stop, provided the worker checks it. Use an atomic class or a lock when an update must combine a read with a write.

5.5. Which Operations Are Atomic, and When Are Atomic Classes Useful?

An atomic operation appears indivisible to other threads. Java’s atomic classes supply operations such as increment and compare-and-set without making callers manage a monitor.

java.util.concurrent.atomic.AtomicInteger stock =
    new java.util.concurrent.atomic.AtomicInteger(5);
int previous = stock.getAndDecrement();
System.out.println(previous);  // 5
System.out.println(stock.get()); // 4

Choose the operation that matches the invariant. This example can decrement below zero; checking get() > 0 before getAndDecrement() would not be one atomic action. A compare-and-set loop or a lock is needed to enforce a nonnegative stock count.

6. Executor Framework

6.1. What Does ExecutorService Do?

An ExecutorService runs submitted tasks and manages their lifecycle. A fixed pool limits how many tasks run concurrently; a single-thread executor runs tasks one at a time; a scheduled executor can run tasks later or repeatedly. Choose an executor that fits the workload and close or shut it down when finished.

try (java.util.concurrent.ExecutorService executor =
         java.util.concurrent.Executors.newFixedThreadPool(2)) {
    java.util.concurrent.Future<Integer> result =
        executor.submit(() -> 21 * 2);
    System.out.println(result.get()); // 42
}

The Future lets the caller wait for the result and observe failure. A pool size of two is illustrative, not a universal tuning recommendation.

6.2. How Do shutdown(), shutdownNow(), and awaitTermination() Differ?

shutdown() stops accepting new tasks and lets submitted tasks finish. awaitTermination() waits for termination up to a timeout. shutdownNow() returns queued tasks and attempts to interrupt running tasks; it cannot force a task that ignores interruption to stop.

static void runAndStop() throws InterruptedException {
    ExecutorService executor = Executors.newSingleThreadExecutor();
    try {
        executor.submit(() -> System.out.println("done"));
    } finally {
        executor.shutdown();
        if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
            executor.shutdownNow();
        }
    }
}

The caller should also handle InterruptedException: restore its interrupt flag and request shutdown. The timeout is a policy choice, not a guarantee that work has stopped.

6.3. How Do submit() and execute() Differ?

execute() accepts a Runnable and returns no handle. submit() returns a Future for a Runnable or Callable, so the caller can await completion and observe a task failure through get().

try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
    executor.execute(() -> System.out.println("fire and forget"));
    Future<Integer> result = executor.submit(() -> 2 + 3);
    System.out.println(result.get()); // 5
}

If the submitted task throws, get() throws an ExecutionException whose cause is the task failure. Choose submit() when the caller needs that result or failure handle.

6.4. How Can We Schedule a Task After a Delay?

ScheduledExecutorService.schedule() runs a task after at least the requested delay. The actual start can be later because scheduling and system load vary.

java.util.concurrent.ScheduledExecutorService scheduler =
    java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
try {
    java.util.concurrent.ScheduledFuture<?> task = scheduler.schedule(
        () -> System.out.println("ready"), 1, java.util.concurrent.TimeUnit.SECONDS);
    task.get();
} finally {
    scheduler.shutdown();
}

For repeated work, scheduleAtFixedRate() measures intervals from planned starts, while scheduleWithFixedDelay() waits between one completion and the next start. Both stop scheduling after an uncaught task exception.

7. Concurrency Classes

7.1. How Does a BlockingQueue Coordinate Producers and Consumers?

A BlockingQueue safely transfers elements between threads. put() waits if a bounded queue is full; take() waits if it is empty. The queue handles the locking and signaling needed for these operations.

java.util.concurrent.BlockingQueue<String> jobs =
    new java.util.concurrent.ArrayBlockingQueue<>(2);
jobs.put("first");
System.out.println(jobs.take()); // first

Use a sentinel value, interruption, or another explicit shutdown protocol when consumers must stop. An unbounded queue avoids a full-queue wait but can grow without limit if producers outrun consumers.

7.2. How Do Callable and Future Work Together?

Callable.call() can return a value or throw an exception. An executor’s submit() returns a Future; get() waits for the outcome and wraps a task failure in ExecutionException.

java.util.concurrent.Callable<Integer> calculation = () -> 6 * 7;
try (java.util.concurrent.ExecutorService executor =
         java.util.concurrent.Executors.newSingleThreadExecutor()) {
    java.util.concurrent.Future<Integer> answer = executor.submit(calculation);
    System.out.println(answer.get()); // 42
}

get(timeout, unit) bounds the wait; cancel(true) requests interruption but cannot guarantee that a noncooperative task stops.

7.3. When Would We Use FutureTask?

FutureTask wraps a Callable (or a Runnable with a supplied result) in an object that is both Runnable and Future. It is useful when a task must be passed to an API that accepts Runnable while the caller still needs its result.

java.util.concurrent.FutureTask<Integer> task =
    new java.util.concurrent.FutureTask<>(() -> 6 * 7);
Thread worker = new Thread(task);
worker.start();
System.out.println(task.get()); // 42
worker.join();

Submitting a Callable directly to an executor is simpler when no Runnable adapter is needed.

7.4. How Do CountDownLatch and CyclicBarrier Differ?

A CountDownLatch starts with a count; await() waits until other actions reduce it to zero. It cannot be reset. A CyclicBarrier lets a fixed number of parties meet at a common point and can be reused for later rounds.

java.util.concurrent.CountDownLatch ready =
    new java.util.concurrent.CountDownLatch(1);
Thread worker = new Thread(() -> {
    System.out.println("prepared");
    ready.countDown();
});
worker.start();
ready.await();
worker.join();

For a barrier, each participating thread calls await(); the last arrival releases the group. If a participant fails or times out, the barrier becomes broken and waiting threads receive BrokenBarrierException.

7.5. How Do Semaphore and ReentrantLock Differ?

A Semaphore tracks permits, so it can limit how many threads enter a resource at once. A ReentrantLock provides mutual exclusion with lock ownership: the thread that locks it must unlock it. A semaphore permit can be released by a different thread, though pairing acquire and release in one task is usually easier to reason about.

java.util.concurrent.Semaphore permits =
    new java.util.concurrent.Semaphore(2);
permits.acquire();
try {
    System.out.println("using a limited resource");
} finally {
    permits.release();
}

Use a lock to protect shared state as one critical section; use a semaphore to cap concurrent access to a resource pool. Neither automatically makes the resource itself thread safe.

7.6. Which Collection Classes Support Concurrent Use?

The java.util.concurrent package provides collections with documented concurrent behavior. ConcurrentHashMap supports concurrent map operations, CopyOnWriteArrayList makes a new array on mutation and suits read-heavy workloads, and BlockingQueue coordinates producers and consumers. Choose by the operations and iteration behavior required, not by a generic “thread-safe collection” label.

java.util.concurrent.ConcurrentHashMap<String, Integer> counts =
    new java.util.concurrent.ConcurrentHashMap<>();
counts.merge("java", 1, Integer::sum);
System.out.println(counts.get("java")); // 1

The merge() operation updates this key atomically. A sequence of separate map calls is not automatically one atomic transaction. A synchronized collection wrapper is another option, but callers must follow its documented locking rule when iterating.

8. Programming Exercises

8.1. How Do We Interrupt a Running Thread?

interrupt() requests cooperative cancellation. A blocking method such as sleep() throws InterruptedException and clears the flag; the task can restore the flag and exit.

Thread worker = new Thread(() -> {
    try {
        while (!Thread.currentThread().isInterrupted()) {
            Thread.sleep(100);
        }
    } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
    }
});
worker.start();
worker.interrupt();
worker.join();

The caller waits for the worker to finish after requesting cancellation. Interruption does not forcibly stop arbitrary code; the task must cooperate.

8.2. What Is a Thread Dump, and How Can We Capture One?

A thread dump records threads, their states, and stack traces at one moment. It helps diagnose blocked threads, deadlock, and work that appears stuck. Compare several dumps if the problem is intermittent; a single snapshot may show a normal temporary wait.

jcmd <pid> Thread.print > threads.txt

Find the process ID with jcmd or another system process tool, then run the command where the JDK diagnostic tool can attach to the target JVM. Check the output for monitor ownership and repeated stacks. VisualVM or jconsole can provide a graphical view when available.

8.3. How Can We Solve the Producer-Consumer Problem?

Producers must wait when a bounded buffer is full, and consumers must wait when it is empty. A BlockingQueue provides these waits and safely transfers each element between threads.

java.util.concurrent.BlockingQueue<String> queue =
    new java.util.concurrent.ArrayBlockingQueue<>(2);
Thread producer = new Thread(() -> {
    try { queue.put("job"); }
    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
Thread consumer = new Thread(() -> {
    try { System.out.println(queue.take()); }
    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
consumer.start();
producer.start();
producer.join();
consumer.join(); // prints job

For a long-running pipeline, define how consumers stop, how producer failure is reported, and whether the queue should be bounded. See the producer-consumer example for a fuller application.

9. Conclusion

These Java concurrency interview questions cover the basic and the advanced concepts of threading in Java, from the thread lifecycle and synchronization to executors and blocking queues.

Happy Learning!

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.