Back to Blog
GuidesOctober 1, 20265 min read

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.

Share this article:

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 volatile actually 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:

  1. Read count
  2. Add 1
  3. 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 monitor
  • volatile — write happens-before subsequent read of same variable
  • Thread.start() — everything before start() happens-before anything in the new thread
  • Thread.join() — everything in the thread happens-before join() returns
  • CompletableFuture chains — 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 blocking
  • tryLock(timeout, unit) — attempt with timeout
  • lockInterruptibly() — can be interrupted while waiting
  • Multiple Condition objects via newCondition()
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 (like map)
  • thenCompose — chain another CompletableFuture (like flatMap)
  • thenAccept — consume the result, return nothing
  • thenRun — run something after completion, doesn't see the result
  • exceptionally — handle exception, provide fallback
  • handle — handle both result and exception
  • whenComplete — 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.

  • supplyAsync without an executor → uses ForkJoinPool.commonPool()
  • thenApply → if the previous stage is already complete when thenApply is 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.

Subscribe to our newsletter

Get notified when we publish new engineering resources, guides, and platform updates.