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.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- 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
A deadlock in Java multithreading is a concurrency failure where two or more threads are blocked forever, each waiting on a resource held by another. In the context of a payment batch system, this typically manifests as a complete processing hang — no transactions complete, no errors thrown, just silent starvation.
The root cause is almost always a circular dependency in lock acquisition: Thread A holds lock L1 and waits for L2, while Thread B holds L2 and waits for L1. The JVM cannot resolve this automatically; it's a permanent state until external intervention (kill -3 for thread dump, or process restart).
Real payment systems at companies like Stripe or Adyen enforce strict lock ordering (e.g., always lock account-level mutexes in ascending ID order) precisely to prevent this pattern at scale.
Deadlock is distinct from livelock (threads keep retrying but make no progress) or starvation (a thread never gets CPU time despite being runnable). In payment batch processing, deadlock often hides behind innocent-looking synchronized blocks or nested ReentrantLock acquisitions.
The Java Memory Model (JMM) doesn't directly cause deadlocks, but it makes them harder to debug because visibility guarantees (happens-before) can mask the ordering of lock releases. Tools like jstack, VisualVM, and thread dump analyzers are your first line of defense — look for threads in BLOCKED state with a stack trace showing lock ownership chains.
The fix is never 'just add more synchronized' — that amplifies the problem. Instead, use tryLock with timeouts, reduce lock granularity, or switch to non-blocking algorithms (ConcurrentHashMap, AtomicReference) where payment state updates don't need mutual exclusion.
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.
package io.thecodeforge.concurrency; public class VisibilityDemo { // Without volatile, the 'running' thread might never see the update from main private static volatile boolean running = true; public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { while (running) { // The CPU might optimize this into an infinite loop without volatile } System.out.println("Worker thread stopped safely."); }); worker.start(); Thread.sleep(1000); System.out.println("Requesting stop..."); running = false; worker.join(); } }
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.
package io.thecodeforge.concurrency; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.TimeUnit; public class DeadlockAvoidance { private final Lock lockA = new ReentrantLock(); private final Lock lockB = new ReentrantLock(); public void safeTransfer() { try { // TryLock prevents the 'deadly embrace' where two threads wait forever if (lockA.tryLock(50, TimeUnit.MILLISECONDS)) {\n try {\n if (lockB.tryLock(50, TimeUnit.MILLISECONDS)) {\n try {\n System.out.println(\"Securely accessed both resources.\");\n } finally {\n lockB.unlock();\n }\n }\n } finally {\n lockA.unlock();\n }\n }\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n }\n }\n}", "output": "Securely accessed both resources." }
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.
// Using synchronized (simple) public synchronized void increment() { counter++; } // Using ReentrantLock with timeout private final ReentrantLock lock = new ReentrantLock(true); // fair public boolean tryTransfer() { if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {\n try {\n // critical section\n return true;\n } finally { lock.unlock(); } } return false; }
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.
package io.thecodeforge.concurrency; public class ThreadLifecycle { public static void main(String[] args) throws InterruptedException { Thread t = new Thread(() -> { try { Thread.sleep(5000); // TIMED_WAITING } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); System.out.println("After creation: " + t.getState()); // NEW t.start(); System.out.println("After start: " + t.getState()); // RUNNABLE Thread.sleep(100); System.out.println("During sleep: " + t.getState()); // TIMED_WAITING t.join(); System.out.println("After join: " + t.getState()); // TERMINATED } }
- 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.
package io.thecodeforge.concurrency; public class DeadlockDetector { private static final Object lock1 = new Object(); private static final Object lock2 = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (lock1) { System.out.println("Thread1: acquired lock1"); try { Thread.sleep(50); } catch (InterruptedException e) {} synchronized (lock2) { System.out.println("Thread1: acquired lock2"); } } }); Thread t2 = new Thread(() -> { synchronized (lock2) { System.out.println("Thread2: acquired lock2"); try { Thread.sleep(50); } catch (InterruptedException e) {} synchronized (lock1) { System.out.println("Thread2: acquired lock1"); } } }); t1.start(); t2.start(); } }
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?
// Example: Lock ordering by account ID public void transfer(Account from, Account to, int amount) {\n // Always lock lower ID first to prevent deadlock\n Object lock1 = from.getId() < to.getId() ? from : to;\n Object lock2 = from.getId() < to.getId() ? to : from;\n synchronized(lock1) {\n synchronized(lock2) {\n from.debit(amount);\n to.credit(amount);\n } } }
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.
package io.thecodeforge.concurrency; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class AsyncPipeline { private static final ExecutorService ioExecutor = Executors.newFixedThreadPool(20); public static void main(String[] args) { CompletableFuture.supplyAsync(() -> fetchUser(1), ioExecutor) .thenApplyAsync(user -> enrichProfile(user), ioExecutor) .thenAccept(profile -> sendNotification(profile)) .exceptionally(e -> { System.err.println("Failed: " + e.getMessage()); return null; }); } private static String fetchUser(int id) { // Simulate IO call return "User_" + id; } private static String enrichProfile(String user) { return user + "_enriched"; } private static void sendNotification(String profile) { System.out.println("Sent notification for " + profile); } }
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'.
// io.thecodeforge.multithreading // Demonstrates process vs thread memory isolation public class MultitaskingLie { public static void main(String[] args) { // Shared mutable state — threads see each other int[] sharedCounter = {0}; Runnable incrementTask = () -> { for (int i = 0; i < 1000; i++) { sharedCounter[0]++; // no synchronization → data race } }; Thread t1 = new Thread(incrementTask); Thread t2 = new Thread(incrementTask); t1.start(); t2.start(); try { t1.join(); t2.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println("Expected: 2000, Actual: " + sharedCounter[0]); // Output: Expected: 2000, Actual: 1342 (non-deterministic) } }
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.
// io.thecodeforge.multithreading // Daemon threads terminate when all user threads finish public class DaemonTrap { public static void main(String[] args) { Thread worker = new Thread(() -> { try { Thread.sleep(5000); // Simulate slow flush System.out.println("Data flushed to disk"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); worker.setDaemon(true); // Set BEFORE start worker.start(); System.out.println("Main thread ending"); // JVM exits before worker prints: no flush occurs } }
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.
// io.thecodeforge.multithreading // Priority inversion demonstrated import java.util.concurrent.locks.ReentrantLock; public class PriorityMyth { private static final ReentrantLock lock = new ReentrantLock(); public static void main(String[] args) throws InterruptedException { Thread high = new Thread(() -> performCriticalTask(), "HIGH"); Thread low = new Thread(() -> performCriticalTask(), "LOW"); high.setPriority(Thread.MAX_PRIORITY); low.setPriority(Thread.MIN_PRIORITY); // Start low first to grab the lock low.start(); Thread.sleep(100); // Let low acquire lock high.start(); // Now high priority blocks on low — inversion // Output: LOW acquires lock, HIGH waits } private static void performCriticalTask() { lock.lock(); try { System.out.println(Thread.currentThread().getName() + " acquired lock"); Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } } }
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 -ljavap -c -p <classname> | grep 'getstatic\|putstatic'jstack <pid> | grep 'RUNNABLE'java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=print,*YourClass.methodNameUse jstack to confirm multiple threads are executing the same block.jcmd <pid> Thread.print -lkill -3 <pid> > threaddump.txt| Feature | synchronized | ReentrantLock |
|---|---|---|
| Mechanism | Implicit (Keyword) | Explicit (API Class) |
| Fairness | Always Unfair | Optional Fairness |
| Flexibility | Block-structured only | Can lock in one method, unlock in another |
| Interruptibility | No (Thread stays blocked) | Yes (via lockInterruptibly) |
| Performance | Extremely optimized by JIT | Better under high contention |
| File | Command / Code | Purpose |
|---|---|---|
| io.thecodeforge.concurrency.VisibilityDemo.java | public class VisibilityDemo { | The Java Memory Model (JMM) & The Visibility Problem |
| io.thecodeforge.concurrency.DeadlockAvoidance.java | public class DeadlockAvoidance { | Locks, Monitors, and Synchronized Internals |
| io.thecodeforge.concurrency.LockStrategyExample.java | public synchronized void increment() { counter++; } | Locking Strategy Matrix |
| io.thecodeforge.concurrency.ThreadLifecycle.java | public class ThreadLifecycle { | Thread Lifecycle and States |
| io.thecodeforge.concurrency.DeadlockDetector.java | public class DeadlockDetector { | Deadlock Prevention and Detection |
| io.thecodeforge.concurrency.DeadlockPreventionChecklist.java | public void transfer(Account from, Account to, int amount) {\n // Always lock... | Deadlock Prevention |
| io.thecodeforge.concurrency.AsyncPipeline.java | public class AsyncPipeline { | Concurrency Utilities |
| MultitaskingLie.java | public class MultitaskingLie { | What Is Multithreading and Why Multitasking Is a Lie |
| DaemonTrap.java | public class DaemonTrap { | Daemon vs User Threads |
| PriorityMyth.java | public class PriorityMyth { | Thread Priority |
Key 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.Explain the 'Double-Checked Locking' pattern for Singletons. Why was it broken in Java 1.4 and how did the volatile keyword fix it in Java 5?
Compare 'Optimistic Locking' using CAS (Compare-And-Swap) in Atomic classes versus 'Pessimistic Locking' in synchronized blocks. In which scenario would CAS perform worse?
What is the difference between 'yielding', 'sleeping', and 'waiting' in terms of CPU usage and monitor ownership?
Thread.yield() is a hint to the scheduler that the current thread is willing to pause its execution. It might not do anything, and the thread remains RUNNABLE. Thread.sleep() causes the thread to enter TIMED_WAITING for a specified duration. It does not release any monitors. Object.wait() causes the thread to release the monitor and enter WAITING until another thread calls notify()/notifyAll() on the same object. This is the only one that releases locks. Sleep and yield keep locks held — a common mistake is to call sleep inside a synchronized block thinking it will let other threads proceed, but it doesn't.How does a ForkJoinPool differ from a standard ThreadPoolExecutor? When would you use each?
Frequently Asked Questions
Because wait() and notify() are based on the monitor of the object. If a thread calls object.wait() without owning the monitor, it throws an IllegalMonitorStateException. Furthermore, the wait() method is designed to atomically release the lock and put the thread to sleep, which is only possible if the thread holds the lock to begin with.
A 'Data Race' occurs when two threads access the same memory location concurrently and at least one is a write, without a happens-before relationship. A 'Race Condition' is a higher-level flaw where the correctness of a program depends on the relative timing of threads (e.g., Check-Then-Act). You can have a race condition even without a data race if your locking is too granular.
Both represent tasks intended for concurrent execution. However, Runnable.run() returns void and cannot throw checked exceptions. Callable.call() returns a Generic type <V> and can throw checked exceptions, making it the preferred choice for tasks that produce a result (retrieved via a Future).
Volatile establishes happens-before relationships, which prevent certain reorderings. Specifically, reads and writes to volatile variables cannot be reordered with each other or with surrounding memory operations. But volatile does not prevent all reorderings — only those that would break the visibility guarantees. The JVM inserts memory barriers (e.g., LoadLoad, StoreStore) to enforce the ordering constraints.
Thread.interrupt() sets the interrupt flag of the thread. Code that checks the flag via isInterrupted() or by catching InterruptedException can respond immediately. A volatile flag requires the thread to explicitly check it; if the thread is blocked in an I/O operation or in wait/sleep/join, it won't see the flag until it wakes up. Interrupt can wake up a sleeping thread, while a volatile flag cannot. So for responsive cancellation, interrupt is preferred.
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?
9 min read · try the examples if you haven't