Java Concurrency Interview Guide: From Threads to Virtual Threads

·15 min read
javaconcurrencymultithreadingvirtual-threadsbackendinterview-preparation

Concurrency is where Java interviews separate juniors from seniors. Anyone can write single-threaded code—understanding thread safety, synchronization, and modern concurrency APIs shows real expertise.

Java's concurrency model has evolved significantly. From raw threads and synchronized blocks to ExecutorService, CompletableFuture, and now virtual threads in Java 21. Interviewers expect you to know when to use each approach.

This guide covers the concurrency concepts that come up in Java backend interviews—from fundamentals to modern patterns.


Thread Fundamentals

Before diving into advanced topics, understand how threads work in Java.

Thread Lifecycle

NEW → RUNNABLE → RUNNING → BLOCKED/WAITING → TERMINATED
         ↑                        ↓
         ←────────────────────────←
StateDescription
NEWThread created, not yet started
RUNNABLEReady to run, waiting for CPU
RUNNINGCurrently executing
BLOCKEDWaiting to acquire a lock
WAITINGWaiting indefinitely (wait(), join())
TIMED_WAITINGWaiting with timeout (sleep(), wait(timeout))
TERMINATEDExecution completed

Creating Threads

Extending Thread (not recommended):

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("Running in: " + Thread.currentThread().getName());
    }
}
 
MyThread thread = new MyThread();
thread.start(); // start(), not run()

Implementing Runnable (preferred):

Runnable task = () -> {
    System.out.println("Running in: " + Thread.currentThread().getName());
};
 
Thread thread = new Thread(task);
thread.start();

Why Runnable is better:

  • Java allows single inheritance—extending Thread wastes it
  • Runnable separates task from execution mechanism
  • Same Runnable can be executed by Thread, ExecutorService, etc.

Runnable vs Callable

// Runnable: no return value, no checked exceptions
Runnable runnable = () -> {
    System.out.println("Fire and forget");
};
 
// Callable: returns value, can throw exceptions
Callable<Integer> callable = () -> {
    Thread.sleep(1000);
    return 42;
};
 
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(callable);
Integer result = future.get(); // Blocks until complete, returns 42

Thread Methods

// Sleep - pause current thread
Thread.sleep(1000); // Throws InterruptedException
 
// Join - wait for another thread to finish
Thread worker = new Thread(task);
worker.start();
worker.join(); // Current thread waits for worker to complete
 
// Interrupt - signal thread to stop
worker.interrupt();
 
// In the worker thread:
if (Thread.interrupted()) {
    // Clean up and exit
    return;
}
 
// Or handle InterruptedException
try {
    Thread.sleep(10000);
} catch (InterruptedException e) {
    // Thread was interrupted during sleep
    Thread.currentThread().interrupt(); // Restore interrupt status
    return;
}

Synchronization

When multiple threads access shared data, you need synchronization to prevent race conditions.

The Problem: Race Conditions

// NOT thread-safe
class Counter {
    private int count = 0;
 
    public void increment() {
        count++; // Read-modify-write: not atomic!
    }
 
    public int getCount() {
        return count;
    }
}
 
// Two threads incrementing 1000 times each
// Expected: 2000, Actual: unpredictable (often less)

synchronized Keyword

class Counter {
    private int count = 0;
 
    // Synchronized method - locks on 'this'
    public synchronized void increment() {
        count++;
    }
 
    // Synchronized block - more granular control
    public void incrementWithBlock() {
        synchronized (this) {
            count++;
        }
    }
 
    // Synchronize on specific lock object
    private final Object lock = new Object();
 
    public void incrementWithLock() {
        synchronized (lock) {
            count++;
        }
    }
}

synchronized guarantees:

  • Mutual exclusion: Only one thread executes the block at a time
  • Visibility: Changes are visible to other threads after unlock
  • Happens-before: Actions before unlock happen-before actions after lock

ReentrantLock

More flexible than synchronized, with additional features.

import java.util.concurrent.locks.ReentrantLock;
 
class Counter {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();
 
    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock(); // ALWAYS unlock in finally
        }
    }
 
    // tryLock - non-blocking attempt
    public boolean tryIncrement() {
        if (lock.tryLock()) {
            try {
                count++;
                return true;
            } finally {
                lock.unlock();
            }
        }
        return false; // Couldn't acquire lock
    }
 
    // tryLock with timeout
    public boolean tryIncrementWithTimeout() throws InterruptedException {
        if (lock.tryLock(1, TimeUnit.SECONDS)) {
            try {
                count++;
                return true;
            } finally {
                lock.unlock();
            }
        }
        return false; // Timeout
    }
}

ReentrantLock vs synchronized:

FeaturesynchronizedReentrantLock
SyntaxSimplerMore verbose
UnlockAutomaticManual (try-finally)
tryLockNoYes
TimeoutNoYes
FairnessNoOptional
Multiple conditionsNoYes (newCondition())
InterruptibleNoYes (lockInterruptibly())

volatile Keyword

Ensures visibility but not atomicity.

class Flag {
    // Without volatile, other threads might not see the change
    private volatile boolean running = true;
 
    public void stop() {
        running = false; // Immediately visible to other threads
    }
 
    public void run() {
        while (running) {
            // Do work
        }
    }
}

When to use volatile:

  • Simple flags read by multiple threads
  • Double-checked locking pattern
  • Single writer, multiple readers

When NOT to use volatile:

// volatile doesn't make this atomic!
private volatile int count = 0;
count++; // Still a race condition
 
// Use AtomicInteger instead
private AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // Atomic operation

Atomic Classes

Lock-free thread-safe operations using CAS (compare-and-swap).

import java.util.concurrent.atomic.*;
 
// Atomic primitives
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();     // Returns new value
counter.getAndIncrement();     // Returns old value
counter.addAndGet(5);          // Add and return new value
counter.compareAndSet(5, 10);  // If current is 5, set to 10
 
AtomicLong longCounter = new AtomicLong();
AtomicBoolean flag = new AtomicBoolean(true);
 
// Atomic reference
AtomicReference<User> userRef = new AtomicReference<>(initialUser);
userRef.compareAndSet(oldUser, newUser);
 
// Atomic update with function
counter.updateAndGet(x -> x * 2);
counter.accumulateAndGet(5, Integer::sum);
 
// LongAdder - better for high contention
LongAdder adder = new LongAdder();
adder.increment(); // Multiple threads can increment concurrently
adder.sum();       // Get total

Concurrent Collections

Thread-safe collections optimized for concurrent access.

ConcurrentHashMap

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
 
// Basic operations are thread-safe
map.put("key", 1);
map.get("key");
map.remove("key");
 
// Atomic compound operations
map.putIfAbsent("key", 1);           // Only puts if key doesn't exist
map.computeIfAbsent("key", k -> 1);  // Compute value if absent
map.computeIfPresent("key", (k, v) -> v + 1); // Update if present
map.merge("key", 1, Integer::sum);   // Merge with existing value
 
// Atomic update
map.compute("counter", (key, value) ->
    value == null ? 1 : value + 1
);
 
// Bulk operations (parallel-friendly)
map.forEach(2, (key, value) ->
    System.out.println(key + ": " + value)
);
 
long sum = map.reduceValues(2, Long::sum);

Why not Collections.synchronizedMap?

// synchronizedMap locks entire map for every operation
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
 
// ConcurrentHashMap uses fine-grained locking
// Multiple threads can read/write different buckets simultaneously

CopyOnWriteArrayList

Optimized for read-heavy, write-rare scenarios.

CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
 
// Every write creates a new copy of the underlying array
list.add("item");
 
// Reads never block, even during writes
// Iterators see a snapshot at the time of creation
for (String item : list) {
    // Safe even if another thread is adding/removing
    System.out.println(item);
}

Use when: Many reads, few writes, need safe iteration

Avoid when: Frequent writes (expensive copying)

BlockingQueue

Thread-safe queue with blocking operations—perfect for producer-consumer.

BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100);
 
// Producer
public void produce(Task task) throws InterruptedException {
    queue.put(task);  // Blocks if queue is full
}
 
// Consumer
public void consume() throws InterruptedException {
    Task task = queue.take();  // Blocks if queue is empty
    process(task);
}
 
// Non-blocking alternatives
queue.offer(task);              // Returns false if full
queue.offer(task, 1, SECONDS);  // Wait up to 1 second
queue.poll();                   // Returns null if empty
queue.poll(1, SECONDS);         // Wait up to 1 second

BlockingQueue implementations:

ImplementationCharacteristics
LinkedBlockingQueueOptionally bounded, FIFO
ArrayBlockingQueueBounded, FIFO, fair option
PriorityBlockingQueueUnbounded, priority ordering
SynchronousQueueZero capacity, direct handoff
DelayQueueElements available after delay

ExecutorService & Thread Pools

Don't create threads manually—use thread pools for production code.

Why Thread Pools?

// Bad: Creating threads is expensive
for (int i = 0; i < 1000; i++) {
    new Thread(task).start(); // 1000 threads = resource exhaustion
}
 
// Good: Reuse a fixed number of threads
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
    executor.submit(task); // Tasks queued, executed by 10 threads
}
executor.shutdown();

Thread Pool Types

// Fixed thread pool - predictable resource usage
ExecutorService fixed = Executors.newFixedThreadPool(10);
 
// Cached thread pool - grows/shrinks based on demand
ExecutorService cached = Executors.newCachedThreadPool();
 
// Single thread - guarantees sequential execution
ExecutorService single = Executors.newSingleThreadExecutor();
 
// Scheduled - for delayed/periodic tasks
ScheduledExecutorService scheduled = Executors.newScheduledThreadPool(5);
 
// Work-stealing (ForkJoinPool) - for parallel divide-and-conquer
ExecutorService workStealing = Executors.newWorkStealingPool();

Custom Thread Pool

// Full control with ThreadPoolExecutor
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    5,                      // corePoolSize
    20,                     // maximumPoolSize
    60, TimeUnit.SECONDS,   // keepAliveTime for idle threads
    new LinkedBlockingQueue<>(100),  // work queue
    new ThreadFactory() {    // custom thread factory
        private int count = 0;
        public Thread newThread(Runnable r) {
            return new Thread(r, "worker-" + count++);
        }
    },
    new ThreadPoolExecutor.CallerRunsPolicy()  // rejection policy
);

Rejection policies (when queue is full):

PolicyBehavior
AbortPolicyThrows RejectedExecutionException
CallerRunsPolicyCaller thread runs the task
DiscardPolicySilently discards the task
DiscardOldestPolicyDiscards oldest queued task

Submitting Tasks

ExecutorService executor = Executors.newFixedThreadPool(10);
 
// Submit Runnable - no result
executor.execute(runnable);
Future<?> future = executor.submit(runnable);
 
// Submit Callable - get result
Future<Integer> result = executor.submit(callable);
Integer value = result.get();           // Blocks until complete
Integer value = result.get(1, SECONDS); // With timeout
 
// Submit multiple tasks
List<Callable<Integer>> tasks = List.of(task1, task2, task3);
List<Future<Integer>> futures = executor.invokeAll(tasks);
Integer first = executor.invokeAny(tasks); // First completed result

Proper Shutdown

ExecutorService executor = Executors.newFixedThreadPool(10);
 
// Graceful shutdown
executor.shutdown(); // No new tasks, complete existing
 
// Wait for completion
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
    // Timeout - force shutdown
    executor.shutdownNow(); // Interrupt running tasks
}
 
// Or combine in try-with-resources (Java 19+)
try (ExecutorService exec = Executors.newFixedThreadPool(10)) {
    exec.submit(task);
} // Auto-shutdown on close

CompletableFuture

Modern async programming—compose, chain, and combine async operations.

Basic Usage

// Run async task
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
    System.out.println("Running in: " + Thread.currentThread().getName());
});
 
// Supply async result
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    return fetchDataFromApi();
});
 
// Get result (blocking)
String result = future.get();
String result = future.get(1, TimeUnit.SECONDS);
 
// Non-blocking result handling
future.thenAccept(result -> System.out.println("Got: " + result));

Chaining Operations

CompletableFuture.supplyAsync(() -> fetchUserId())
    .thenApply(userId -> fetchUser(userId))           // Transform result
    .thenApply(user -> user.getEmail())               // Transform again
    .thenAccept(email -> sendNotification(email))     // Consume result
    .thenRun(() -> System.out.println("Done"))        // Run action
    .exceptionally(ex -> {                            // Handle errors
        log.error("Failed", ex);
        return null;
    });
 
// Async versions for blocking operations
CompletableFuture.supplyAsync(() -> fetchUserId())
    .thenApplyAsync(userId -> fetchUser(userId))      // Run in thread pool
    .thenApplyAsync(user -> user.getEmail(), customExecutor);

Combining Futures

CompletableFuture<User> userFuture = fetchUserAsync(userId);
CompletableFuture<List<Order>> ordersFuture = fetchOrdersAsync(userId);
 
// Combine two futures
CompletableFuture<UserWithOrders> combined = userFuture
    .thenCombine(ordersFuture, (user, orders) ->
        new UserWithOrders(user, orders)
    );
 
// Wait for both (no result combination)
CompletableFuture<Void> both = CompletableFuture
    .allOf(userFuture, ordersFuture);
 
// Wait for first completed
CompletableFuture<Object> first = CompletableFuture
    .anyOf(future1, future2, future3);

Error Handling

CompletableFuture.supplyAsync(() -> {
    if (error) throw new RuntimeException("Failed");
    return "success";
})
.exceptionally(ex -> {
    // Handle exception, return default
    log.error("Error", ex);
    return "default";
})
.handle((result, ex) -> {
    // Handle both success and failure
    if (ex != null) {
        return "error: " + ex.getMessage();
    }
    return "success: " + result;
})
.whenComplete((result, ex) -> {
    // Side effect, doesn't transform result
    if (ex != null) {
        log.error("Failed", ex);
    }
});

Timeout (Java 9+)

CompletableFuture.supplyAsync(() -> slowOperation())
    .orTimeout(5, TimeUnit.SECONDS)           // Throws on timeout
    .completeOnTimeout("default", 5, SECONDS); // Default on timeout

Virtual Threads (Java 21+)

Project Loom brought lightweight threads to Java—a game-changer for I/O-bound applications.

Platform Threads vs Virtual Threads

Platform Threads (traditional):
┌─────────────────────────────────────────┐
│ JVM Thread (1:1 with OS thread)         │
│ - ~1MB stack memory                     │
│ - Expensive to create                   │
│ - Limited to thousands                  │
└─────────────────────────────────────────┘

Virtual Threads (Java 21+):
┌─────────────────────────────────────────┐
│ Virtual Thread (managed by JVM)         │
│ - ~1KB initial memory                   │
│ - Cheap to create                       │
│ - Millions possible                     │
│ - Runs on carrier (platform) threads    │
└─────────────────────────────────────────┘

Creating Virtual Threads

// Direct creation
Thread vThread = Thread.ofVirtual().start(() -> {
    System.out.println("Running in virtual thread");
});
 
// Named virtual thread
Thread vThread = Thread.ofVirtual()
    .name("my-virtual-thread")
    .start(task);
 
// Virtual thread executor
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // Each task gets its own virtual thread
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            // I/O operation
            fetchFromDatabase();
        });
    }
} // Waits for all tasks to complete

When Virtual Threads Shine

// Perfect for I/O-bound tasks
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = new ArrayList<>();
 
    // 10,000 HTTP requests - each in its own virtual thread
    for (String url : urls) {
        futures.add(executor.submit(() -> {
            return httpClient.send(request, BodyHandlers.ofString())
                           .body();
        }));
    }
 
    // When a virtual thread blocks on I/O,
    // its carrier thread runs other virtual threads
}

When NOT to Use Virtual Threads

// CPU-bound work - no benefit from virtual threads
// The carrier threads are still limited
executor.submit(() -> {
    // Heavy computation - use platform threads
    return fibonacci(1000000);
});
 
// synchronized blocks pin the carrier thread
synchronized (lock) {
    // Virtual thread cannot unmount during synchronized
    // Use ReentrantLock instead
    blockingOperation();
}
 
// Better with ReentrantLock
lock.lock();
try {
    blockingOperation(); // Virtual thread can unmount
} finally {
    lock.unlock();
}

Structured Concurrency (Preview)

// Java 21 preview - manage related tasks as a unit
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<User> userFuture = scope.fork(() -> fetchUser(id));
    Future<List<Order>> ordersFuture = scope.fork(() -> fetchOrders(id));
 
    scope.join();           // Wait for all
    scope.throwIfFailed();  // Propagate errors
 
    return new UserWithOrders(
        userFuture.resultNow(),
        ordersFuture.resultNow()
    );
} // All tasks cancelled if scope exits

Common Concurrency Problems

Understanding these problems helps you avoid and debug them.

Race Condition

Multiple threads access shared data, result depends on timing.

// Race condition
class Counter {
    private int count = 0;
 
    // Two threads: read 0, read 0, write 1, write 1 → count is 1, not 2
    public void increment() {
        count++;
    }
}
 
// Fix: synchronization or atomic
class Counter {
    private AtomicInteger count = new AtomicInteger(0);
 
    public void increment() {
        count.incrementAndGet();
    }
}

Deadlock

Two threads each hold a lock the other needs.

// Deadlock waiting to happen
Object lockA = new Object();
Object lockB = new Object();
 
// Thread 1
synchronized (lockA) {
    synchronized (lockB) {
        // work
    }
}
 
// Thread 2
synchronized (lockB) {
    synchronized (lockA) {
        // work - DEADLOCK if Thread 1 holds A, Thread 2 holds B
    }
}
 
// Fix: Consistent lock ordering
// Both threads acquire locks in same order: A, then B

Preventing Deadlocks

// 1. Lock ordering - always acquire in same order
synchronized (lockA) { // Always A first
    synchronized (lockB) {
        // work
    }
}
 
// 2. Lock timeout - give up instead of waiting forever
if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        // work
    } finally {
        lock.unlock();
    }
} else {
    // Handle timeout - maybe retry later
}
 
// 3. Lock all or nothing
boolean gotBoth = false;
while (!gotBoth) {
    if (lockA.tryLock()) {
        try {
            if (lockB.tryLock()) {
                try {
                    // work
                    gotBoth = true;
                } finally {
                    lockB.unlock();
                }
            }
        } finally {
            lockA.unlock();
        }
    }
    if (!gotBoth) Thread.sleep(100); // Back off
}

Livelock

Threads keep responding to each other but make no progress.

// Livelock: both threads keep yielding to each other
while (resourceInUse) {
    Thread.yield(); // "After you" "No, after you" forever
}

Thread Starvation

Low-priority threads never get CPU time.

// Starvation: high-priority threads monopolize executor
executor.submit(highPriorityTask);
executor.submit(highPriorityTask);
executor.submit(lowPriorityTask); // May never run
 
// Fix: Fair locks, separate queues, or priority aging
ReentrantLock fairLock = new ReentrantLock(true); // Fair mode

Memory Visibility

Without synchronization, threads may see stale values.

// Thread 1 may never see running = false
class Worker {
    private boolean running = true; // Not volatile!
 
    public void stop() {
        running = false;
    }
 
    public void run() {
        while (running) { // May be cached in CPU register
            // work
        }
    }
}
 
// Fix: volatile
private volatile boolean running = true;

Quick Reference

Synchronization options:

  • synchronized - Simple mutual exclusion
  • ReentrantLock - tryLock, timeout, fairness
  • volatile - Visibility only (no atomicity)
  • Atomic* - Lock-free atomic operations

Thread pools:

  • newFixedThreadPool(n) - Fixed number of threads
  • newCachedThreadPool() - Grows/shrinks on demand
  • newVirtualThreadPerTaskExecutor() - Virtual thread per task

Concurrent collections:

  • ConcurrentHashMap - Fine-grained locking map
  • CopyOnWriteArrayList - Read-heavy, write-rare
  • BlockingQueue - Producer-consumer pattern

CompletableFuture:

  • supplyAsync - Async with result
  • thenApply - Transform result
  • thenCombine - Combine two futures
  • exceptionally - Handle errors

Virtual threads (Java 21+):

  • Cheap, lightweight threads
  • Best for I/O-bound work
  • Avoid synchronized (use ReentrantLock)
  • Use newVirtualThreadPerTaskExecutor()

Related Articles


What's Next?

Concurrency is a deep topic—this guide covers the essentials for interviews. In practice, start with the simplest solution: immutable objects where possible, then thread-safe collections, then explicit synchronization only when needed.

For Java 21+, virtual threads change the game for I/O-bound applications. Understand when they help (I/O) and when they don't (CPU-bound work).

Ready to ace your interview?

Get 550+ interview questions with detailed answers in our comprehensive PDF guides.

View PDF Guides