Java Multithreading Deadlock — Payment Batch Hang
Payment batch hangs after 500 transfers due to circular lock acquisition.
20+ years shipping production code across the stack, with years spent interviewing engineers. Notes here come from systems that actually shipped.
- Multithreading lets multiple tasks run concurrently on separate CPU cores
- Core concept: shared mutable state must be synchronized to prevent race conditions
- volatile guarantees visibility, not atomicity; Atomic* classes use CAS for lock-free atomics
- synchronized uses monitor locks with bias→lightweight→heavyweight escalation
- Performance insight: lock contention adds ~1-10µs per acquisition under low contention, spikes to ms under high contention
- Production insight: thread dumps reveal deadlocks, but silent liveness failures (starvation) are harder to catch
- Biggest mistake: assuming volatile makes read-modify-write operations thread-safe
Imagine a busy restaurant kitchen. One chef doing everything — chopping, frying, plating — is single-threaded. Multithreading is hiring multiple chefs who work at the same time. But now you need rules: who uses the single oven? What if two chefs grab the same knife? Java multithreading is the system of rules, tools, and signals that lets multiple 'chefs' (threads) work together without burning the kitchen down.
Multithreading questions separate senior Java developers from juniors faster than almost anything else in an interview. It's not enough to know that synchronized exists — interviewers at companies like Amazon, Google, and Goldman Sachs want to know what happens inside the JVM when two threads collide on a shared object, why volatile doesn't make compound operations atomic, and how the Java Memory Model actually defines 'visibility'. These are the questions that decide offers.
The real problem multithreading solves is utilising multi-core hardware. Modern servers have 32, 64, even 128 cores sitting idle if your application is single-threaded. But concurrency introduces an entirely new class of bugs — race conditions, deadlocks, liveness failures, and memory visibility errors — that are notoriously hard to reproduce and even harder to debug in production. A solid mental model is your only real defence.
By the end of this article you'll be able to answer the top Java multithreading interview questions with the depth and precision that impresses senior engineers. You'll understand the Java Memory Model, the monitor mechanism behind synchronized, the happens-before guarantee, the difference between Callable and Runnable at the implementation level, and the patterns that prevent deadlock. You'll walk into that interview room ready to discuss internals, not just syntax.
What Java Multithreading Deadlock Actually Is
A deadlock is a concurrency failure where two or more threads are blocked forever, each waiting on a resource held by another. The core mechanic is a circular dependency: thread A holds lock L1 and waits for L2, while thread B holds L2 and waits for L1. Neither can proceed. This is not a race condition — it's a deterministic stall. In Java, deadlock typically involves synchronized blocks, ReentrantLocks, or database transaction locks. The JVM can detect deadlocks via ThreadMXBean, but it cannot resolve them. Deadlock requires four conditions: mutual exclusion, hold-and-wait, no preemption, and circular wait. Remove any one, and deadlock is impossible. In practice, you control the last two: enforce a global lock ordering (always acquire locks in the same sequence) or use tryLock with timeouts. Deadlock is silent — no exception, no crash, just a hung thread pool and a pager at 3 AM.
transfer() locked account A then B, while batch reconciliation locked B then A — 12 threads hung, no transactions processed for 8 minutes.The Java Memory Model (JMM) & The Visibility Problem
In a multi-core environment, threads don't just talk to main memory; they have local CPU caches. This creates a visibility problem: Thread A might update a variable in its cache, but Thread B on another core still sees the old value in main memory. The JMM defines the 'Happens-Before' relationship, ensuring that memory writes by one specific statement are visible to another specific statement. Without proper synchronization or the volatile keyword, the JVM is actually allowed to reorder your code for optimization, which can lead to disastrous results in concurrent execution.
Here's the thing: most engineers think volatile makes all reads see the latest write. That's true for simple reads, but for compound operations like count++, you still get a race. Volatile only guarantees visibility, not atomicity. If you're doing read-modify-write, you need AtomicInteger or synchronized.
The JMM also defines the happens-before rule for thread start and join: calling Thread.start() happens-before any action in the started thread. Similarly, all actions in a thread happen-before another thread successfully returns from Thread.join(). These rules let you safely share initialization data without explicit synchronization.
Locks, Monitors, and Synchronized Internals
Every object in Java is associated with a 'Monitor'. When a thread enters a synchronized block, it must acquire the lock on that monitor. If the lock is held, the thread enters a BLOCKED state. Under the hood, the JVM optimizes this using 'Biased Locking' (now mostly deprecated in newer JDKs), 'Lightweight Locking', and finally 'Heavyweight Locking' involving OS-level mutexes. Understanding this escalation helps you write code that avoids unnecessary lock contention.
But here's the reality: synchronized is not as expensive as many junior devs fear. For uncontended locks, the JIT can eliminate the lock entirely (lock elision). Contended locks are the problem — they cause thread context switches that kill throughput. That's why ReentrantLock with tryLock can be a better choice under high contention: it avoids the OS mutex path if the lock is quickly available.
Wait/notify must always be called within a synchronized context because they are based on the monitor. When you call wait(), the thread releases the monitor and goes to WAITING state. When notify() is called, it wakes one thread, which must re-acquire the monitor before proceeding. This mechanism is fundamental to producer-consumer patterns.
Locking Strategy Matrix: Synchronized vs ReentrantLock
Choosing the right locking mechanism is a common interview discussion and a critical production decision. The following matrix summarizes the key trade-offs between synchronized and ReentrantLock. Use this as a quick reference when designing concurrent code.
| Feature | synchronized | ReentrantLock |
|---|---|---|
| API Style | Keyword, implicit | Class, explicit |
| Unlock | Automatic on block exit | Manual (must call unlock() in finally) |
| Fairness | Unfair only | Can be fair or unfair |
| Interruptibility | Not interruptible while waiting | Interruptible via lockInterruptibly() |
| Timeout | No timeout | tryLock(time, unit) |
| Condition support | Single condition via wait/notify | Multiple Condition objects |
| Lock ownership | Locked by the same thread that acquired it | Same thread can re-enter (reentrant) |
| Performance under low contention | Excellent (JIT optimises) | Slightly slower overhead |
| Performance under high contention | Can degrade due to context switches | Better with tryLock backoff |
| Debugging | Thread dumps show monitor owner | Shows owner and wait queue |
| Lock striping / ReadWrite | Not directly possible | Supports ReadWriteLock |
In production, start with synchronized for simplicity. If you need timeouts, interruptibility, or fairness, switch to ReentrantLock. If read-dominant workloads, consider ReentrantReadWriteLock. Always document the lock order to avoid deadlocks.
Thread Lifecycle and States: From NEW to TERMINATED
A Java thread goes through six well-defined states: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED. Understanding these states is critical for debugging production issues. When you take a thread dump, every thread's state tells a story.
NEW means the thread object exists but start() hasn't been called yet. RUNNABLE means it's executing or ready to execute (the JVM doesn't distinguish between running and runnable). BLOCKED means it's waiting for a monitor lock. WAITING means it entered via Object.wait(), Thread.join(), or LockSupport.park(). TIMED_WAITING is similar but with a timeout.
The mistake many make is thinking that a thread in RUNNABLE is actively using CPU. It might be stuck in a tight loop waiting for a flag that never changes — that's a liveness failure disguised as RUNNABLE. Always look at the stack trace, not just the state.
- NEW: train at depot, not yet on tracks
- RUNNABLE: moving or waiting for a slot on the CPU track
- BLOCKED: waiting at a gate for another train to leave the station (synchronized lock)
- WAITING: train parked in a siding waiting for a signal (wait/join/park)
- TIMED_WAITING: parked with a timer — will automatically resume
- TERMINATED: arrived at final destination, removed from service
Visual Thread State Machine
Understanding thread state transitions is easier with a diagram. The following Mermaid state diagram shows the valid transitions between Java thread states. Each arrow is triggered by a specific action (e.g., start(), sleep(), acquire lock). Use this as a mental map when debugging thread dumps: trace the state back to the operation that caused it.
- NEW → RUNNABLE:
t.start() - RUNNABLE → BLOCKED: failed to acquire monitor lock
- RUNNABLE → WAITING:
Object.wait(),Thread.join(),LockSupport.park() - RUNNABLE → TIMED_WAITING:
Thread.sleep(), Object.wait(timeout), Thread.join(timeout) - WAITING → RUNNABLE:
notify()/notifyAll(), target thread completes (join),unpark() - TIMED_WAITING → RUNNABLE: timeout expires or notification
- BLOCKED → RUNNABLE: monitor lock becomes available
- RUNNABLE → TERMINATED:
run()method exits
Deadlock Prevention and Detection: The Patterns That Save Your App
Deadlock is the most feared concurrency bug because it brings the system to a complete halt with no error message. It happens when two or more threads hold locks and wait indefinitely for locks held by each other. The classic example: Thread1 locks A, then tries B; Thread2 locks B, then tries A.
The Java approach to deadlock detection is via thread dumps — jstack or jcmd will automatically detect cycles and report 'Found one Java-level deadlock'. But detection is reactive. Prevention requires consistent lock ordering or using higher-level abstractions that avoid nested locks.
The real fix that senior engineers use is to minimize the number of locks held simultaneously. If you must hold multiple locks, always acquire them in the same order across all code paths. Also consider using tryLock with backoff — if you can't acquire all locks within a timeout, release everything and retry. This makes deadlock impossible because locks are never held forever waiting for another lock.
ThreadMXBean.findDeadlockedThreads() and alert if it returns non-empty.ReentrantLock.tryLock() with timeout and rollbackDeadlock Prevention: Technical Reference Guide
This reference guide consolidates the essential rules, patterns, and tools for preventing deadlocks in production systems. Use it as a checklist during code reviews and architecture design.
1. Consistent Lock Ordering Define a global ordering for all locks and strictly follow it. Example: lock accounts by account ID (always lock lower ID first). Enforce this with automated linting or architecture tests.
2. tryLock with Timeout Replace indefinite synchronized blocks with ReentrantLock.tryLock(timeout). If you cannot acquire all locks within the timeout, release any locks already held and retry (with backoff). This guarantees that deadlock is impossible.
3. Lock Hierarchy Organize locks into a hierarchy (e.g., Layer1, Layer2) and always lock from highest to lowest. Code that violates the hierarchy should fail fast in tests.
4. Minimize Lock Scope Hold locks only for the minimal critical section. Never perform I/O, sleep, or call unknown code while holding a lock. This reduces the chance of lock contention and deadlock.
5. Avoid Nested Locks Whenever possible, use a single lock or higher-level abstractions (e.g., ConcurrentHashMap, atomic operations) that eliminate the need for multiple locks.
6. Deadlock Detection Tools jstack, jcmd, and VisualVM can detect deadlocks at runtime. Integrate ThreadMXBean.findDeadlockedThreads() into your health check endpoint to alert operations teams.
7. Code Review Checklist - Are all locks acquired in the same order? - Is there any path where a thread holds one lock and waits for another? - Are tryLock calls paired with rollback logic? - Is the lock scope minimised?
Concurrency Utilities: From CountDownLatch to CompletableFuture
Raw threads and synchronized are the assembly language of concurrency. The java.util.concurrent package provides higher-level building blocks that handle common patterns safely and efficiently.
CountDownLatch lets one or more threads wait until a set of operations completes. CyclicBarrier lets a set of threads wait for each other to reach a common barrier point. Semaphore controls access to a pool of resources. Exchanger lets two threads exchange objects at a synchronization point.
But the workhorse in modern Java is CompletableFuture. It chains asynchronous tasks with thenApply, thenCompose, and exceptionally. It provides a declarative way to build async pipelines without nesting callbacks. When used with a ForkJoinPool, it can automatically parallelize independent stages.
The key production insight: CompletableFuture uses a default ForkJoinPool that is sized to the number of CPU cores. For I/O-bound tasks, this can starve CPU-bound work. Always supply a custom executor for I/O-heavy operations.
CompletableFuture.supplyAsync() uses ForkJoinPool.commonPool() by default. That pool is sized to number of CPU cores. For I/O operations, use a custom thread pool with more threads to avoid starving CPU-bound tasks.CompletableFuture.get() — it blocks the calling thread indefinitely. Always use a timeout: get(2, TimeUnit.SECONDS).CompletableFuture.allOf()Concurrent Collection Performance Comparison
Choosing the right concurrent collection is critical for performance. The table below compares the most common java.util.concurrent collections across key dimensions: thread-safety, concurrency level, iteration semantics, and typical use cases.
| Collection | Thread Safety | Concurrency Level | Iteration | Best For |
|---|---|---|---|---|
| ConcurrentHashMap | Full | High (segmented locks or CAS) | Weakly consistent | Shared key-value maps with high read/write concurrency |
| CopyOnWriteArrayList | Full (snapshot) | Low (copy on write) | Snapshot, no ConcurrentModificationException | Read-dominant scenarios with few writes (e.g., listener lists) |
| ConcurrentLinkedQueue | Non-blocking, lock-free | Very high | Weakly consistent | High-throughput producer-consumer queues |
| LinkedBlockingQueue | Blocking (locks) | Moderate | Weakly consistent | Bounded producer-consumer with backpressure |
| ConcurrentSkipListMap | Full (lock-free) | High | Weakly consistent, sorted | Concurrent sorted maps (replaces TreeMap) |
| ConcurrentSkipListSet | Full (lock-free) | High | Weakly consistent, sorted | Concurrent sorted sets |
| ArrayBlockingQueue | Blocking (single lock) | Low (contended) | Weakly consistent | Bounded queue with one producer/consumer |
| DelayQueue | Blocking | Moderate | No direct iteration | Delayed task scheduling |
- For most use cases, ConcurrentHashMap is the go-to. It scales well with multiple threads due to internal striping (Java 8+ uses CAS and bins).
- CopyOnWriteArrayList is memory-inefficient on writes but offers snapshot iterations that never throw ConcurrentModificationException.
- For queues, ConcurrentLinkedQueue gives best throughput if boundedness isn't required; LinkedBlockingQueue is better for bounded scenarios with blocking producers.
- Sorted maps: ConcurrentSkipListMap is the only thread-safe sorted map; TreeMap is not thread-safe.
What Is Multithreading and Why Multitasking Is a Lie
You've been asked to explain the difference between multitasking and multithreading in an interview. Here's the truth that matters after your third production outage. Multitasking is the OS-level illusion of running multiple processes simultaneously. The CPU juggles them so fast you think they're parallel. Multithreading is different — it's multiple threads of execution within a single process, sharing heap memory, stack frames be damned. Your JVM starts with one main thread. You spawn more to keep the UI responsive while a database query blocks, or to process a stream of orders without serializing latency. The critical distinction: threads share memory. Processes don't. That's why a deadlock in one thread crashes your entire app, not just the OS scheduler. When a junior asks 'why not just use processes?', you answer: context switching overhead and shared state. When they ask 'why not single-threaded?', show them the latency graph where one blocking call stalls 10,000 requests. That's the 'why'.
Daemon vs User Threads: Which Gets Killed When JVM Shuts Down
Not all threads survive the JVM exit. User threads keep the process alive. Daemon threads are background workers that the JVM kills without mercy when no user threads remain. This is not optional — it's a feature your GC threads use. If you launch a background metrics uploader as a user thread, your app never terminates cleanly. The JVM will wait for it forever, leaking memory until someone kills -9 the PID. Conversely, if you mark it daemon, the JVM yanks it mid-execution when the main thread finishes. That means incomplete writes, lost data, corrupted files. The production pattern: daemon threads for tasks where losing work is acceptable (cache warmers, periodic stats). User threads for cleanup, flush, or commit operations. Set the daemon flag before calling start(). After start() it's a no-op that silently does nothing. Found that one the hard way during a rollout that took down an entire microservice because disk buffers never flushed.
start() throws IllegalThreadStateException. Always set thread properties before starting them.Thread Priority: The OS Ignores You (Mostly)
You can call thread.setPriority(Thread.MAX_PRIORITY) and expect your thread to jump the queue. Reality: the JVM maps Java thread priorities to OS thread priorities in a lossy, platform-dependent way. Linux ignores them entirely for default scheduling policies. Windows honors a rough mapping but the kernel scheduler still makes its own decisions. Priority inversion is the real killer — a low-priority thread holds a lock a high-priority thread needs. The high-priority thread spins waiting, making the system appear frozen. Fix with ReentrantLock's fair mode or redesign to reduce shared lock contention. My rule: never rely on thread priorities for correctness. They're hints, not guarantees. Use them for soft optimization, like bumping a background log flusher so it doesn't starve the main request handler. But if your design breaks because a thread got the wrong priority, your design is wrong. Fix the locks, not the priority.
Deadlock in Payment Batch Processor Took Down Production
- Always acquire locks in a global consistent order to avoid deadlocks.
- Use tryLock with a timeout as a safety net — never rely on indefinite blocking.
- Monitor thread dumps periodically in production to detect blocking threads early.
Thread.currentThread().interrupt() to restore the interrupt flag. Failing to do so kills thread shutdown signals.jstack <pid> | grep -A 30 'BLOCKED'jcmd <pid> Thread.print -lKey takeaways
Common mistakes to avoid
6 patternsUsing volatile for counters (e.g., volatile int count++; is NOT thread-safe)
Not releasing locks in a 'finally' block, leading to permanent resource starvation
unlock() is called.Calling Thread.stop(), Thread.suspend(), or Thread.resume() — these are deprecated and dangerous
Thread.stop() can leave objects in an inconsistent state because it releases all locks abruptly.Ignoring the InterruptedException, which breaks the thread's ability to shut down gracefully
Thread.currentThread().interrupt() in the catch block to restore the interrupt flag, then either propagate or abort.Assuming synchronized on a method makes the whole class thread-safe
Using synchronized on a String literal or a boxed primitive
Object();Interview Questions on This Topic
How does the 'Happens-Before' principle apply to a thread starting versus a thread joining, and how does it guarantee memory visibility?
Thread.start(), all memory writes made by the calling thread before the start() call are guaranteed to be visible to the started thread. Similarly, when a thread calls Thread.join() and successfully returns, all memory writes made by the joined thread are guaranteed to be visible to the calling thread. This means you can safely share initialization data between threads without explicit synchronization if you ensure the writes happen before start() is called. However, this only applies to the initial data — subsequent shared state still needs synchronization.Frequently Asked Questions
20+ years shipping production code across the stack, with years spent interviewing engineers. Notes here come from systems that actually shipped.
That's Java Interview. Mark it forged?
12 min read · try the examples if you haven't