Java Multithreading Coding Questions for Interviews: Classic Problems and Solutions
Thread synchronization is a fundamental concept that frequently appears in technical interviews. This blog covers the classic multithreading problems that every Java developer should understand, with production-ready implementations using synchronization primitives.
Table of Contents
- Dining Philosophers Problem
- Producer-Consumer Problem
- Print Even-Odd Numbers Using Semaphores
- FizzBuzz with Four Threads
- Print 1,2,3 Using Three Threads Sequentially
- Understanding the volatile Keyword
- Interview Tips
Why These Problems Matter
Understanding these patterns helps you:
- Design thread-safe systems in distributed environments
- Prevent race conditions and deadlocks
- Optimize concurrent data processing
- Handle shared resource management effectively
Let's dive into each problem with practical implementations.
1. Dining Philosophers Problem
Problem Statement
The Dining Philosophers Problem is a classical synchronization challenge that illustrates resource deadlock and starvation scenarios.
Setup:
- Five philosophers sit around a circular table
- Five forks are placed between them (shared resources)
- Each philosopher alternates between thinking and eating
- A philosopher needs both adjacent forks to eat
Challenge: Design a solution that prevents deadlock where all philosophers grab one fork and wait indefinitely for the second.
Understanding the Deadlock Scenario
A circular wait occurs when:
- Philosopher 1 picks up fork 1
- Philosopher 2 picks up fork 2
- Philosopher 3 picks up fork 3
- Philosopher 4 picks up fork 4
- Philosopher 5 picks up fork 5
Now everyone holds one fork and waits for their neighbor's fork—classic deadlock.
Solution Strategy
The key insight: break the circular dependency by making one philosopher pick up forks in reverse order.
Implementation approach:
- Philosophers 0-3: pick left fork first, then right fork
- Philosopher 4: pick right fork first, then left fork
This asymmetry prevents circular wait conditions.
Implementation
javaclass Fork { private final int id; public Fork(int id) { this.id = id; } public int getId() { return id; } } class Philosopher implements Runnable { private final int id; private final Fork leftFork; private final Fork rightFork; private final Random random = new Random(); public Philosopher(int id, Fork leftFork, Fork rightFork) { this.id = id; this.leftFork = leftFork; this.rightFork = rightFork; } @Override public void run() { try { while (true) { think(); eat(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } private void think() throws InterruptedException { System.out.println("Philosopher " + id + " is thinking"); Thread.sleep(random.nextInt(1000)); } private void eat() throws InterruptedException { // Acquire forks in order to prevent deadlock synchronized (leftFork) { System.out.println("Philosopher " + id + " picked up left fork " + leftFork.getId()); synchronized (rightFork) { System.out.println("Philosopher " + id + " picked up right fork " + rightFork.getId()); System.out.println("Philosopher " + id + " is EATING"); Thread.sleep(random.nextInt(1000)); } System.out.println("Philosopher " + id + " put down right fork " + rightFork.getId()); } System.out.println("Philosopher " + id + " put down left fork " + leftFork.getId()); } } public class DiningPhilosophers { public static void main(String[] args) { Fork[] forks = new Fork[5]; Philosopher[] philosophers = new Philosopher[5]; // Create forks for (int i = 0; i < 5; i++) { forks[i] = new Fork(i); } // Create philosophers with asymmetric fork ordering for the last one for (int i = 0; i < 4; i++) { philosophers[i] = new Philosopher(i, forks[i], forks[i + 1]); } // Last philosopher picks right fork first to break circular wait philosophers[4] = new Philosopher(4, forks[0], forks[4]); // Start all philosopher threads for (Philosopher philosopher : philosophers) { new Thread(philosopher).start(); } } }
Key Takeaways
- Synchronized blocks provide mutual exclusion for fork access
- Random thinking time prevents starvation by introducing natural scheduling variation
- Asymmetric resource ordering breaks circular wait conditions
- Always release locks in reverse order of acquisition
Real-World Application
This problem maps directly to resource allocation in distributed systems:
Database Transaction Management:
- Multiple transactions need multiple database connections
- Transactions can deadlock when waiting for each other's resources
- Solution: Lock ordering or timeout-based deadlock detection
Microservices Resource Locking:
- Service A locks Resource 1, needs Resource 2
- Service B locks Resource 2, needs Resource 1
- Solution: Consistent global ordering of resource acquisition
Interview Follow-Up Questions
Interviewers often ask:
- "What if we have N philosophers instead of 5?"
- "How would you detect deadlock in this system?"
- "What's the throughput impact of this solution?"
- "How would you implement fairness so all philosophers eat equally?"
Hint: For fairness, track eating counts and prioritize hungry philosophers using wait queues or semaphore permits.
2. Producer-Consumer Problem
Problem Statement
The Producer-Consumer problem demonstrates how multiple threads can safely share a bounded buffer without race conditions.
Scenario:
- Shared queue with fixed capacity (e.g., 5 elements)
- Producer threads add items when queue isn't full
- Consumer threads remove items when queue isn't empty
- Must prevent: overflow, underflow, and race conditions
Solution Using wait() and notify()
This implementation uses Java's intrinsic locks and inter-thread communication mechanisms.
Implementation
javaclass ProducerConsumer { private final Queue<Integer> queue = new LinkedList<>(); private final int capacity; public ProducerConsumer(int capacity) { this.capacity = capacity; } public synchronized void produce(int item) throws InterruptedException { // Wait while queue is full while (queue.size() == capacity) { System.out.println("Queue is full. Producer waiting..."); wait(); // Release lock and wait } queue.add(item); System.out.println("Produced: " + item + " | Queue size: " + queue.size()); // Notify consumers that item is available notifyAll(); } public synchronized int consume() throws InterruptedException { // Wait while queue is empty while (queue.isEmpty()) { System.out.println("Queue is empty. Consumer waiting..."); wait(); // Release lock and wait } int item = queue.poll(); System.out.println("Consumed: " + item + " | Queue size: " + queue.size()); // Notify producers that space is available notifyAll(); return item; } } class Producer implements Runnable { private final ProducerConsumer pc; private int item = 0; public Producer(ProducerConsumer pc) { this.pc = pc; } @Override public void run() { try { while (true) { pc.produce(++item); Thread.sleep(500); // Simulate production time } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } class Consumer implements Runnable { private final ProducerConsumer pc; public Consumer(ProducerConsumer pc) { this.pc = pc; } @Override public void run() { try { while (true) { pc.consume(); Thread.sleep(1000); // Simulate consumption time } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } public class ProducerConsumerDemo { public static void main(String[] args) { ProducerConsumer pc = new ProducerConsumer(5); Thread producerThread = new Thread(new Producer(pc), "Producer"); Thread consumerThread = new Thread(new Consumer(pc), "Consumer"); producerThread.start(); consumerThread.start(); } }
Key Synchronization Concepts
- wait(): Releases the lock and suspends the thread until notified
- notify()/notifyAll(): Wakes up waiting threads
- synchronized: Ensures atomic operations on shared resources
java// ALWAYS use while loops with wait(), not if statements while (conditionNotMet) { wait(); }
Real-World Application
This pattern is fundamental in production systems:
Message Queue Systems:
- Kafka/RabbitMQ implement producer-consumer pattern at scale
- Producers: Application services pushing events
- Consumers: Worker threads processing events
Thread Pool Executors:
java// Java's ThreadPoolExecutor uses producer-consumer internally ThreadPoolExecutor executor = new ThreadPoolExecutor( corePoolSize, maxPoolSize, keepAliveTime, TimeUnit.SECONDS, new ArrayBlockingQueue<>(queueCapacity) // Bounded buffer! );
Log Aggregation & API Rate Limiting:
- In-memory buffers prevent I/O bottlenecks
- Queue buffers handle burst traffic
Interview Follow-Up Questions
Expect deeper probing:
- What happens if producers are much faster than consumers?
- How would you handle multiple consumers for load distribution
- What if produce() or consume() operations can fail?
- How would you add priority to certain items?
- What metrics would you track in production?
Real Answer: In production, use BlockingQueue implementations like LinkedBlockingQueue or ArrayBlockingQueue. They handle all the synchronization internally and are battle-tested. For distributed systems, use message brokers like Kafka or RabbitMQ.
3. Print Even-Odd Numbers Using Semaphores
Problem Statement
Use two threads to print numbers from 1 to N where:
- Thread 1 prints odd numbers (1, 3, 5, ...)
- Thread 2 prints even numbers (2, 4, 6, ...)
- Numbers must be printed in sequential order
Understanding Semaphores
A semaphore is a signaling mechanism that controls access to shared resources using permits.
Key methods:
- acquire(): Acquires a permit, blocking if none available
- release(): Releases a permit, potentially unblocking waiting threads
Implementation
javaclass OddEvenPrinter { private final int max; private final Semaphore oddSemaphore = new Semaphore(1); // Start with odd private final Semaphore evenSemaphore = new Semaphore(0); public OddEvenPrinter(int max) { this.max = max; } public void printOdd() { for (int i = 1; i <= max; i += 2) { try { oddSemaphore.acquire(); // Wait for permit System.out.println("Odd: " + i); evenSemaphore.release(); // Give permit to even thread } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } public void printEven() { for (int i = 2; i <= max; i += 2) { try { evenSemaphore.acquire(); // Wait for permit System.out.println("Even: " + i); oddSemaphore.release(); // Give permit to odd thread } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } } public class OddEvenDemo { public static void main(String[] args) { OddEvenPrinter printer = new OddEvenPrinter(10); Thread oddThread = new Thread(printer::printOdd, "Odd-Thread"); Thread evenThread = new Thread(printer::printEven, "Even-Thread"); oddThread.start(); evenThread.start(); } }
How It Works
- Odd semaphore starts with 1 permit (odd thread goes first)
- Even semaphore starts with 0 permits (even thread waits)
- After printing, each thread releases a permit for the other
- This creates a ping-pong effect ensuring sequential ordering
Semaphore vs. synchronized
| Semaphore | synchronized |
|---|---|
| Permits can be acquired/released by different threads | Lock must be released by acquiring thread |
| Supports fairness policies | No built-in fairness |
| Can control multiple permits | Binary (locked/unlocked) |
| More flexible signaling | Simpler for mutual exclusion |
Real-World Application
Resource Pooling:
java// Limit concurrent database connections to 10 Semaphore dbConnections = new Semaphore(10); public void executeQuery(String query) { dbConnections.acquire(); // Wait if 10 connections in use try { // Execute database query } finally { dbConnections.release(); // Free up connection } }
Concurrency Control:
- API rate limiting (N permits = N requests per window)
- Thread coordination in batch processing
- Bounded resource access in distributed systems
Interview Follow-Up Questions
- How would you implement a counting semaphore from scratch using wait/notify?
- What's the difference between fair and unfair semaphores?
- When would you choose Semaphore over ReentrantLock?
- How do semaphores help with resource pooling?
Key Insight: Semaphores are about controlling access to resources, while locks are about protecting critical sections. In interviews, articulate this distinction clearly.
Alternative Implementation Using wait()/notify()
While semaphores provide a clean solution, you can also solve this problem using traditional wait/notify mechanisms. This approach is often asked in interviews to test your understanding of low-level thread synchronization.
javapublic class EvenOddPrinter { static final String ODD_THREAD = " ODD_THREAD"; static final String EVEN_THREAD = " EVEN_THREAD"; public static void main(String[] args) { SharedPrinter sharedPrinter = new SharedPrinter(); Thread evenThread = new Thread(new Task(sharedPrinter, true), EVEN_THREAD); Thread oddThread = new Thread(new Task(sharedPrinter, false), ODD_THREAD); evenThread.start(); oddThread.start(); } } class Task implements Runnable { private SharedPrinter sharedPrinter; private boolean isEven; public Task(SharedPrinter sharedPrinter, boolean isEven) { this.sharedPrinter = sharedPrinter; this.isEven = isEven; } @Override public void run() { int number = isEven ? 2 : 1; while (number <= 10) { sharedPrinter.printNumber(number, isEven); number += 2; } } } class SharedPrinter { private volatile boolean isEven = true; synchronized void printNumber(int number, boolean isEven) { try { // Wait while it's not this thread's turn while (this.isEven == isEven) { wait(); } // Print the number with thread name System.out.println(number + Thread.currentThread().getName()); // Toggle the state this.isEven = !this.isEven; // Notify waiting thread notify(); } catch (Exception e) { e.printStackTrace(); } } }
Key differences from semaphore approach:
- Uses boolean flag instead of semaphore permits
- Direct thread coordination with wait/notify
- Requires careful synchronization to prevent lost notifications
4. FizzBuzz with Four Threads
Problem Statement
Implement the classic FizzBuzz problem using four threads:
- Thread 1: Print "Fizz" for numbers divisible by 3
- Thread 2: Print "Buzz" for numbers divisible by 5
- Thread 3: Print "FizzBuzz" for numbers divisible by both
- Thread 4: Print the number if none of the above apply
Implementation
javaclass FizzBuzz { private final int n; private int current = 1; private volatile int turn = 4; // Start with number thread public FizzBuzz(int n) { this.n = n; } public synchronized void fizz() throws InterruptedException { while (current <= n) { while (current <= n && turn != 1) { wait(); } if (current > n) break; System.out.println("Fizz"); current++; turn = getNextTurn(current); notifyAll(); } } public synchronized void buzz() throws InterruptedException { while (current <= n) { while (current <= n && turn != 2) { wait(); } if (current > n) break; System.out.println("Buzz"); current++; turn = getNextTurn(current); notifyAll(); } } public synchronized void fizzbuzz() throws InterruptedException { while (current <= n) { while (current <= n && turn != 3) { wait(); } if (current > n) break; System.out.println("FizzBuzz"); current++; turn = getNextTurn(current); notifyAll(); } } public synchronized void number() throws InterruptedException { while (current <= n) { while (current <= n && turn != 4) { wait(); } if (current > n) break; System.out.println(current); current++; turn = getNextTurn(current); notifyAll(); } } private int getNextTurn(int num) { if (num > n) return -1; if (num % 15 == 0) return 3; // FizzBuzz if (num % 3 == 0) return 1; // Fizz if (num % 5 == 0) return 2; // Buzz return 4; // Number } } public class FizzBuzzDemo { public static void main(String[] args) { FizzBuzz fizzBuzz = new FizzBuzz(25); Thread t1 = new Thread(() -> { try { fizzBuzz.fizz(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "Fizz"); Thread t2 = new Thread(() -> { try { fizzBuzz.buzz(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "Buzz"); Thread t3 = new Thread(() -> { try { fizzBuzz.fizzbuzz(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "FizzBuzz"); Thread t4 = new Thread(() -> { try { fizzBuzz.number(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "Number"); t1.start(); t2.start(); t3.start(); t4.start(); } }
Design Decisions
Why check divisibility by 15 first?
javaif (num % 15 == 0) return 3; // Must check FizzBuzz before Fizz or Buzz if (num % 3 == 0) return 1; if (num % 5 == 0) return 2;
Numbers divisible by 15 are also divisible by both 3 and 5. Checking 15 first ensures correct "FizzBuzz" output.
Turn coordination: Uses a state machine pattern with the turn variable to determine which thread should execute next.
Real-World Application
While FizzBuzz seems contrived, the state machine with multi-threaded coordination pattern appears in:
Distributed Workflow Orchestration:
java// Order processing pipeline: Validate → Payment → Inventory → Shipping // Different services handle different states // Coordination required to ensure correct order
Event Stream Processing:
- Multiple consumers process events based on event type
- Event router determines which consumer handles which event
- Similar turn-based coordination ensures ordering
Game Server State Management:
- Different threads handle: physics, rendering, AI, networking
- Each must execute in correct order per game tick
- Turn coordination prevents race conditions in game state
ETL Pipelines:
- Extract, Transform, Load stages run in parallel threads
- Coordination ensures data flows in correct sequence
- Similar conditional execution based on data characteristics
Interview Follow-Up Questions
- How would you handle if one thread fails?
- What if FizzBuzz goes to 1 million—performance implications?
- How would you add a fifth condition (e.g., 'Whizz' for divisible by 7)?
- Is there a lock-free way to implement this?
- What metrics would indicate thread starvation?
Advanced Insight: Interviewers may ask you to redesign this without shared state—consider using CountDownLatch or CyclicBarrier with message passing instead of shared turn variable.
5. Print 1,2,3 Using Three Threads Sequentially
Problem Statement
Create three threads that print numbers in sequence:
- Thread 1: Prints only 1, 4, 7, ...
- Thread 2: Prints only 2, 5, 8, ...
- Thread 3: Prints only 3, 6, 9, ...
The output should be in perfect sequence: 1, 2, 3, 4, 5, 6, ...
Solution Approach
This problem extends the even-odd pattern to three threads. We'll use a Boolean object with three possible states (true, false, null) to coordinate which thread should print next.
Implementation
javapublic class SequentialNumberPrinter { static final String ONE = " ONE_THREAD"; static final String TWO = " TWO_THREAD"; static final String THREE = " THREE_THREAD"; public static void main(String[] args) { SharedPrinter sharedPrinter = new SharedPrinter(); Thread oneThread = new Thread(new Task(sharedPrinter, true), ONE); Thread twoThread = new Thread(new Task(sharedPrinter, false), TWO); Thread threeThread = new Thread(new Task(sharedPrinter, null), THREE); oneThread.start(); twoThread.start(); threeThread.start(); } } class Task implements Runnable { private SharedPrinter sharedPrinter; private Boolean isTurn; public Task(SharedPrinter sharedPrinter, Boolean isTurn) { this.sharedPrinter = sharedPrinter; this.isTurn = isTurn; } @Override public void run() { int number = isTurn != null ? (isTurn ? 1 : 2) : 3; while (number <= 10) { sharedPrinter.printNumber(number, isTurn); number += 3; } } } class SharedPrinter { private volatile Boolean isTurn = true; // Start with thread 1 synchronized void printNumber(int number, Boolean isTurn) { try { // Wait until it's this thread's turn while (this.isTurn != isTurn) { wait(); } // Print the number with thread name System.out.println(number + Thread.currentThread().getName()); // Cycle through the states: true -> false -> null -> true if (this.isTurn != null && this.isTurn) { this.isTurn = false; // Thread 1 -> Thread 2 } else if (this.isTurn != null && !this.isTurn) { this.isTurn = null; // Thread 2 -> Thread 3 } else { this.isTurn = true; // Thread 3 -> Thread 1 } // Wake up all waiting threads notifyAll(); } catch (Exception e) { e.printStackTrace(); } } }
How It Works
- We use a tri-state Boolean (true, false, null) to track which thread should print
- Each thread waits until the shared state matches its assigned state
- After printing, the thread cycles the state to the next thread's value
- notifyAll() is used instead of notify() since we have more than two threads
Key Insights
State Transitions:
- true → false → null → true (Thread 1 → Thread 2 → Thread 3 → Thread 1)
Thread Identification:
- Thread 1: isTurn = true
- Thread 2: isTurn = false
- Thread 3: isTurn = null
Why notifyAll() instead of notify()?
With three threads, using notify() might wake up the wrong thread. notifyAll() ensures all threads check the condition, but only the one whose turn it is will proceed.
Real-World Applications
Sequential Processing Pipelines:
- Multi-stage data processing with ordered execution
- Round-robin task distribution among worker threads
Interview Follow-Up Questions
- How would you extend this to N threads?
- What happens if one thread fails?
- How would you implement this using Semaphores?
- Can you implement this using ReentrantLock and Conditions?
Advanced Solution: For N threads, replace the Boolean with an integer counter and use modulo arithmetic to determine the next thread's turn.
6. Understanding the volatile Keyword
Why volatile Matters
Consider this scenario:
javaclass Task { private boolean running = true; // Potential visibility issue public void run() { while (running) { // Do work } } public void stop() { running = false; // May not be visible to run() thread } }
Problem: In multi-core systems, each thread may cache variables locally. Changes made by one thread might not be visible to others immediately.
The volatile Solution
javaclass Task { private volatile boolean running = true; // Ensures visibility public void run() { while (running) { // Do work } } public void stop() { running = false; // Immediately visible to all threads } }
What volatile Guarantees
- Visibility: Writes to volatile variables are immediately visible to all threads
- Ordering: Prevents instruction reordering around volatile operations
- Atomicity: Reads and writes are atomic for primitive types and references
When to Use volatile
Use volatile for:
- Simple flags (boolean status variables)
- Variables with single writer, multiple readers
- Non-compound operations (read, write)
Don't use volatile for:
- Compound operations (i++, read-modify-write)
- Complex state updates requiring atomicity
java// WRONG - volatile doesn't help here private volatile int counter = 0; public void increment() { counter++; // Not thread-safe! Use AtomicInteger instead } // CORRECT - simple flag private volatile boolean shutdownRequested = false; public void shutdown() { shutdownRequested = true; // Thread-safe }
volatile vs. synchronized
| volatile | synchronized |
|---|---|
| Visibility only | Visibility + atomicity |
| No locking | Uses monitor locks |
| Faster | Slower due to lock overhead |
| Simple read/write | Complex operations |
Real-World Application
Common Usage Patterns:
java// Shutdown flag pattern public class Worker implements Runnable { private volatile boolean shutdownRequested = false; public void run() { while (!shutdownRequested) { processNextItem(); } cleanup(); } public void shutdown() { shutdownRequested = true; // Immediately visible to all threads } }
- Configuration hot reloading (background thread updates, all threads see latest)
- Circuit breaker pattern (state changes visible to all request threads)
Interview Follow-Up Questions
- Can volatile guarantee atomicity for count++? Why or why not?
- What is the Java Memory Model and how does volatile fit in?
- Explain happens before relationship with volatile variables
- When would you use volatile instead of AtomicInteger?
- What are the CPU cache implications of volatile?
Deep Insight: volatile prevents CPU cache coherence issues by forcing reads from main memory and flushing writes immediately. This adds memory barrier semantics but not atomicity. Interviewers want to hear you understand the hardware-level implications, not just the API.
Common Patterns and Best Practices
Core Synchronization Patterns
java// 1. Shared State Protection synchronized (sharedObject) { // Modify shared state safely } // 2. Condition-Based Waiting synchronized (lock) { while (!conditionMet) { // Always use while, not if lock.wait(); } // Proceed when condition is true } // 3. Signaling After State Changes synchronized (lock) { // Update state lock.notifyAll(); // Wake up waiting threads }
Deadlock Prevention
- Resource Ordering: Acquire locks in consistent global order
- Timeouts: Use tryLock(timeout) instead of indefinite blocking
- Asymmetric Access: Break circular dependencies (Dining Philosophers)
Performance Optimization
- Fine-grained Locking: Lock at field level, not object level
- Lock-free Alternatives: Use atomic classes for simple counters
- Minimize Critical Sections: Do expensive work outside synchronized blocks
Interview Tips
What Interviewers Look For
Interviewers aren't just checking if you can solve the problem. They're also evaluating how you think about concurrency in production systems.
1. Problem Decomposition and Analysis
What they're testing:
- Can you identify the shared resources?
- Do you recognize the critical sections?
- Can you articulate race conditions before coding?
Example dialogue:
plaintextInterviewer: Implement producer-consumer You: Let me first identify what needs synchronization: - The shared queue (critical resource) - Queue size checks (race-prone operations) - Condition: queue full/empty (coordination points) I'll use wait/notify for coordination and synchronized for atomicity.
This shows structured thinking, not just jumping to code.
2. Trade-off Discussion
What they're testing:
- Do you know multiple solutions?
- Can you compare approaches?
- Do you consider production implications?
Strong answer structure:
plaintextApproach 1: synchronized with wait/notify Pros: Simple, built-in, no dependencies Cons: Coarse-grained locking, less flexible Approach 2: ReentrantLock with Conditions Pros: Fine-grained control, interruptible locks, fairness options Cons: More verbose, easy to forget unlock Approach 3: BlockingQueue (java.util.concurrent) Pros: Battle-tested, handles edge cases, optimal performance Cons: Less learning value in interview context For production, I'd use BlockingQueue. For understanding fundamentals, let me implement with synchronized.
3. Deadlock Prevention Strategies
What they're testing:
- Do you recognize deadlock conditions?
- Can you apply prevention techniques?
- Do you understand the four Coffman conditions?
The four conditions for deadlock:
- Mutual Exclusion: Resources are non-shareable
- Hold and Wait: Threads hold resources while waiting for more
- No Preemption: Resources can't be forcibly taken
- Circular Wait: Circular chain of threads waiting for resources
Prevention techniques to mention:
- Lock ordering: Always acquire locks in consistent global order
- Lock timeout: Use tryLock(timeout) instead of blocking indefinitely
- Deadlock detection: Periodic thread dump analysis in production
- Resource hierarchy: Assign ordering to resources
Production example:
java// BAD - potential deadlock synchronized(resourceA) { synchronized(resourceB) { ... } } // GOOD - consistent ordering private final Object LOCK_1 = new Object(); private final Object LOCK_2 = new Object(); // Always acquire in order: LOCK_1 before LOCK_2 synchronized(LOCK_1) { synchronized(LOCK_2) { ... } }
4. Performance and Scalability Awareness
What they're testing:
- Do you understand lock contention?
- Can you minimize critical sections?
- Do you know when to use lock-free alternatives?
Key insights to demonstrate:
Lock Granularity:
java// COARSE-GRAINED - high contention synchronized(entireObject) { // Lots of operations } // FINE-GRAINED - lower contention // Only lock what's necessary computeExpensiveValue(); synchronized(specificField) { updateField(); }
Read vs Write Patterns:
java// If reads >> writes, use ReadWriteLock ReadWriteLock rwLock = new ReentrantReadWriteLock(); // Multiple readers can proceed concurrently rwLock.readLock().lock(); try { return data; } finally { rwLock.readLock().unlock(); } // Writers get exclusive access rwLock.writeLock().lock(); try { data = newValue; } finally { rwLock.writeLock().unlock(); }
Lock-Free When Possible:
java// For simple counters, atomic operations are faster AtomicInteger counter = new AtomicInteger(0); counter.incrementAndGet(); // Lock-free, wait-free // Compare-and-swap for lock-free updates AtomicReference<State> state = new AtomicReference<>(initialState); state.compareAndSet(expectedState, newState);
5. Production Debugging Skills
What they're testing:
- How would you debug concurrency issues in production?
- What tools and techniques do you know?
Strong candidates mention:
Thread Dumps:
bashjstack <pid> > thread_dump.txt # Look for BLOCKED threads and lock holders
JMX Monitoring:
javaThreadMXBean threadBean = ManagementFactory.getThreadMXBean(); long[] deadlockedThreads = threadBean.findDeadlockedThreads(); if (deadlockedThreads != null) { // Alert operations team }
Metrics to Track:
- Thread pool queue depth
- Lock wait time (percentiles: p50, p95, p99)
- Context switch rate
- CPU utilization per thread
Logging Best Practices:
java// Include thread information in production logs logger.info("Processing order {} on thread {}", orderId, Thread.currentThread().getName());
6. Error Handling and Edge Cases
What they're testing:
- Do you handle interruption correctly?
- What about resource cleanup?
- Can you handle failures gracefully?
Correct InterruptedException handling:
java// WRONG - swallows interruption try { queue.take(); } catch (InterruptedException e) { // Do nothing } // CORRECT - preserve interrupt status try { queue.take(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // Restore flag throw new RuntimeException("Thread interrupted", e); }
Resource Cleanup:
java// Always use try-finally for locks Lock lock = new ReentrantLock(); lock.lock(); try { // Critical section } finally { lock.unlock(); // Guaranteed cleanup }
Timeout Handling:
java// Production systems need timeouts to prevent indefinite blocking if (!lock.tryLock(5, TimeUnit.SECONDS)) { // Log warning, trigger alert, use fallback strategy logger.warn("Failed to acquire lock within timeout"); return fallbackValue(); }
7. Testing Concurrent Code
What they're testing:
- How do you verify correctness?
- What about race conditions that happen rarely?
Strong answers mention:
Stress Testing:
java@Test public void testConcurrentAccess() { ExecutorService executor = Executors.newFixedThreadPool(20); CountDownLatch latch = new CountDownLatch(1000); for (int i = 0; i < 1000; i++) { executor.submit(() -> { sharedResource.operation(); latch.countDown(); }); } assertTrue(latch.await(30, TimeUnit.SECONDS)); assertEquals(expectedState, sharedResource.getState()); }
Thread Safety Annotations:
java@ThreadSafe public class SafeCounter { @GuardedBy("this") private int count = 0; public synchronized void increment() { count++; } }
Tools to Mention:
- Thread Sanitizer (TSan)
- Java Concurrency Stress Tests (jcstress)
- FindBugs/SpotBugs for concurrency bug detection
Common Mistakes to Avoid
- Using if instead of while for condition checks
- Forgetting to call notifyAll() after state changes
- Using notify() when multiple threads wait on different conditions
- Not handling InterruptedException properly
- Assuming operations are atomic when they're not
- Overusing synchronization (can hurt performance)
Performance Optimization
java// INEFFICIENT - entire method synchronized public synchronized void processItem(Item item) { doExpensiveComputation(item); sharedList.add(item); // Only this needs synchronization } // EFFICIENT - minimize critical section public void processItem(Item item) { doExpensiveComputation(item); synchronized (sharedList) { sharedList.add(item); } }
Modern Java Alternatives
While these classic problems use traditional synchronization, modern Java offers higher-level abstractions:
BlockingQueue for Producer-Consumer
javaBlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5); // Producer queue.put(item); // Blocks if full // Consumer int item = queue.take(); // Blocks if empty
Phaser for Complex Coordination
javaPhaser phaser = new Phaser(4); // 4 parties // Each thread phaser.arriveAndAwaitAdvance(); // Synchronization point
CompletableFuture for Async Operations
javaCompletableFuture.supplyAsync(() -> computeValue()) .thenApply(value -> processValue(value)) .thenAccept(result -> publishResult(result));
Further Reading
Books:
- Java Concurrency in Practice by Brian Goetz - The definitive guide
Resources:
Practice:
- Implement variations with different thread counts
- Add metrics (throughput, latency)
- Experiment with Java 21's virtual threads
Conclusion
Mastering these multithreading problems gives you:
- Deep understanding of synchronization primitives
- Ability to design thread-safe systems
- Skills to debug concurrency issues
- Confidence in technical interviews
The key is understanding the why behind each solution, not just memorizing code. Practice implementing variations, introducing bugs intentionally, and fixing them to build intuition.
Related Posts
Continue exploring similar topics
Spring Boot 3.x: What Actually Changed (and What Matters)
A practical look at Spring Boot 3.x features that change how you build services - virtual threads, reactive patterns, security gotchas, and performance lessons from production.
Merkle Trees: Implementation in Java and Real-World Applications
A comprehensive guide to Merkle Trees with Java implementation, practical applications in blockchain, distributed systems, and data integrity verification.
Implementing Trie (Prefix Tree): A Complete Guide with Java
Learn how to implement a Trie data structure in Java with insert, search, delete, and prefix matching operations for efficient string processing and autocomplete functionality.