Java Concurrency Interview Questions That Expose How Little You Actually Know About Threads
You probably know how to use synchronized or CompletableFuture. But can you explain the Java Memory Model, false sharing, or what volatile actually guarantees under interview pressure? This guide covers the Java concurrency questions that expose the difference between copying StackOverflow code and actually understanding how threads work under the JVM.
Java Concurrency Interview Questions That Expose How Little You Actually Know About Threads
You've written multithreaded Java code.
You've used synchronized blocks. You've seen volatile somewhere in a codebase. You've probably copy-pasted a CompletableFuture chain from Stack Overflow and it worked, so you moved on.
And when an interviewer asks "are you comfortable with Java concurrency?" you say yes.
Then they ask:
"What does
volatileactually guarantee?"
And you say something like "it makes the variable visible to other threads" and they nod and you think you nailed it.
You didn't nail it.
Because the next question is:
"So if I have a volatile boolean flag, is this thread-safe?"
if (!flag) {
flag = true;
doSomething();
}
And that's where things get uncomfortable.
Senior Java interviews don't test if you've memorized definitions. They test whether you can reason about what's actually happening in memory, across threads, under the JVM.
This is that guide.
1. What does synchronized actually do?
Everyone uses it. Almost nobody explains it correctly under pressure.
public synchronized void increment() {
count++;
}
Most people say: "It prevents two threads from running the method at the same time."
That's... not wrong. But it's incomplete in a way that will get you failed.
synchronized does two things:
Mutual exclusion — only one thread can hold the monitor lock at a time.
Memory visibility — when a thread exits a synchronized block, all writes it made are flushed and visible to the next thread that enters a synchronized block on the same monitor.
That second part is what interviewers are actually fishing for.
So if you synchronize on different objects:
// Thread 1
synchronized (lockA) {
count++;
}
// Thread 2
synchronized (lockB) {
System.out.println(count);
}
You have mutual exclusion (sort of) but NO memory visibility guarantee between these two threads.
They're using different monitors. The happens-before relationship doesn't exist here.
That's not thread-safe. And it looks thread-safe. That's the worst kind of bug.
2. What does volatile actually guarantee?
Here's where most people have a vague answer.
"It makes the variable visible across threads."
Okay but what does that mean?
volatile guarantees two things:
Visibility — writes to a volatile variable are immediately visible to other threads. No CPU caching, no thread-local copies.
Happens-before — a write to a volatile variable happens-before every subsequent read of that same variable.
What it does NOT guarantee:
Atomicity.
Back to that flag example:
volatile boolean flag = false;
// Thread 1
if (!flag) {
flag = true; // <- not atomic with the read above
doSomething();
}
Thread 1 reads flag (false), then Thread 2 reads flag (also false, because Thread 1 hasn't written yet), both set it to true, both call doSomething().
volatile didn't save you here.
The read-check-write is not atomic even if each individual read or write is visible.
For that, you need AtomicBoolean or synchronized.
3. The count++ problem
This is the most classic trap in Java threading interviews and people still fall into it.
private int count = 0;
public void increment() {
count++;
}
Is this thread-safe?
No.
count++ is NOT one operation. It's three:
- Read
count - Add 1
- Write back
Between step 1 and step 3, another thread can read the old value, increment it, write it back — and then your thread also writes the old value + 1.
You've lost an update.
Making count volatile doesn't fix this. Volatile guarantees visibility, not atomicity of compound operations.
Fix it:
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
Or:
public synchronized void increment() {
count++;
}
Both work. Different tradeoffs.
4. What is the Java Memory Model and why should you care?
Most developers have never read the JMM spec. Most senior developers should have at least a working mental model.
The JVM can:
- Reorder instructions for performance
- Cache variable reads in CPU registers or caches
- Defer writes to main memory
The Java Memory Model defines when one thread's writes are guaranteed to be visible to another thread.
The key concept: happens-before.
A happens-before relationship means: if action A happens-before action B, then the effects of A are visible to B.
Happens-before is established by:
synchronized— unlock happens-before subsequent lock on same monitorvolatile— write happens-before subsequent read of same variableThread.start()— everything before start() happens-before anything in the new threadThread.join()— everything in the thread happens-before join() returnsCompletableFuturechains — each stage happens-before the next
Without happens-before? The JVM makes zero guarantees about what another thread sees.
Zero.
This is why "it works on my machine" is so dangerous with threading bugs. Different JVM implementations, different CPU architectures, different optimizations.
5. synchronized vs ReentrantLock — when does it matter?
You probably know ReentrantLock exists. But why would you use it over synchronized?
synchronized:
- Simpler
- Automatically released when block exits (even on exception)
- Can't be interrupted while waiting for lock
- Can't try to acquire with a timeout
- Can't have multiple condition variables
ReentrantLock:
- You have to call
unlock()manually (use try-finally or you'll leak the lock) tryLock()— attempt without blockingtryLock(timeout, unit)— attempt with timeoutlockInterruptibly()— can be interrupted while waiting- Multiple
Conditionobjects vianewCondition()
ReentrantLock lock = new ReentrantLock();
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// do work
} finally {
lock.unlock(); // <- you MUST do this
}
} else {
// couldn't acquire lock, do something else
}
For most code: synchronized is fine and simpler.
For lock contention problems, timeouts, or interruptibility: ReentrantLock.
The interviewer is usually asking this to see if you know when to reach for the heavier tool.
6. wait(), notify(), notifyAll() — what even is this
This is old-school Java concurrency. Still shows up in interviews because it reveals whether you understand monitor-based synchronization.
synchronized (lock) {
while (!conditionMet) {
lock.wait(); // releases the lock, waits
}
// do work
}
// Other thread:
synchronized (lock) {
conditionMet = true;
lock.notify(); // wakes up one waiting thread
}
Key things that trip people up:
wait() must be called in a loop, not an if.
Why? Spurious wakeups. The JVM (and underlying OS) can wake a waiting thread for no reason. If you use if and there's a spurious wakeup, you proceed even though the condition isn't true.
Always:
while (!condition) {
lock.wait();
}
Never:
if (!condition) {
lock.wait(); // wrong
}
notify() vs notifyAll()
notify() wakes one random waiting thread.
notifyAll() wakes all waiting threads (they then compete for the lock).
Using notify() when multiple threads are waiting on different conditions can wake the wrong thread and leave your program stuck.
Safe default: notifyAll() unless you really know what you're doing.
7. What is CompletableFuture and why is it not just a fancy Future?
Old Future<T>:
Future<String> future = executor.submit(() -> fetchData());
String result = future.get(); // blocks. that's it. that's all it does.
future.get() blocks the calling thread. You can't chain operations. You can't compose multiple futures easily. You can't attach callbacks.
CompletableFuture<T>:
CompletableFuture.supplyAsync(() -> fetchUser())
.thenApply(user -> enrichUser(user))
.thenCompose(user -> fetchOrders(user.getId()))
.thenAccept(orders -> process(orders))
.exceptionally(ex -> {
log(ex);
return null;
});
You're describing a pipeline. Each stage runs when the previous one completes. No blocking.
Key methods and what they actually mean:
thenApply— transform the result (likemap)thenCompose— chain anotherCompletableFuture(likeflatMap)thenAccept— consume the result, return nothingthenRun— run something after completion, doesn't see the resultexceptionally— handle exception, provide fallbackhandle— handle both result and exceptionwhenComplete— called for both success and failure, doesn't transform
8. Which thread actually runs a CompletableFuture stage?
This is where it gets interesting and most people have no idea.
CompletableFuture.supplyAsync(() -> fetchData())
.thenApply(data -> transform(data));
Which thread runs transform(data)?
It depends.
supplyAsyncwithout an executor → usesForkJoinPool.commonPool()thenApply→ if the previous stage is already complete whenthenApplyis registered, the current thread runs it. If not, the thread that completes the previous stage runs it.
This is subtle and important.
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
Thread.sleep(1000);
return "result";
});
// If we attach thenApply AFTER 1 second has passed...
// the stage might already be done
// and thenApply runs on the current thread
cf.thenApply(r -> r.toUpperCase()); // which thread?? depends on timing
To control which thread pool runs your stages:
cf.thenApplyAsync(r -> r.toUpperCase(), myExecutor);
The Async suffix variants take an executor and guarantee the stage runs on that executor's thread.
Without Async suffix: "whatever thread is convenient."
If you're doing I/O in a stage and using ForkJoinPool.commonPool()... that's not great. Common pool threads are meant for CPU-bound work.
9. CompletableFuture.allOf() vs manual chaining
You have three independent async operations. You want all three to complete before proceeding.
Wrong way (serial):
String a = await getA(); // Java doesn't have await, but conceptually
String b = await getB(); // these run one after another
String c = await getC();
Actually in Java with CompletableFuture that looks like:
String a = getAAsync().get();
String b = getBAsync().get(); // B doesn't start until A finishes
String c = getCAsync().get();
Right way (parallel):
CompletableFuture<String> futureA = getAAsync();
CompletableFuture<String> futureB = getBAsync();
CompletableFuture<String> futureC = getCAsync();
CompletableFuture.allOf(futureA, futureB, futureC).join();
String a = futureA.join();
String b = futureB.join();
String c = getCAsync().join();
All three start immediately. allOf waits until all complete.
The annoying thing about allOf: it returns CompletableFuture<Void>. It doesn't collect results. You have to get them individually from each future after joining.
Compare with anyOf — returns when any one completes. Useful for "first successful response wins" patterns.
10. What is a deadlock and how do you create one accidentally?
Deadlock: Thread A holds lock 1, wants lock 2. Thread B holds lock 2, wants lock 1. Both wait forever.
Classic example:
Object lock1 = new Object();
Object lock2 = new Object();
// Thread A
synchronized (lock1) {
synchronized (lock2) {
// work
}
}
// Thread B
synchronized (lock2) { // acquires lock2 first — opposite order
synchronized (lock1) {
// work
}
}
If Thread A acquires lock1 and Thread B acquires lock2 at the same time, you're stuck.
How to avoid it:
Always acquire locks in a consistent order across all threads.
Or use tryLock with a timeout and back off if you can't get both.
Or rethink your design so you don't need two locks at once.
The interviewer isn't just asking you to describe deadlock. They want to see if you can spot lock ordering problems in code they show you.
11. ThreadLocal — useful or footgun?
ThreadLocal<T> gives each thread its own independent copy of a variable.
ThreadLocal<SimpleDateFormat> dateFormat = ThreadLocal.withInitial(
() -> new SimpleDateFormat("yyyy-MM-dd")
);
// Each thread that calls dateFormat.get() gets its OWN instance
SimpleDateFormat is notoriously not thread-safe. ThreadLocal is one way to handle this (though just use DateTimeFormatter from Java 8 which IS thread-safe).
Where ThreadLocal is genuinely useful: storing request context in web applications.
// Set at request start
RequestContext.set(new Context(userId, requestId));
// Anywhere in the call stack
Context ctx = RequestContext.get();
The footgun: memory leaks in thread pools.
Thread pool threads are reused. If you set a ThreadLocal and never remove it, the value stays on that thread forever — even for the next completely different request.
Always:
try {
RequestContext.set(ctx);
// handle request
} finally {
RequestContext.remove(); // <- this
}
Interviewers sometimes give you a "why is our application leaking memory?" scenario where ThreadLocal in a thread pool is the answer.
12. The question that breaks most people
Interviewer shows you this:
public class Counter {
private int value = 0;
public void increment() {
value++;
}
public int get() {
return value;
}
}
Then asks: "100 threads each call increment() 1000 times. What is value at the end?"
Most people say: "Less than 100,000 because of race conditions."
Yes. But then they ask:
"Could it ever be exactly 100,000?"
And people get confused.
Yes. It could be. Race conditions are non-deterministic. Sometimes the timing works out and you get the right answer. That's what makes threading bugs so insidious — they don't always fail.
Then they ask:
"If I add volatile to value, is it fixed?"
No. Volatile fixes visibility, not the atomicity of value++.
"If I make get() and increment() both synchronized, is it fixed?"
If they both synchronize on the same object (which they do when you use synchronized on instance methods — they use this), then yes.
But then they show you:
Counter counter = new Counter();
synchronized (counter) {
if (counter.get() < 100) {
counter.increment();
}
}
Now you have synchronized around the already-synchronized methods. This works because synchronized in Java is reentrant. A thread that already holds a lock can acquire it again.
13. ExecutorService — what happens when you don't shut it down?
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.submit(() -> doWork());
// ... forgot executor.shutdown()
The threads in the pool are still alive. The JVM won't exit cleanly if non-daemon threads are running.
Your application appears to "hang" after main() finishes.
Always:
executor.shutdown(); // stop accepting new tasks
executor.awaitTermination(30, TimeUnit.SECONDS); // wait for running tasks
Or if you need to cancel everything immediately:
executor.shutdownNow(); // interrupt running tasks, return queued ones
shutdownNow() doesn't guarantee tasks stop — it interrupts them. Tasks that don't respect interruption (Thread.interrupted() checks) keep running.
14. ConcurrentHashMap — is it fully thread-safe?
People think: "I used ConcurrentHashMap so I'm thread-safe."
Not so fast.
Individual operations on ConcurrentHashMap are thread-safe.
Compound operations are not.
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// This is NOT thread-safe:
if (!map.containsKey("key")) {
map.put("key", 1);
}
Between containsKey returning false and put executing, another thread could have put the key.
Use the atomic compound operations:
map.putIfAbsent("key", 1);
map.computeIfAbsent("key", k -> computeValue(k));
map.merge("key", 1, Integer::sum);
These are atomic within ConcurrentHashMap. The non-atomic check-then-act pattern is still a race condition.
15. Virtual Threads (Java 21) — what the interview will ask
Java 21 brought Project Loom. Virtual threads are lightweight threads managed by the JVM, not the OS.
The pitch: you can have millions of virtual threads. Blocking a virtual thread is cheap — the JVM "unmounts" it from the OS thread and parks the state.
Thread.ofVirtual().start(() -> {
String result = database.query(); // blocking, but cheap now
process(result);
});
Or with ExecutorService:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> handleRequest());
}
What virtual threads are for: high-throughput I/O. If you're blocking on database calls, HTTP requests, file I/O — virtual threads let you write simple blocking code without sacrificing scalability.
What they're NOT:
- Not faster for CPU-bound work
- Not a replacement for async/reactive programming in every case
- Not magical — CPU-bound tasks still need real threads
Common interview gotcha: "Should I use virtual threads everywhere?"
Answer: For I/O-bound work that used to need thread pools, yes, they simplify things enormously. For CPU-bound work, no benefit.
16. The thing nobody talks about: false sharing
This is a genuinely senior-level topic.
Modern CPUs cache memory in cache lines (typically 64 bytes). If two threads on different CPU cores are modifying different variables that happen to sit in the same cache line, they're constantly invalidating each other's cache.
This is false sharing. It can tank performance in multithreaded code even when there are no logical data races.
class Counters {
volatile long counterA; // thread 1 writes this
volatile long counterB; // thread 2 writes this
}
Both longs might be in the same 64-byte cache line. Thread 1 and Thread 2 are fighting over the same cache line even though they're writing different variables.
Fix: padding.
class Counters {
volatile long counterA;
long p1, p2, p3, p4, p5, p6, p7; // padding
volatile long counterB;
}
Or Java 8+:
@jdk.internal.vm.annotation.Contended
volatile long counterA;
You probably won't fix false sharing on day 1. But knowing it exists tells an interviewer you've thought about concurrency performance beyond just correctness.
17. What senior interviewers are actually fishing for
They don't want definitions. Anyone can look up definitions.
They want to see if you can reason.
If they show you broken concurrent code — can you spot why it's broken?
If they ask about performance — do you know where the bottlenecks in concurrent code come from?
Do you know when to use synchronized vs ReentrantLock vs AtomicInteger vs ConcurrentHashMap vs going back to the drawing board and questioning why multiple threads need shared mutable state at all?
That last one — immutability as a concurrency strategy — is something strong candidates bring up unprompted.
// No synchronization needed
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
Immutable objects are inherently thread-safe. No locks, no visibility issues, no race conditions. You can share them freely across threads.
If you can reach for immutability, do.
The biggest mistakes in Java concurrency interviews
❌ "volatile makes operations atomic."
It doesn't.
❌ "synchronized just prevents two threads from running at the same time."
It also establishes happens-before relationships for memory visibility.
❌ "ConcurrentHashMap is fully thread-safe."
Individual operations are. Compound operations aren't.
❌ "Thread.sleep() releases the lock."
It does not. wait() does. sleep() just pauses execution while holding any locks it holds.
❌ "More threads always means more performance."
No. Thread creation has overhead. Context switching has overhead. Lock contention gets worse with more threads. Sometimes fewer threads with better design is faster.
❌ "Virtual threads replace everything."
No. They help with I/O-bound blocking workloads. CPU-bound work still needs careful parallelism strategy.
❌ "I can test threading bugs reliably."
You can't. Race conditions are timing-dependent. Code that passes tests on your machine can fail in production under different load.
Final thought
The thing about Java concurrency is that wrong code often looks right.
private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000000; i++) counter++;
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000000; i++) counter++;
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter); // probably not 2000000
}
This looks like it should print 2000000. It won't. And if you run it enough times, sometimes it'll be close enough that you second-guess yourself.
That's concurrency. It punishes you subtly, intermittently, and usually in production.
The developers who get hired for senior roles aren't the ones who've memorized every API. They're the ones who've been bitten enough times to develop genuine intuition about shared mutable state — and who can explain why something is broken, not just that it's broken.
If you want to get good at this, stop writing single-threaded code in isolation and start asking: "what happens if two threads hit this simultaneously?"
Ask that question enough, and you'll start seeing race conditions before they see you.