Senior 12 min · March 06, 2026

Java Multithreading Deadlock — Payment Batch Hang

Payment batch hangs after 500 transfers due to circular lock acquisition.

N
Naren Founder & Principal Engineer

20+ years shipping production code across the stack, with years spent interviewing engineers. Notes here come from systems that actually shipped.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is Java Multithreading Interview Q&A?

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.

Imagine a busy restaurant kitchen.

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.

Plain-English First

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.

Deadlock vs. Livelock
Deadlock is a permanent block; livelock is threads actively retrying but never making progress — both waste CPU, but only deadlock freezes the system.
Production Insight
A payment batch system deadlocked because transfer() locked account A then B, while batch reconciliation locked B then A — 12 threads hung, no transactions processed for 8 minutes.
Symptom: thread dumps show BLOCKED threads with identical stack traces waiting on distinct monitors; CPU idle but throughput zero.
Rule: enforce a canonical lock order (e.g., lock accounts by ID hash) — never rely on documentation; enforce it in code with a lock-ordering check.
Key Takeaway
Deadlock requires four conditions; breaking circular wait via lock ordering is the only practical fix.
Use tryLock with a timeout instead of synchronized to fail fast instead of hanging forever.
Always dump threads on suspected hang — deadlock is invisible without thread dump analysis.
Java Multithreading Deadlock — Payment Batch Hang THECODEFORGE.IO Java Multithreading Deadlock — Payment Batch Hang Flow from thread lifecycle to deadlock detection Thread States (NEW → TERMINATED) Lifecycle: RUNNABLE, BLOCKED, WAITING, TIMED_WAITING Synchronized & ReentrantLock Intrinsic vs explicit locks; monitor internals Locking Strategy Matrix Synchronized vs ReentrantLock trade-offs Deadlock Condition (Hold & Wait) Circular wait with locks on resources Prevention & Detection Patterns Lock ordering, timeout, thread dump analysis ⚠ Missing lock ordering causes circular wait Always acquire locks in a consistent global order THECODEFORGE.IO
thecodeforge.io
Java Multithreading Deadlock — Payment Batch Hang
Java Multithreading Interview Questions

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.

io.thecodeforge.concurrency.VisibilityDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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();
    }
}
Output
Requesting stop...
Worker thread stopped safely.
Forge Tip: Volatile vs Atomic
Remember: volatile only guarantees visibility, NOT atomicity. If you are doing a compound operation like count++, volatile is not enough because the read-modify-write cycle can still be interrupted. For that, you need AtomicInteger or synchronized.
Production Insight
In production, visibility bugs often manifest as 'it works on my machine' — the developer's single-core laptop doesn't reproduce the issue.
Use jstack to check if threads are spinning in loops waiting on non-volatile flags.
Rule: if a flag is written by one thread and read by another, mark it volatile or use AtomicBoolean.
Key Takeaway
Visibility doesn't come free.
Volatile fixes one thread seeing stale data, but compound ops still need atomicity.
If you're sharing a flag between threads, mark it volatile — don't rely on accidental synchronization.
When to Use Volatile vs Synchronized vs Atomic
IfSingle variable, only reads/writes (no compound ops)
UseUse volatile — simplest and fastest
IfSingle variable with read-modify-write (e.g., counter)
UseUse AtomicInteger/Long — lock-free via CAS
IfMultiple variables that must be updated atomically together
UseUse synchronized block or ReentrantLock
IfYou need to block threads waiting for a condition
UseUse wait/notify or higher-level synchronizers like CountDownLatch

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.

io.thecodeforge.concurrency.DeadlockAvoidance.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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.

FeaturesynchronizedReentrantLock
API StyleKeyword, implicitClass, explicit
UnlockAutomatic on block exitManual (must call unlock() in finally)
FairnessUnfair onlyCan be fair or unfair
InterruptibilityNot interruptible while waitingInterruptible via lockInterruptibly()
TimeoutNo timeouttryLock(time, unit)
Condition supportSingle condition via wait/notifyMultiple Condition objects
Lock ownershipLocked by the same thread that acquired itSame thread can re-enter (reentrant)
Performance under low contentionExcellent (JIT optimises)Slightly slower overhead
Performance under high contentionCan degrade due to context switchesBetter with tryLock backoff
DebuggingThread dumps show monitor ownerShows owner and wait queue
Lock striping / ReadWriteNot directly possibleSupports 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.

io.thecodeforge.concurrency.LockStrategyExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
// 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;
}
Production Insight
In production, lock fairness can reduce throughput by up to 10x under high contention because it forces context switches even when threads could proceed immediately. Use fairness only when thread starvation is a real concern, e.g., in a long-lived lock held infrequently.
Key Takeaway
synchronized for simplicity and low contention; ReentrantLock for timeouts, interruptibility, and fairness. Know the matrix to justify your choice in interviews.

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.

io.thecodeforge.concurrency.ThreadLifecycle.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
    }
}
Output
After creation: NEW
After start: RUNNABLE
During sleep: TIMED_WAITING
After join: TERMINATED
Thread State Mental Model
  • 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
Production Insight
Threads in WAITING or TIMED_WAITING are not a problem per se — they're expected. But an excessive number of BLOCKED threads indicates lock contention.
In production, configure your thread pool size to avoid over-subscription. Too many threads cause more context switching than useful work.
Rule: if you see threads lingering in BLOCKED for seconds, you have a design problem — not a configuration problem.
Key Takeaway
Thread dump gives you state, but the stack trace gives you the story.
BLOCKED ≠ always bad, but sustained blocking always is.
RUNNABLE doesn't mean running — it could be a busy-wait disaster.
Interpreting Thread States in Dumps
IfMany threads in BLOCKED on the same monitor
UseLock contention — investigate the synchronized block for excessive duration
IfOne thread in BLOCKED, others waiting for it
UsePossible deadlock — look for circular lock dependencies
IfThread in RUNNABLE but no progress (infinite loop)
UseMissing volatile or broken wait condition — check the loop logic
IfAll threads idle (WAITING) but system not responding
UseStarvation or missing notify signals — check producer-consumer design

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.

The key transitions
  • 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
Production Insight
When reading a thread dump, map each thread's state back to this diagram. For example, a thread in TIMED_WAITING is often expected (pooled threads idle), but a thread in WAITING without a matching unpark or notify may indicate a missed signal — a common bug in custom park/unpark usage.
Key Takeaway
The state machine is your debugging compass. Knowing which action leads to which state lets you quickly infer what a thread is waiting for.
Java Thread State Transitions
start()failed to acquire lockwait(), join(), park()sleep(), wait(timeout),join(timeout)lock acquirednotify(), notifyAll(), unpark()timeout or notificationrun() completesNEWRUNNABLEBLOCKEDWAITINGTIMED_WAITINGTERMINATED

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.

io.thecodeforge.concurrency.DeadlockDetector.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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();
    }
}
Output
Thread1: acquired lock1
Thread2: acquired lock2
(and then hangs — deadlock detected by jstack)
Forge Warning: Deadlock vs Livelock
Deadlock: threads wait forever. Livelock: threads are active but make no progress (e.g., two polite threads repeatedly release and retry). Livelock can be harder to detect because threads are RUNNABLE, not BLOCKED. Use thread dumps to see repetitive stack patterns.
Production Insight
Deadlock rarely happens during testing because timing is hard to reproduce. It manifests under specific load patterns.
Use tryLock with a timeout and a fallback: if you can't get all locks, back off and retry.
As a defensive measure, enable ThreadMXBean.findDeadlockedThreads() and alert if it returns non-empty.
Rule: a lock ordering policy is the only reliable prevention — document it in team coding standards.
Key Takeaway
Deadlock is a design bug, not a runtime glitch.
Consistent lock ordering is the only 100% prevention.
tryLock with timeout makes deadlock impossible — it's your safety net.
Deadlock Prevention Strategies
IfAlways need multiple locks
UseEnforce global lock ordering (e.g., by resource ID)
IfCan tolerate retries
UseUse ReentrantLock.tryLock() with timeout and rollback
IfOnly one lock needed at a time
UseNo deadlock possible — keep it simple
IfLegacy code with unknown ordering
UseAdd monitoring: log lock acquisition order during development, detect violations

Deadlock 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?

io.thecodeforge.concurrency.DeadlockPreventionChecklist.javaJAVA
1
2
3
4
// 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        }
    }
}
Production Insight
Deadlock prevention is a design-time discipline. In production, the best defense is layered: consistent ordering + tryLock + monitoring. Set up alerts on DeadlockException or thread dump analysis tools like FastThread to catch issues before they escalate.
Key Takeaway
Prevent deadlocks with a combination of lock ordering, tryLock with timeout, and minimal lock scope. Use automated checks to enforce policy.
Deadlock Prevention Decision Flow
YesNoYesNoNeed multiple locks?Define global lock orderSingle lock - safeCan all locks be acquiredatomically?Use tryLock with timeoutRevisit design: can you reducelock count?Acquire all or none retry withbackoffConsider lock-free datastructures

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.

io.thecodeforge.concurrency.AsyncPipeline.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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);
    }
}
Output
Sent notification for User_1_enriched
Forge Tip: Custom Executor for I/O
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.
Production Insight
Using the default ForkJoinPool for I/O tasks is a common production mistake. It leads to thread starvation because I/O threads block waiting for responses while CPU cores sit idle.
Also, be careful with CompletableFuture.get() — it blocks the calling thread indefinitely. Always use a timeout: get(2, TimeUnit.SECONDS).
Rule: separate CPU-bound and I/O-bound thread pools. Use a larger pool for I/O and keep the ForkJoinPool for CPU-bound computations.
Key Takeaway
java.util.concurrent is your shield against most raw thread bugs.
CompletableFuture is powerful but requires explicit executor management.
CountDownLatch + CompletableFuture handle 80% of coordination patterns.
Choosing the Right Concurrency Utility
IfOne thread must wait for several tasks to complete
UseUse CountDownLatch or CompletableFuture.allOf()
IfMultiple threads must wait for each other at a point
UseUse CyclicBarrier
IfControl access to a fixed number of resources (e.g., database connections)
UseUse Semaphore
IfAsynchronous pipeline with chained operations
UseUse CompletableFuture with a custom executor for I/O

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.

CollectionThread SafetyConcurrency LevelIterationBest For
ConcurrentHashMapFullHigh (segmented locks or CAS)Weakly consistentShared key-value maps with high read/write concurrency
CopyOnWriteArrayListFull (snapshot)Low (copy on write)Snapshot, no ConcurrentModificationExceptionRead-dominant scenarios with few writes (e.g., listener lists)
ConcurrentLinkedQueueNon-blocking, lock-freeVery highWeakly consistentHigh-throughput producer-consumer queues
LinkedBlockingQueueBlocking (locks)ModerateWeakly consistentBounded producer-consumer with backpressure
ConcurrentSkipListMapFull (lock-free)HighWeakly consistent, sortedConcurrent sorted maps (replaces TreeMap)
ConcurrentSkipListSetFull (lock-free)HighWeakly consistent, sortedConcurrent sorted sets
ArrayBlockingQueueBlocking (single lock)Low (contended)Weakly consistentBounded queue with one producer/consumer
DelayQueueBlockingModerateNo direct iterationDelayed task scheduling
Key takeaways
  • 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.
Production Insight
In production, always benchmark with your actual workload. ConcurrentHashMap's performance degrades under very high write contention (e.g., many threads incrementing the same counter). Consider LongAdder for counters. For queues, unbounded ConcurrentLinkedQueue can cause memory pressure if producers outpace consumers — use a bounded blocking queue or monitor queue size.
Key Takeaway
ConcurrentHashMap handles 90% of concurrent map needs. For queues, choose based on boundedness and throughput requirements. Always test with realistic contention levels.

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'.

MultitaskingLie.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 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)
    }
}
Output
Expected: 2000, Actual: 1342
Production Trap:
If you use a plain int for a shared counter in a web server, you'll see counts drift under load. AtomicInteger exists for a reason.
Key Takeaway
Multitasking isolates processes; multithreading shares heap memory. Always synchronize shared mutable state or pay in corrupted data.

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.

DaemonTrap.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 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
    }
}
Output
Main thread ending
Production Trap:
Calling setDaemon(true) after start() throws IllegalThreadStateException. Always set thread properties before starting them.
Key Takeaway
Daemon threads die when the JVM exits. Use them only for work you can afford to lose. User threads are for guaranteed completion.

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.

PriorityMyth.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 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();
        }
    }
}
Output
LOW acquired lock
HIGH acquired lock (after 2 seconds)
Production Trap:
Priority inversion in a web server can make your high-priority health-check endpoint timeout while a low-priority batch job holds a database pool lock.
Key Takeaway
Thread priorities are OS suggestions. Don't rely on them for correctness. Fix priority inversion by reducing lock contention or using fair locks.
● Production incidentPOST-MORTEMseverity: high

Deadlock in Payment Batch Processor Took Down Production

Symptom
Payment batch job would occasionally hang after processing ~500 transfers. No error logs. Threads remained BLOCKED indefinitely. Manual intervention (kill -9) was the only recovery.
Assumption
Junior dev assumed using synchronized on individual transfer methods was sufficient because each method was thread-safe in isolation.
Root cause
Thread A acquired lock on Account X then tried to acquire lock on Account Y. Simultaneously, Thread B acquired lock on Account Y then tried to acquire lock on Account X. Both threads blocked forever because they held locks the other needed.
Fix
Implemented a consistent lock ordering based on account ID (always lock the smaller ID first). Also added a timeout using tryLock with a fallback rollback.
Key lesson
  • 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.
Production debug guideSymptom → Action guide for the most common concurrency bugs4 entries
Symptom · 01
Application hangs or becomes unresponsive
Fix
Take thread dump (jstack <pid> or kill -3 <pid>). Look for threads in BLOCKED state waiting on the same monitor. Also check for threads in RUNNABLE that are stuck in an infinite loop.
Symptom · 02
Data corruption or inconsistent state
Fix
Enable -XX:+PrintConcurrentLocks or use jcmd <pid> Thread.print -l. Check for missing synchronized blocks around shared mutable state. Often the problem is a field that is not volatile or not synchronized.
Symptom · 03
Unexpected InterruptedException
Fix
Verify all catch blocks for InterruptedException call Thread.currentThread().interrupt() to restore the interrupt flag. Failing to do so kills thread shutdown signals.
Symptom · 04
High CPU usage but no progress
Fix
Look for busy-wait loops (while(!ready){}). Replace with wait/notify or CountDownLatch. Use jstack to find threads consuming CPU.
★ Quick Multithreading Debugging Cheat SheetCommands and checks to diagnose the most frequent concurrency failures in under 5 minutes.
Threads stuck in BLOCKED state
Immediate action
Check thread dump for lock ownership chains.
Commands
jstack <pid> | grep -A 30 'BLOCKED'
jcmd <pid> Thread.print -l
Fix now
Identify circular waiting — restart the application with a consistent lock ordering fix.
NullPointerException in concurrent code+
Immediate action
Check if the variable is declared volatile or accessed within synchronized.
Commands
javap -c -p <classname> | grep 'getstatic\|putstatic'
jstack <pid> | grep 'RUNNABLE'
Fix now
Add volatile keyword or synchronize the access.
Race condition causing incorrect counts+
Immediate action
Inspect the increment operation — volatile count++ is not atomic.
Commands
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=print,*YourClass.methodName
Use jstack to confirm multiple threads are executing the same block.
Fix now
Replace with AtomicInteger or synchronized block.
Application deadlocks under load+
Immediate action
Use jconsole or VisualVM to detect deadlocks automatically.
Commands
jcmd <pid> Thread.print -l
kill -3 <pid> > threaddump.txt
Fix now
Redesign lock ordering — if possible, use java.util.concurrent locks with tryLock.
synchronized vs ReentrantLock
FeaturesynchronizedReentrantLock
MechanismImplicit (Keyword)Explicit (API Class)
FairnessAlways UnfairOptional Fairness
FlexibilityBlock-structured onlyCan lock in one method, unlock in another
InterruptibilityNo (Thread stays blocked)Yes (via lockInterruptibly)
PerformanceExtremely optimized by JITBetter under high contention

Key takeaways

1
Concurrency is about managing shared mutable state; if state isn't shared or isn't mutable, it's thread-safe by default.
2
Volatile solves visibility but not atomicity; Atomic classes use hardware-level CAS for lock-free thread safety.
3
Always favor high-level concurrency utilities (java.util.concurrent) like ExecutorService or CountDownLatch over raw Thread objects.
4
The Java Memory Model allows for compiler reordering; synchronization primitives act as 'memory barriers' to prevent this.
5
Thread dumps are your best friend for debugging concurrency issues. Learn to read BLOCKED vs WAITING vs RUNNABLE stacks.
6
Deadlock is prevented by consistent lock ordering
document the order and enforce it in code reviews.

Common mistakes to avoid

6 patterns
×

Using volatile for counters (e.g., volatile int count++; is NOT thread-safe)

Symptom
Counter ends up with incorrect values under high concurrency — misses increments due to lost updates.
Fix
Use AtomicInteger for counters or wrap the increment in a synchronized block.
×

Not releasing locks in a 'finally' block, leading to permanent resource starvation

Symptom
After an exception is thrown, the lock is never released. Other threads block indefinitely.
Fix
Always use try-finally (or try-with-resources for ReentrantLock) to ensure unlock() is called.
×

Calling Thread.stop(), Thread.suspend(), or Thread.resume() — these are deprecated and dangerous

Symptom
Thread.stop() can leave objects in an inconsistent state because it releases all locks abruptly.
Fix
Use a volatile flag to signal the thread to stop gracefully, and handle InterruptedException properly.
×

Ignoring the InterruptedException, which breaks the thread's ability to shut down gracefully

Symptom
Thread cannot be cancelled — it keeps running even when interrupted.
Fix
Always call 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

Symptom
Race conditions appear when one method is synchronized but another accessor is not, or when state is shared across objects.
Fix
Ensure all methods that read or write shared mutable state are synchronized on the same lock. Consider using atomic classes or immutable objects.
×

Using synchronized on a String literal or a boxed primitive

Symptom
Strings from the constant pool or small integers from the cache are shared across the JVM — two unrelated pieces of code can block each other.
Fix
Create a dedicated Object lock per class or use private final Object lock = new Object();
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does the 'Happens-Before' principle apply to a thread starting versu...
Q02SENIOR
Explain the 'Double-Checked Locking' pattern for Singletons. Why was it ...
Q03SENIOR
Compare 'Optimistic Locking' using CAS (Compare-And-Swap) in Atomic clas...
Q04SENIOR
What is the difference between 'yielding', 'sleeping', and 'waiting' in ...
Q05SENIOR
How does a ForkJoinPool differ from a standard ThreadPoolExecutor? When ...
Q01 of 05SENIOR

How does the 'Happens-Before' principle apply to a thread starting versus a thread joining, and how does it guarantee memory visibility?

ANSWER
The JMM defines that when a thread calls 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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Why is it said that 'Wait/Notify' must always be called from a synchronized context?
02
What is a 'Race Condition' versus a 'Data Race' in Java?
03
What is the difference between Runnable and Callable?
04
Can volatile cause instruction reordering?
05
What is the difference between Thread.interrupt() and a volatile flag for cancellation?
N
Naren Founder & Principal Engineer

20+ years shipping production code across the stack, with years spent interviewing engineers. Notes here come from systems that actually shipped.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's Java Interview. Mark it forged?

12 min read · try the examples if you haven't

Previous
Java Collections Interview Questions
4 / 6 · Java Interview
Next
Java 8 Interview Questions