Java Multithreading Interview Questions, Answered by Running Them

Most Java multithreading interview questions have a definition for an answer, and the definition is where the interviewer starts rather than stops. Every answer here came from running the code on JDK 21: eight threads doing 100,000 increments each lost 82 to 86 percent of them, unsynchronised HashMap writes lost 78,282 to 245,151 entries of 400,000 with nothing thrown, and 10,000 blocking tasks took 128 seconds on eight platform threads against 151 milliseconds on virtual ones.

Java multithreading interview questions get asked as definitions and marked as understanding. "What does volatile do" has a one-line answer that almost everyone gives and almost nobody can defend, because the next question is "so does it make my counter thread-safe" and the honest answer is no.

The openers below are answered briefly, because there is nothing to measure in them. Everything after that came out of a program on JDK 21 — eclipse-temurin:21-jdk, eight cores — and two of those answers contradict advice that is still repeated everywhere.

The openers, in one line each

A thread against a process. A process owns its memory; threads share the process's heap and get only their own stack. That sharing is the whole subject: everything below is a consequence of it.

A platform thread against a virtual thread. A platform thread is an OS thread, and its stack is native memory the JVM reserves. A virtual thread is a Java object the JVM parks and resumes on a carrier — a real platform thread it borrows only while running. Creating 10,000 of each, measured as resident memory rather than heap:

10,000 platform threads  1,467 ms   RSS +402 MB
10,000 virtual  threads     24 ms   RSS + 72 MB

Runnable against Callable. Callable returns a value and may throw a checked exception; Runnable does neither. If you need a result, you need a Future.

wait/notify against a CountDownLatch. Both block until something happens. The latch is a named, one-shot, already-correct version of the pattern you would otherwise hand-write — and hand-written wait/notify is where lost-wakeup bugs live.

Now the ones worth measuring.

Two threads share a counter. How much is lost?

The expected answer is "some increments are lost". The useful answer is the size of it.

static int plain = 0;

static void increment(int perThread) {
  for (int i = 0; i < perThread; i++) plain++;  // read, add, write
}

Eight threads, 100,000 each:

expected           800,000
plain  count++     110,261   (lost 689,739 = 86.2%)   39 ms
synchronized       800,000   (lost 0)                 49 ms
AtomicInteger      800,000   (lost 0)                 33 ms

Not a handful — 86 percent. Three runs of that program lost 82.1, 83.3 and 86.2 percent. count++ compiles to a read, an add and a write, and eight threads interleave those so thoroughly that most writes land on a value another thread has already moved past.

Then a second program ran the same statement repeatedly inside one JVM, and the number collapsed:

run 1  plain 147,244 (lost 81.6%)
run 2  plain 800,000 (lost  0.0%)
run 3  plain 800,000 (lost  0.0%)

Cold it loses four fifths; warm it loses nothing. Once the loop is hot — run enough times that the JIT, the JVM's just-in-time compiler, replaces the interpreted version with optimised machine code — it is free to keep plain in a register and write it back once per thread instead of 100,000 times — so the threads stop interleaving and the bug stops appearing. A race is not a percentage. It is the absence of a guarantee, and the optimiser is entitled to hide it on the run where you happen to be looking.

The second number is the one to volunteer: AtomicInteger was faster than synchronized here, 29-33 ms against 42-49 ms across runs, and both were exactly correct. A single counter under contention is what a compare-and-swap is for — one instruction that reads, compares and writes, retrying if another thread got there first.

What does volatile actually fix?

One thing, and it is not the counter. A loop reading a plain boolean and one reading a volatile boolean, with the flag flipped from another thread after the JIT has compiled the loop:

plain boolean                STILL RUNNING after 3005 ms
volatile boolean             stopped after 0 ms

The plain loop never noticed, in either run. The language specification puts it plainly — a field may be declared volatile, "in which case the Java Memory Model ensures that all threads see a consistent value for the variable".

Seeing a consistent value is not the same as updating it safely. Make the counter from the previous section volatile and it does not get better — it gets reliably worse:

plain     0.0% – 83.0% lost   (0% once the loop is warm)
volatile 68.4% – 86.3% lost   (every run, warm or cold)

volatile forbids exactly the optimisation that was hiding the bug. Every increment now has to be a real read and a real write against shared memory, so the threads interleave every time. It did not make the counter safe; it removed the accident that made it look safe. That is the distinction the question is testing, and it is worth more than the definition.

Why can't two threads write to a HashMap?

The folklore answer is that it spins forever. That was a resize bug in Java 7. On 21, eight threads writing 400,000 entries:

round 1  HashMap size 321,718  (missing  78,282)  no exception
round 2  HashMap size 154,849  (missing 245,151)  no exception
round 3  HashMap size 172,478  (missing 227,522)  no exception
         ConcurrentHashMap     (missing       0)  no exception

Between 19 and 61 percent of the data gone, nothing thrown and nothing logged. The HashMap javadoc has required this all along: "If multiple threads access a hash map concurrently, and at least one of the threads modifies the map structurally, it must be synchronized externally." What is worth saying in an interview is that the punishment is silence — the same shape of bug as the hashCode mistakes that make a HashSet lose an element.

How would you find a deadlock in production?

Not by reading the code. Two locks taken in opposite order, then the JVM asked:

findDeadlockedThreads() named 2 threads:
  worker-A-then-B  BLOCKED
    waiting on  java.lang.Object@b4c966a
    held by     worker-B-then-A
  worker-B-then-A  BLOCKED
    waiting on  java.lang.Object@6f496d9f
    held by     worker-A-then-B

ThreadMXBean.findDeadlockedThreads() names the cycle, what each thread waits on and who holds it — enough to find the two code paths without guessing.

It has a blind spot worth knowing, because the javadoc says so and it is true: "Cycles that include virtual threads are not found by this method." The same deadlock, one per JVM, on each kind of thread:

platform threads, synchronized  : 2 threads detected
platform threads, ReentrantLock : 2 threads detected
virtual  threads, synchronized  : null — NOT DETECTED
virtual  threads, ReentrantLock : null — NOT DETECTED

shutdown() or shutdownNow()?

"Graceful versus immediate" is not wrong, but it says nothing about the tasks. Six 500 ms tasks, a pool of two, shut down 100 ms in:

shutdown()     started 6 of 6, finished 6, handed back 0 queued
shutdownNow()  started 2 of 6, finished 0, handed back 4 queued

shutdown() drains the queue — every task ran. After shutdownNow() neither of the two in flight completed — both were sleeping, and the pool's interrupt is what a sleep throws on — and the four that had not started came back to the caller as a list. That returned list is the practical detail: those tasks are yours to requeue or report, and code that ignores the return value loses them.

And it interrupts rather than stops:

a task that sleeps (throws on interrupt)     terminated within 1s: true  (0 ms)
a task that loops (ignores interrupt)        terminated within 1s: false (1001 ms)

The ExecutorService javadoc is honest about it: "There are no guarantees beyond best-effort attempts to stop processing actively executing tasks... any task that fails to respond to interrupts may never terminate."

When do virtual threads help?

When the threads are waiting. 10,000 tasks, each sleeping 100 ms:

fixed pool of 8 platform threads   128,155 ms
one virtual thread per task            151 ms

About 850 times over, and the reason is arithmetic rather than magic: 10,000 tasks through 8 threads is 1,250 sequential rounds of 100 ms. A virtual thread that blocks releases its carrier — the platform thread it was borrowing — so all 10,000 wait at once and the pool is no longer the limit.

On CPU work there is no gain at all, which is the half most answers leave out:

64 arithmetic tasks, 8 cores
  platform 7-10 ms     virtual 7-8 ms

JEP 444 says this outright — "Virtual threads are not faster threads — they do not run code any faster than platform threads. They exist to provide scale (higher throughput), not speed (lower latency)."

Common mistake

The first version of that CPU benchmark reported virtual threads at 8 ms against platform at 29 ms — 3.6 times faster, which would contradict the spec. It was measuring JIT warm-up: the platform run went first and paid for compiling the loop. Five warm-up rounds and alternating which went first made the difference vanish. If a concurrency number surprises you, suspect the measurement before the platform.

Answering multithreading interview questions out loud

Each of these has a definition and a consequence, and interviewers are listening for the second one. volatile is about visibility, so a counter still needs an atomic. shutdownNow() interrupts, so a task that never checks the flag outlives it. Virtual threads release their carrier when they block, so they do nothing for arithmetic.

If you want the shape of the problem rather than the API, threads against processes is the same trade in another language, what the JVM actually is explains whose threads these are, and ArrayList against LinkedList is the same habit of checking a claim by timing it.

[!TAKEAWAY] Run the race. Eighty-six percent of increments lost, 245,151 map entries gone with no exception, and 128 seconds against 151 milliseconds are not facts you can memorise convincingly — but they are facts you cannot forget once you have watched them happen on your own machine.

Frequently asked questions

Is volatile ever enough on its own?
For one pattern: a flag written by one thread and read by others, where the readers only need to see it eventually. A shutdown signal is the textbook case, and the measurement above is exactly that. It is not enough the moment a thread reads a value, decides something from it and writes it back — that is three operations, and volatile makes each of them visible without making them one.
When should I use synchronized instead of an atomic?
When the invariant spans more than one field. An AtomicInteger protects one variable; if a withdrawal has to check a balance and append to a ledger together, no per-variable atomic gives you that, and a lock or a single immutable object swapped atomically does. Reach for the atomic when the shared state is genuinely one number.
What should I use instead of HashMap?
ConcurrentHashMap, usually. Collections.synchronizedMap works and is not the same thing: it serialises every call through one lock, so it scales worse, and the javadoc still requires you to synchronise externally while ITERATING it. ConcurrentHashMap needs neither, and its compute and merge methods do read-modify-write atomically per key.
How do I write a task that can actually be cancelled?
Let InterruptedException out rather than swallowing it, and in a loop that does not block, check Thread.currentThread().isInterrupted() and return. If you must catch it and cannot propagate, call Thread.currentThread().interrupt() so the flag survives for whoever looks next. Cancellation is a request; the task is the only thing that can honour it.
Do I have to change my code to use virtual threads?
Usually not. Blocking calls stay blocking calls — that is the point of the design. Two habits do need changing: do not pool virtual threads, because creating one is cheap and a pool reintroduces the limit you were escaping, and be careful with ThreadLocal caches, which were sized for a handful of pool threads rather than ten thousand short-lived ones.

References

  1. Java Language Specification 21, §8.3.1.4 volatile FieldsOracle
  2. HashMap (Java SE 21 API)Oracle
  3. ExecutorService (Java SE 21 API)Oracle
  4. ThreadMXBean (Java SE 21 API)Oracle
  5. JEP 444: Virtual ThreadsOpenJDK