Crucial Java Threading Queries for Interviews: 12 Code Scenarios
12 scenario-based Java threading interview questions: what the code prints, where the bug is and how to fix it, from lost updates and deadlocks to ThreadLocal leaks and virtual-thread pinning.
New8 min read

Senior Java interviews rarely ask "what is a thread?". They show you ten lines of code and ask what it prints, where the bug is, or why production hangs once a week. This post collects 12 crucial Java threading questions in that format. Each has the code, the answer, and the fix you would give in the interview.
If any concept here is shaky, read the companion post first: Java multithreading interview questions, simplified. It explains the lifecycle, volatile, locks and executors that these scenarios rely on.
1. What does this counter print?
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 10_000; i++) count++;
};
Thread a = new Thread(task);
Thread b = new Thread(task);
a.start(); b.start();
a.join(); b.join();
System.out.println(count);
}Answer: any number up to 20,000, often less, and different from run to run. count++ is a read, an add and a write; when both threads read the same value, one increment is lost. Making count volatile does not help, because the three steps are still separate. Fix it with an AtomicInteger and incrementAndGet(), or by incrementing inside a synchronized block.
2. Which thread name is printed?
Thread t = new Thread(
() -> System.out.println(Thread.currentThread().getName()), "worker");
t.run();
t.start();Answer: main, then worker. run() is an ordinary method call on the current thread, so it prints main and does not change the thread's state. start() then creates the new thread, which prints worker. Calling start() a second time would throw IllegalThreadStateException.
3. Why does this worker never stop?
class Worker implements Runnable {
private boolean running = true;
public void run() {
while (running) {
// busy work
}
System.out.println("stopped");
}
public void stop() { running = false; }
}Answer: stop() is called from another thread, and nothing guarantees the worker ever sees the new value. The JIT compiler is allowed to read running once and turn the loop into while (true). Declare the field volatile, which creates the happens-before edge between the write and the read. Better still, use interruption, so the worker also wakes up from sleep() or a blocking call.
4. Find the deadlock
void transfer(Account from, Account to, long amount) {
synchronized (from) {
synchronized (to) {
from.debit(amount);
to.credit(amount);
}
}
}
// thread 1: transfer(alice, bob, 100)
// thread 2: transfer(bob, alice, 50)Answer: thread 1 locks alice and waits for bob; thread 2 locks bob and waits for alice. Neither can continue. All four conditions for deadlock are present: mutual exclusion, hold-and-wait, no preemption and a circular wait.

How to detect it: take a thread dump with jstack <pid> or jcmd <pid> Thread.print. The JVM reports "Found one Java-level deadlock" and lists which thread holds which monitor. In code, ThreadMXBean.findDeadlockedThreads() returns the same information, which is useful for a health check.
The fix: break the circular wait by always locking in the same global order, for example by account id:
void transfer(Account from, Account to, long amount) {
Account first = from.id() < to.id() ? from : to;
Account second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(amount);
to.credit(amount);
}
}
}The alternative is ReentrantLock.tryLock(timeout): if the second lock is not free in time, release the first and retry after a short random pause.
5. Write a producer-consumer
The interviewer wants to see that you do not reach for wait/notify when a BlockingQueue already solves the problem:
BlockingQueue<String> queue = new ArrayBlockingQueue<>(100);
String DONE = "__done__";
Thread producer = new Thread(() -> {
try {
for (String order : orders) queue.put(order); // blocks while the queue is full
queue.put(DONE);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumer = new Thread(() -> {
try {
while (true) {
String item = queue.take(); // blocks while the queue is empty
if (item.equals(DONE)) break;
process(item);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});Points to mention: the bounded queue gives back-pressure, so a fast producer cannot exhaust memory; the "poison pill" tells the consumer to stop; with several consumers, put one pill per consumer.
6. Is this singleton thread-safe?
class Config {
private static Config instance;
static Config get() {
if (instance == null) {
synchronized (Config.class) {
if (instance == null) instance = new Config();
}
}
return instance;
}
}Answer: no. The first check reads instance without a lock, and without volatile there is no happens-before edge to the write. Another thread can see a non-null reference to a Config whose fields are not yet visible. Declare the field private static volatile Config instance, or use the holder idiom, which is lazy and thread-safe because class initialization is:
class Config {
private Config() {}
private static class Holder {
static final Config INSTANCE = new Config();
}
static Config get() { return Holder.INSTANCE; }
}An enum with a single constant is also thread-safe and survives serialization.
7. Why are word counts wrong with ConcurrentHashMap?
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
void add(String word) {
Integer c = counts.get(word);
counts.put(word, c == null ? 1 : c + 1);
}Answer: each call is thread-safe, but the pair is not. Two threads can both read 4 and both write 5. A thread-safe collection does not make a sequence of calls atomic. Use the map's atomic methods:
counts.merge(word, 1, Integer::sum);
// or for a list per key:
index.computeIfAbsent(word, k -> new CopyOnWriteArrayList<>()).add(page);Keep the function passed to compute or merge short, and never modify the same map from inside it: other updates to that key wait while it runs.
8. Why doesn't this program exit?
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.submit(() -> System.out.println("done"));
}Answer: it prints done and then hangs. The pool's threads are non-daemon and stay alive waiting for more work, so the JVM never exits. Shut the pool down:
pool.shutdown(); // no new tasks; finish queued ones
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // interrupt whatever is still running
}Since Java 19, ExecutorService is AutoCloseable, so a try-with-resources block shuts it down and waits for the tasks to finish.
9. Where did the exception go?
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.submit(() -> Integer.parseInt("abc"));Answer: nowhere visible. submit() captures the NumberFormatException inside the returned Future; it only surfaces, wrapped in an ExecutionException, if someone calls get(). Ignoring the future means the failure is silent. With execute() instead, the exception reaches the thread's uncaught-exception handler and its stack trace is printed. In production, call get(), or catch and log inside the task.
10. CountDownLatch or CyclicBarrier?
// Wait until three services have started
CountDownLatch ready = new CountDownLatch(3);
// each service, when started: ready.countDown();
ready.await(); // main continues once the count hits zero
// Four workers that must finish each phase together
CyclicBarrier phase = new CyclicBarrier(4, () -> System.out.println("phase done"));
// each worker, at the end of every phase: phase.await();Answer: a latch is one-shot, and the threads that count down are usually different from the threads that wait. A barrier makes a fixed group of threads wait for each other, runs an optional action when the last one arrives, and then resets for the next round. If one waiting thread is interrupted or times out, the barrier breaks and the others get BrokenBarrierException.
11. Why did one user see another user's data?
static final ThreadLocal<User> CURRENT = new ThreadLocal<>();
pool.submit(() -> {
CURRENT.set(authenticate(request));
handle(request);
});Answer: pool threads are reused, and the value is never removed. If a later task on the same thread reads CURRENT before setting it, for example on a code path that skips authentication, it gets the previous request's user. The values also stay reachable for as long as the thread lives, which leaks memory, especially in application servers. Always clean up:
try {
CURRENT.set(authenticate(request));
handle(request);
} finally {
CURRENT.remove();
}12. Why don't virtual threads scale here?
synchronized (cacheLock) {
String body = httpClient.send(request, BodyHandlers.ofString()).body();
cache.put(key, body);
}Answer: on Java 21, a virtual thread that blocks while holding a monitor is pinned: it cannot unmount, so its carrier thread is blocked too. With only as many carriers as CPU cores, a few pinned threads stall everything else. Find it with the JFR event jdk.VirtualThreadPinned (or -Djdk.tracePinnedThreads=full on Java 21). Fix it by not doing I/O while holding a lock, or by switching to a ReentrantLock, which does not pin. JDK 24 (JEP 491) stopped synchronized from pinning in most cases, but holding any lock during slow I/O is still a design smell.
How to answer scenario questions
- Say what can interleave. Name the two threads and the exact steps that overlap.
- Name the guarantee that is missing: atomicity, visibility or ordering.
- Give the smallest fix first, then the better design (a queue instead of
wait/notify, atomic map methods instead of get-then-put). - Mention how you would find it in production: thread dumps, JFR, metrics on pool queues.
Go further
For 100 of the most-asked multithreading and concurrency questions with full answers and coding challenges, see Java Multithreading and Concurrency: 100 interview questions. Concurrency is week 3 of the 30-day Java interview roadmap, and the concepts behind every answer here are in Java multithreading interview questions, simplified.





