8 Java Thread Interview Questions with Runnable Examples and Failure Cases

Quick Overview
Practice Java thread interview questions with checked examples on start vs run, monitor waits, lost updates, executor rejection, and concurrent counters.
Java thread interview questions become easier when you separate three concerns: which thread runs the task, what shared state it can observe, and which operations must happen together. A program can use a thread-safe collection and still contain a race; a thread can remain in the same stack without being deadlocked.
These eight questions explain those distinctions with small, original examples. The complete Java programs below target Java 21 or later; the virtual-thread notes identify version differences. Start with the public Java concurrency and lost-update question, then use the examples to explain your reasoning rather than memorize an answer.
1. Should a task extend Thread or implement Runnable?
Use Runnable when a task does not return a result. It separates the work from its execution mechanism: the same task can run in a platform thread, a virtual thread, or an executor. Extending Thread combines the task with a particular thread object and uses the class's single superclass slot. Subclassing remains possible, but is rarely necessary for an application task.
A lambda such as Runnable task = () -> processBatch(); represents work, not a promise that the work will execute concurrently. The caller still chooses how to execute it. Callable is useful when a task returns a value or needs to declare checked exceptions; an executor can submit it and return a Future.
The useful follow-up is about ownership: who submits work, observes failures, requests cancellation, and shuts down the executor? Moving work into a lambda does not solve those lifecycle questions.
2. What is the difference between start() and run()?
For a platform Thread constructed with a task, calling run() directly invokes that task on the caller. Calling start() schedules the new thread to execute independently. Save this example as StartRun.java:
public class StartRun {
public static void main(String[] args) throws InterruptedException {
Thread.currentThread().setName("caller");
Thread worker = new Thread(
() -> System.out.println(Thread.currentThread().getName()),
"worker");
worker.run();
worker.start();
worker.join();
System.out.println(worker.getState());
try {
worker.start();
} catch (IllegalThreadStateException expected) {
System.out.println("cannot restart");
}
}
}
The output is caller, worker, TERMINATED, and cannot restart, on separate lines. The direct call did not start the thread, so the first start() is valid. A second start is forbidden, including after termination.
There is an important modern exception to the usual explanation: directly calling run() on a virtual thread does nothing. Use start() or an executor to launch it. See the current Thread API contract.
The Java Memory Model also defines publication edges: work before start() happens-before actions in the started thread, and a successful join() observes that thread's completed actions. Neither rule prevents two workers from racing while both are running.
3. How do sleep(), wait(), and interruption differ?
Thread.sleep() delays the current thread and does not release monitors it owns. Object.wait() coordinates through an object's monitor: the caller must own that monitor, releases it while waiting, and reacquires it before returning or throwing InterruptedException. Other monitors held by the caller remain locked. Calling wait() without ownership throws IllegalMonitorStateException.
Always check the condition in a loop. A notification is not a stored message or a guarantee that the condition remains true when a waiter reacquires the lock. Spurious wakeups are also permitted by the Object.wait contract.
This deliberately small one-shot gate publishes a value once; it is not a reusable queue. Save it as MessageGate.java:
public class MessageGate {
private boolean ready;
private int value;
synchronized void publish(int next) {
if (ready) throw new IllegalStateException("already published");
value = next;
ready = true;
notifyAll();
}
synchronized int await() throws InterruptedException {
while (!ready) wait();
return value;
}
public static void main(String[] args) throws InterruptedException {
MessageGate gate = new MessageGate();
Thread consumer = new Thread(() -> {
try {
System.out.println(gate.await());
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
});
consumer.start();
gate.publish(42);
consumer.join();
}
}
The output is 42, whether publication happens before or after the consumer first checks ready. Both the condition and value use the same monitor. notifyAll() makes waiters eligible to proceed; it does not hand them the lock immediately.
Methods that can propagate interruption should normally do so. A task that catches InterruptedException should follow its cancellation policy; restoring the flag and returning, as above, avoids silently continuing. For a reusable producer/consumer structure, discuss BlockingQueue or separate Condition queues, then practice the public bounded blocking queue prompt.
4. When is volatile enough, and when do you need a lock?
volatile supplies visibility and ordering for a field, but does not make a read-modify-write sequence indivisible. synchronized adds mutual exclusion and a visibility edge between unlocking and subsequently locking the same monitor. Describe these language guarantees rather than saying every access literally goes to physical main memory.
A volatile cancellation flag can suit a worker that polls it. It does not wake a worker already blocked on I/O or a condition; interruption, timeouts, and the blocking API's contract still matter. Protect a balance-and-limit invariant with one lock or an appropriate higher-level operation, rather than marking the fields volatile independently.
The following program forces a lost update. It splits an increment into read and write, and uses latches solely to make both threads read before either writes. Save it as LostUpdate.java:
import java.util.concurrent.CountDownLatch;
public class LostUpdate {
static volatile int count;
public static void main(String[] args) throws InterruptedException {
CountDownLatch bothRead = new CountDownLatch(2);
CountDownLatch writeNow = new CountDownLatch(1);
Runnable increment = () -> {
int previous = count;
bothRead.countDown();
try {
writeNow.await();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return;
}
count = previous + 1;
};
Thread first = new Thread(increment);
Thread second = new Thread(increment);
first.start();
second.start();
bothRead.await();
writeNow.countDown();
first.join();
second.join();
System.out.println(count);
}
}
It prints 1, although two increments were attempted. An ordinary unsynchronized count++ example might accidentally print the expected total on a particular run; that would not prove correctness. Continue with the volatile semantics question and the safe-publication article.
5. How do you prevent and diagnose deadlock?
Suppose one operation locks object A then object B, while another locks B then A. Each can retain its first lock while waiting for the second. A consistent global acquisition order prevents this circular wait only when every relevant path follows that order. Use stable, unique ordering keys; handle equal keys or self-transfers explicitly rather than assuming hash codes are unique.
Timed ReentrantLock.tryLock can support an alternative policy, but a timeout alone is not a complete repair. Release any acquired locks in finally, use a bounded retry/deadline policy, and account for interruption and livelock. Thread-safe queues and collections reduce manual locking; application callbacks and external locks can still introduce cycles. The lock and condition guide develops those choices.
For diagnosis, inspect lock owners and the dependency cycle in thread dumps. A repeatedly blocked stack may indicate contention or a long-running operation; an unchanged stack by itself does not prove deadlock. For platform-thread lock diagnosis, jcmd PID Thread.print -l is useful. For virtual-thread coverage, current JDKs also provide jcmd PID Thread.dump_to_file -format=json /tmp/threads.json; the output differs from a traditional monitor dump. Use the jcmd command reference and the runtime's available commands.
Rehearse this reasoning with the public identify-and-fix-deadlock question. Name the dependency you would break, then explain why the proposed lock order is consistent.
6. What do the six Thread.State values tell you?
These are JVM states, not a direct map of operating-system scheduling states. The Thread.State API defines six values:
| State | Example | Interpretation limit |
|---|---|---|
NEW | Constructed, not started | Calling run() directly does not start the object |
RUNNABLE | Executing or waiting for OS resources | It does not establish that CPU work is making progress |
BLOCKED | Waiting to enter a synchronized monitor, or to re-enter it after wait() | Not the label for every kind of blocked operation |
WAITING | Untimed wait(), join(), or parking | May be normal idle coordination |
TIMED_WAITING | sleep(), timed waiting, or timed parking | The timeout is not a scheduling deadline |
TERMINATED | Thread execution has ended | The object cannot be restarted |
ReentrantLock contention commonly involves parking, so looking only for BLOCKED misses important waits. Compare several samples with owner information, CPU activity, task counts, and external dependency health. Do not resize a pool solely because many threads are waiting; they may be idle, waiting on downstream capacity, or waiting on work that needs the same exhausted executor.
7. What does ExecutorService manage, and how do virtual threads change the answer?
An executor separates submission from execution. It provides task handles and lifecycle operations, but its configuration determines capacity and overload behavior. A fixed platform-thread count is not sufficient if the queue is unbounded. In ThreadPoolExecutor, new tasks start core threads until corePoolSize threads exist, then generally queue (even if some of those threads are idle); expansion toward the maximum occurs only when the queue cannot accept another task. See the pool configuration contract.
This miniature executor has one worker, one queued task, and explicit rejection. Latches make saturation deterministic. Save it as BoundedExecutor.java:
import java.util.concurrent.*;
public class BoundedExecutor {
public static void main(String[] args) throws Exception {
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
try (ExecutorService pool = new ThreadPoolExecutor(
1, 1, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1),
new ThreadPoolExecutor.AbortPolicy())) {
Future<Integer> first = pool.submit(() -> {
started.countDown();
release.await();
return 1;
});
try {
started.await();
Future<Integer> second = pool.submit(() -> 2);
try {
pool.execute(() -> {});
} catch (RejectedExecutionException expected) {
System.out.println("rejected");
}
release.countDown();
System.out.println(first.get() + second.get());
} finally {
release.countDown();
}
}
}
}
It prints rejected and then 3. An application must translate rejection into an explicit overload policy. CallerRunsPolicy can slow submissions by running work on the submitter, but is unsuitable when that submitter must stay responsive, such as an event loop.
Observe submitted-task failures through Future.get() or an explicit completion strategy. A timeout on get does not itself cancel a task; shutdownNow() and interruption request cooperation rather than forcibly ending arbitrary work. ExecutorService.close has existed since Java 19 and waits for termination, so resource cleanup is not a substitute for task deadlines. Java 8/11/17 code needs explicit shutdown and termination handling.
Virtual threads became a standard feature in Java 21. Executors.newVirtualThreadPerTaskExecutor() starts a virtual thread per task, not a reusable bounded worker pool. It suits many tasks that mostly block; CPU capacity, connection limits, admission limits, and deadlines still apply. Limit scarce downstream operations, for example with a semaphore, rather than pooling virtual threads merely to reuse them.
Pinning advice is version-sensitive: JDK 24 removed monitor-related pinning from synchronized blocking code; native or foreign calls can still pin. Check the deployed JDK and evidence before proposing a lock rewrite. Oracle's virtual-thread guide explains the execution model, and JDK 24 release notes identify the monitor change.
For a larger exercise, use the public worker-pool, futures, and shutdown prompt.
8. When do atomic classes and concurrent collections solve a race?
AtomicInteger.incrementAndGet() makes a single counter update atomic. It does not combine arbitrary updates to several fields. A concurrent map similarly protects its operations, not a caller's separate containsKey followed by put.
Use an appropriate combined operation such as putIfAbsent or computeIfAbsent. Keep mapping functions short, and never modify the same map from them; the contract forbids it and a recursive update can throw IllegalStateException. Do not assume a thread-safe container makes every mutable value thread-safe. The ConcurrentHashMap contract describes these boundaries.
Save this independent example as ConcurrentCounters.java:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;
public class ConcurrentCounters {
public static void main(String[] args) throws InterruptedException {
AtomicInteger exact = new AtomicInteger();
ConcurrentHashMap<String, LongAdder> totals = new ConcurrentHashMap<>();
Runnable task = () -> {
for (int i = 0; i < 10_000; i++) {
exact.incrementAndGet();
totals.computeIfAbsent("finished", key -> new LongAdder())
.increment();
}
};
Thread first = new Thread(task);
Thread second = new Thread(task);
first.start();
second.start();
first.join();
second.join();
System.out.println(exact.get());
System.out.println(totals.get("finished").sum());
}
}
Both lines print 20000; all writers have terminated before the reads. LongAdder is useful for contended statistics, but sum() is not an atomic snapshot during concurrent updates. It is not a drop-in primitive for allocating unique sequence numbers or enforcing a hard limit. Choose AtomicInteger's operations or a lock according to the invariant, and account for numeric overflow where it matters.
For map and iterator follow-ups, continue with Java Collections examples.
Practice the reasoning, including the failure case
For each answer, identify the shared state, describe an interleaving that breaks the naive implementation, and name the rule that makes the repaired version correct. Then add its limit: cancellation may be cooperative, a queue may reject, and a thread-safe method may not protect a larger workflow.
Run each complete program in its own file with javac FileName.java, then java FileName. Expected outputs above describe these synthetic fixtures, not a throughput benchmark or an interview-frequency claim. Compare with Go concurrency and cancellation and Node.js event-loop and worker questions to distinguish language-specific mechanisms from shared capacity and coordination problems.
Comments (0)