Java Multithreading β Syntax, Types, Examples & Best Practices
Everything you need to know about Java Multithreading β Thread class, Runnable interface, thread lifecycle, synchronization, volatile keyword, wait/notify, Executor framework, thread pools, Callable/Future, CompletableFuture, concurrent collections, atomic classes, ReentrantLock, deadlock prevention, and virtual threads (Java 21).
Last Updated
March 2026
Read Time
30 min
Level
Advanced
Chapter
28 of 35
What is Multithreading in Java?
Multithreading is the ability of a Java program to execute multiple threads simultaneously within a single process. A thread is the smallest unit of CPU execution β it has its own program counter, call stack, and local variables, but shares the heap memory (objects, static fields, open files) with all other threads in the same JVM process. This shared memory is what makes threads powerful and dangerous at the same time.
Modern applications are inherently concurrent. A web server handles thousands of HTTP requests simultaneously β each on its own thread. A payment gateway processes multiple transactions in parallel. A trading platform ticks market prices while executing orders concurrently. An IDE compiles code in the background while you keep typing. Without multithreading, none of these systems would scale.
The two primary benefits of multithreading are: (1) CPU utilisation β on a multi-core processor, threads run truly in parallel across cores, multiplying throughput. (2) Responsiveness β the UI thread stays free while a background thread does heavy computation or waits for I/O. The primary risk is shared mutable state β when two threads simultaneously read and write the same object without coordination, the result is unpredictable: race conditions, stale data, and corruption.
Creating Threads β Thread Class vs Runnable Interface
Java provides two classic ways to define a concurrent task: extending Thread and implementing Runnable. A third way β implementing Callable β is used when the task must return a result or throw a checked exception. In modern Java, lambdas make Runnable and Callable extremely concise, and the Executor framework manages the actual threads β raw Thread creation is reserved for low-level scenarios.
class MyTask extends Thread { public void run() { /* task */ } } new MyTask().start(); Problems: (1) Consumes the single Java inheritance slot β MyTask can't extend any other class. (2) Tightly couples task logic with thread lifecycle. (3) Cannot be submitted to an Executor. Use only if you need to override other Thread methods (e.g., custom interrupt handling).
class MyTask implements Runnable { public void run() { /* task */ } } new Thread(new MyTask()).start(); // OR submit to executor: executor.submit(new MyTask()); // OR lambda: executor.submit(() -> { /* task */ }); Advantages: separates task from thread, allows extending another class, compatible with all Executor/thread pool APIs.
class MyTask implements Callable<Integer> { public Integer call() throws Exception { return 42; } } Future<Integer> f = executor.submit(new MyTask()); int result = f.get(); // blocks until done Callable differs from Runnable: (1) returns a typed result, (2) can throw checked exceptions, (3) used with Future/CompletableFuture.
import java.util.concurrent.*;
// ββ Approach 1: Extend Thread (avoid in production) ββββββββββββββ
class DownloadTask extends Thread {
private final String url;
DownloadTask(String url) { this.url = url; }
@Override
public void run() {
System.out.println(Thread.currentThread().getName()
+ " downloading: " + url);
// ... download logic
}
}
// ββ Approach 2: Implement Runnable (preferred) βββββββββββββββββββ
class ReportGenerator implements Runnable {
private final String reportType;
ReportGenerator(String type) { this.reportType = type; }
@Override
public void run() {
System.out.println(Thread.currentThread().getName()
+ " generating " + reportType + " report");
// ... report generation logic
}
}
// ββ Approach 3: Implement Callable (returns result) ββββββββββββββ
class PriceCalculator implements Callable<Double> {
private final String productId;
PrimeCalculator(String id) { this.productId = id; }
@Override
public Double call() throws Exception {
// ... fetch price from DB
System.out.println("Calculating price for: " + productId);
return 1499.99;
}
}
public class CreatingThreads {
public static void main(String[] args) throws Exception {
// Approach 1: direct thread start
Thread t1 = new DownloadTask("https://example.com/file.zip");
t1.setName("Download-Thread");
t1.start(); // start(), NOT run() β run() is synchronous!
// Approach 2: Runnable via Thread
Thread t2 = new Thread(new ReportGenerator("Sales"), "Report-Thread");
t2.start();
// Approach 2: Runnable via Lambda (cleanest for simple tasks)
Thread t3 = new Thread(() ->
System.out.println(Thread.currentThread().getName() + " processing"),
"Lambda-Thread");
t3.start();
// Approach 3: Callable via ExecutorService
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<Double> priceFuture = executor.submit(new PriceCalculator("PROD-001"));
// Do other work while price is being calculated...
System.out.println("Main thread doing other work...");
Double price = priceFuture.get(); // blocks until result is ready
System.out.println("Price fetched: βΉ" + price);
executor.shutdown();
}
}Thread Lifecycle β States & Transitions
A Java thread passes through a well-defined set of states during its lifetime, defined by the Thread.State enum. Understanding these states is essential for debugging stuck threads, analysing thread dumps, and understanding what transitions are possible.
public class ThreadLifecycle {
public static void main(String[] args) throws InterruptedException {
Object lock = new Object();
Thread worker = new Thread(() -> {
try {
System.out.println("Worker: starting work");
Thread.sleep(500); // TIMED_WAITING
synchronized (lock) {
lock.wait(300); // WAITING (on lock)
}
System.out.println("Worker: finishing");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("Worker: interrupted");
}
}, "WorkerThread");
System.out.println("State after new : " + worker.getState()); // NEW
worker.start();
System.out.println("State after start() : " + worker.getState()); // RUNNABLE
Thread.sleep(100); // let worker enter sleep
System.out.println("State during sleep : " + worker.getState()); // TIMED_WAITING
Thread.sleep(600); // let worker enter wait
System.out.println("State during wait : " + worker.getState()); // WAITING or TIMED_WAITING
worker.join(); // wait for completion
System.out.println("State after join : " + worker.getState()); // TERMINATED
// ββ Daemon threads β JVM exits without waiting for them ββ
Thread daemon = new Thread(() -> {
while (true) {
try { Thread.sleep(1000); } catch (InterruptedException e) { break; }
System.out.println("Daemon heartbeat");
}
});
daemon.setDaemon(true); // MUST set before start()
daemon.start();
// JVM will terminate daemon thread automatically when main finishes
}
}Important Thread Methods β sleep, join, interrupt, yield
The Thread class provides several key methods for controlling thread execution, coordination between threads, and graceful shutdown. Understanding their precise semantics β especially the difference between sleep and wait, and the correct way to handle interrupt β is essential for writing correct concurrent code.
import java.util.concurrent.atomic.AtomicBoolean;
public class ThreadMethods {
// ββ Graceful shutdown using interrupt flag ββββββββββββββββββββ
static class DataPoller implements Runnable {
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
try {
System.out.println("Polling data...");
Thread.sleep(1000); // reacts to interrupt immediately
} catch (InterruptedException e) {
// β
Re-interrupt ALWAYS β never swallow InterruptedException
Thread.currentThread().interrupt();
System.out.println("Poller: interrupt received β shutting down");
break;
}
}
System.out.println("Poller: clean shutdown complete");
}
}
public static void main(String[] args) throws InterruptedException {
// ββ Thread.sleep() βββββββββββββββββββββββββββββββββββββ
System.out.println("Main: sleeping 500ms");
Thread.sleep(500);
System.out.println("Main: awake");
// ββ thread.join() β wait for completion βββββββββββββββ
Thread[] workers = new Thread[3];
for (int i = 0; i < 3; i++) {
final int id = i;
workers[i] = new Thread(() -> {
try {
Thread.sleep((long)(Math.random() * 1000));
System.out.println("Worker-" + id + " done");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Worker-" + i);
workers[i].start();
}
// Wait for ALL workers to complete
for (Thread w : workers) w.join();
System.out.println("Main: all workers finished");
// ββ Graceful interrupt-based shutdown ββββββββββββββββ
Thread poller = new Thread(new DataPoller(), "DataPoller");
poller.start();
Thread.sleep(3500); // let it poll 3 times
poller.interrupt(); // signal shutdown
poller.join(); // wait for clean exit
System.out.println("Main: poller shut down gracefully");
}
}Synchronization β Preventing Race Conditions
A race condition occurs when two or more threads access shared mutable data concurrently and the outcome depends on the unpredictable order of their execution. Java's primary tool to prevent race conditions is synchronization β built around the concept of a monitor lock (also called an intrinsic lock). Every Java object has exactly one monitor lock. The synchronized keyword causes a thread to acquire the lock before entering a block or method, and release it upon exit β ensuring only one thread at a time executes the guarded code.
public synchronized void deposit(double amount) { ... } Acquires lock on 'this' (instance methods) or on the Class object (static methods). Entire method is the critical section. Simple to write but coarser-grained β any caller must wait even if it only needs to read an unrelated field. Two synchronized instance methods on the SAME object cannot run concurrently.
public void deposit(double amount) { // non-critical work here (concurrent) synchronized (this) { balance += amount; // critical section } // non-critical work here (concurrent) } Finer-grained: only the minimum code that accesses shared state is locked. You can choose any object as the lock β enabling independent locks for independent data.
private final Object balanceLock = new Object(); private final Object historyLock = new Object(); synchronized (balanceLock) { balance += amount; } synchronized (historyLock) { history.add(txn); } Balance updates and history updates can now run concurrently β they use different locks. This technique maximises throughput in high-contention scenarios.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
// ββ BAD: Unsynchronized counter β race condition ββββββββββββββββββ
class UnsafeCounter {
private int count = 0;
public void increment() { count++; } // NOT atomic: read β increment β write
public int getCount() { return count; }
}
// ββ GOOD: Synchronized counter ββββββββββββββββββββββββββββββββββββ
class SafeCounter {
private int count = 0;
public synchronized void increment() { count++; }
public synchronized int getCount() { return count; }
}
// ββ BETTER: Synchronized block with private lock ββββββββββββββββββ
class BankAccount {
private double balance;
private final Object lock = new Object(); // private lock object
public BankAccount(double initialBalance) { this.balance = initialBalance; }
public void deposit(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
synchronized (lock) { // lock on private object, not 'this'
balance += amount;
}
}
public boolean withdraw(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
synchronized (lock) {
if (balance < amount) return false;
balance -= amount;
return true;
}
}
public double getBalance() {
synchronized (lock) { return balance; }
}
}
public class SynchronizationDemo {
public static void main(String[] args) throws InterruptedException {
int threadCount = 100;
int incPerThread = 1000;
// ββ Race condition demo βββββββββββββββββββββββββββββββββ
UnsafeCounter unsafe = new UnsafeCounter();
ExecutorService pool1 = Executors.newFixedThreadPool(10);
for (int i = 0; i < threadCount; i++)
pool1.submit(() -> { for (int j = 0; j < incPerThread; j++) unsafe.increment(); });
pool1.shutdown(); pool1.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("Unsafe expected: " + (threadCount * incPerThread));
System.out.println("Unsafe actual : " + unsafe.getCount()); // likely < 100000!
// ββ Synchronized counter ββββββββββββββββββββββββββββββββ
SafeCounter safe = new SafeCounter();
ExecutorService pool2 = Executors.newFixedThreadPool(10);
for (int i = 0; i < threadCount; i++)
pool2.submit(() -> { for (int j = 0; j < incPerThread; j++) safe.increment(); });
pool2.shutdown(); pool2.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("Safe expected : " + (threadCount * incPerThread));
System.out.println("Safe actual : " + safe.getCount()); // always 100000
}
}volatile Keyword β Visibility Guarantee Without Mutual Exclusion
The volatile keyword solves a specific problem: memory visibility. Without volatile, each CPU core may cache a thread's local copy of a variable in its registers or L1/L2 cache. A write by Thread A may not be visible to Thread B running on a different core for an unpredictable amount of time. volatile guarantees that every read of a volatile variable goes to main memory, and every write immediately flushes to main memory β establishing a happens-before relationship.
public class VolatileKeyword {
// ββ Correct use of volatile: single boolean flag βββββββββββββ
static class BackgroundService implements Runnable {
private volatile boolean running = true; // visibility guarantee
@Override
public void run() {
System.out.println("Service started");
while (running) { // always reads from main memory
try {
processNextItem();
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
System.out.println("Service stopped gracefully");
}
public void stop() {
running = false; // write immediately flushed to main memory
}
private void processNextItem() {
System.out.println("Processing...");
}
}
// ββ WITHOUT volatile β the JVM may CACHE 'running' in a register
// The while loop could run forever even after stop() is called!
// ββ volatile does NOT fix compound operations βββββββββββββββββ
static volatile int counter = 0;
static void badIncrement() {
counter++; // β NOT atomic even with volatile
// Expands to: read counter β increment β write counter
// Two threads can both read same value before either writes
}
static synchronized void goodIncrement() {
counter++; // β
synchronized makes compound action atomic
}
// ββ Double-checked locking β classic volatile use βββββββββββββ
// WITHOUT volatile, broken due to instruction reordering
private static volatile BackgroundService instance;
public static BackgroundService getInstance() {
if (instance == null) { // first check (no lock)
synchronized (VolatileKeyword.class) {
if (instance == null) { // second check (with lock)
instance = new BackgroundService(); // volatile write
}
}
}
return instance; // volatile read β always sees fully initialised object
}
public static void main(String[] args) throws InterruptedException {
BackgroundService svc = new BackgroundService();
Thread t = new Thread(svc, "BackgroundService");
t.start();
Thread.sleep(500);
svc.stop(); // write to volatile β service sees it immediately
t.join();
}
}wait(), notify(), notifyAll() β Thread Communication
While synchronized prevents two threads from running simultaneously, wait() and notify() allow threads to coordinate β one thread can pause and wait for a condition to be met, while another thread signals that the condition has changed. This is the classic Producer-Consumer coordination pattern. All three methods (wait, notify, notifyAll) must be called inside a synchronized block on the same object β the monitor object.
import java.util.LinkedList;
import java.util.Queue;
public class WaitNotify {
// ββ Bounded buffer shared between producer and consumer βββββββ
static class BoundedBuffer<T> {
private final Queue<T> queue;
private final int capacity;
private final Object lock = new Object();
BoundedBuffer(int capacity) {
this.capacity = capacity;
this.queue = new LinkedList<>();
}
// Producer calls this β blocks if buffer is full
public void put(T item) throws InterruptedException {
synchronized (lock) {
while (queue.size() == capacity) { // β
while loop, not if
System.out.println("[Producer] Buffer full β waiting");
lock.wait(); // releases lock; waits for consumer signal
}
queue.offer(item);
System.out.println("[Producer] Produced: " + item
+ " | Buffer: " + queue.size() + "/" + capacity);
lock.notifyAll(); // wake consumers β item is available
}
}
// Consumer calls this β blocks if buffer is empty
public T take() throws InterruptedException {
synchronized (lock) {
while (queue.isEmpty()) { // β
while loop, not if
System.out.println("[Consumer] Buffer empty β waiting");
lock.wait(); // releases lock; waits for producer signal
}
T item = queue.poll();
System.out.println("[Consumer] Consumed: " + item
+ " | Buffer: " + queue.size() + "/" + capacity);
lock.notifyAll(); // wake producers β space is available
return item;
}
}
}
public static void main(String[] args) {
BoundedBuffer<Integer> buffer = new BoundedBuffer<>(3);
// ββ Producer thread βββββββββββββββββββββββββββββββββββββββ
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 8; i++) {
buffer.put(i);
Thread.sleep(200);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Producer");
// ββ Consumer thread βββββββββββββββββββββββββββββββββββββββ
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 8; i++) {
buffer.take();
Thread.sleep(500); // consumer is slower than producer
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Consumer");
producer.start();
consumer.start();
}
}Executor Framework β Thread Pools Done Right
Creating a raw new Thread() for every task is dangerous in production: thread creation is expensive (1β10ms), unbounded thread creation exhausts OS resources, and there is no lifecycle management. The Executor framework (java.util.concurrent) solves all of this β it manages a pool of reusable threads, queues tasks, handles thread lifecycle, and provides controlled shutdown. ExecutorService is the interface; Executors factory class creates common pool configurations.
import java.util.concurrent.*;
public class ExecutorFramework {
public static void main(String[] args) throws Exception {
// ββ Fixed Thread Pool β for CPU-bound tasks βββββββββββββββ
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuPool = Executors.newFixedThreadPool(cores);
for (int i = 1; i <= 8; i++) {
final int taskId = i;
cpuPool.submit(() -> {
System.out.printf("Task %d running on %s%n",
taskId, Thread.currentThread().getName());
// simulate CPU work
double result = 0;
for (int j = 0; j < 1_000_000; j++) result += Math.sqrt(j);
System.out.printf("Task %d done%n", taskId);
});
}
cpuPool.shutdown();
cpuPool.awaitTermination(60, TimeUnit.SECONDS);
// ββ Custom ThreadPoolExecutor β production recommended ββββ
ThreadPoolExecutor customPool = new ThreadPoolExecutor(
4, // corePoolSize
8, // maximumPoolSize
60L, TimeUnit.SECONDS, // keepAliveTime for extra threads
new ArrayBlockingQueue<>(100), // bounded task queue
new ThreadFactory() { // named threads for debugging
int counter = 1;
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "CustomPool-" + counter++);
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // back-pressure strategy
);
// Submit tasks
for (int i = 1; i <= 5; i++) {
final int id = i;
customPool.submit(() ->
System.out.println(Thread.currentThread().getName() + ": task " + id));
}
customPool.shutdown();
if (!customPool.awaitTermination(30, TimeUnit.SECONDS)) {
customPool.shutdownNow(); // force shutdown if tasks take too long
}
// ββ ScheduledExecutorService β periodic tasks βββββββββββββ
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
// Run once after 2 second delay
scheduler.schedule(() -> System.out.println("Delayed task!"),
2, TimeUnit.SECONDS);
// Run every 1 second, starting after 0 delay
ScheduledFuture<?> periodic = scheduler.scheduleAtFixedRate(
() -> System.out.println("Periodic: " + System.currentTimeMillis()),
0, 1, TimeUnit.SECONDS);
Thread.sleep(4000);
periodic.cancel(false); // stop periodic task
scheduler.shutdown();
}
}Callable & Future β Threads That Return Results
Runnable.run() returns void and cannot throw checked exceptions β it provides no way to get a result back from a thread. Callable<V> solves both problems: its call() method returns a typed value V and can throw any checked exception. When submitted to an ExecutorService, it returns a Future<V> β a handle to the computation that lets you check completion, retrieve the result, cancel the task, or handle exceptions.
import java.util.concurrent.*;
import java.util.*;
public class CallableFuture {
// ββ Callable that fetches a product price βββββββββββββββββββββ
static class PriceFetcher implements Callable<Double> {
private final String productId;
private final String source;
PriceFetcher(String productId, String source) {
this.productId = productId;
this.source = source;
}
@Override
public Double call() throws Exception {
System.out.printf("[%s] Fetching price for %s...%n",
source, productId);
Thread.sleep((long)(Math.random() * 1000)); // simulate network
double price = 1000 + Math.random() * 500;
System.out.printf("[%s] Price for %s: βΉ%.2f%n",
source, productId, price);
return price;
}
}
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(4);
// ββ Submit multiple Callables, get Futures ββββββββββββββββ
String productId = "LAPTOP-XPS15";
List<Future<Double>> futures = new ArrayList<>();
futures.add(executor.submit(new PriceFetcher(productId, "Amazon")));
futures.add(executor.submit(new PriceFetcher(productId, "Flipkart")));
futures.add(executor.submit(new PriceFetcher(productId, "Croma")));
System.out.println("Main: waiting for all prices...");
// ββ Collect results ββββββββββββββββββββββββββββββββββββββββ
double minPrice = Double.MAX_VALUE;
for (Future<Double> future : futures) {
try {
double price = future.get(5, TimeUnit.SECONDS); // timeout!
minPrice = Math.min(minPrice, price);
} catch (TimeoutException e) {
System.out.println("Price fetch timed out");
future.cancel(true); // interrupt the blocked thread
} catch (ExecutionException e) {
// Callable threw an exception β wrapped in ExecutionException
System.out.println("Price fetch failed: " + e.getCause().getMessage());
}
}
System.out.printf("Best price: βΉ%.2f%n", minPrice);
// ββ invokeAll β submit all, wait for all to complete βββββββ
List<Callable<Double>> tasks = List.of(
new PriceFetcher("PHONE-S24", "Amazon"),
new PriceFetcher("PHONE-S24", "Flipkart"),
new PriceFetcher("PHONE-S24", "Reliance")
);
List<Future<Double>> allResults = executor.invokeAll(tasks);
System.out.println("All prices fetched: " + allResults.size());
// ββ invokeAny β return as soon as ONE succeeds ββββββββββββ
double firstPrice = executor.invokeAny(tasks);
System.out.printf("First available price: βΉ%.2f%n", firstPrice);
executor.shutdown();
}
}CompletableFuture β Non-Blocking Async Pipelines (Java 8+)
CompletableFuture<T> (Java 8+) is the most powerful concurrency API in modern Java. Unlike Future β which blocks when you call get() β CompletableFuture lets you build non-blocking async pipelines: chain transformations, combine multiple futures, handle errors, and define callbacks that execute when results become available β all without blocking any thread.
import java.util.concurrent.*;
public class CompletableFutureDemo {
// Simulated async services
static CompletableFuture<String> fetchUser(String userId) {
return CompletableFuture.supplyAsync(() -> {
sleep(300);
return "User:" + userId;
});
}
static CompletableFuture<Double> fetchAccountBalance(String userId) {
return CompletableFuture.supplyAsync(() -> {
sleep(200);
return 50000.0;
});
}
static CompletableFuture<String> processPayment(double amount, String userId) {
return CompletableFuture.supplyAsync(() -> {
sleep(400);
if (amount > 100000) throw new RuntimeException("Amount exceeds daily limit");
return "TXN-" + System.currentTimeMillis();
});
}
static CompletableFuture<Void> sendConfirmation(String txnId, String userId) {
return CompletableFuture.runAsync(() -> {
sleep(100);
System.out.println("Confirmation sent for " + txnId + " to " + userId);
});
}
public static void main(String[] args) throws Exception {
// ββ Sequential pipeline β each step waits for previous ββββ
String userId = "USR-1001";
double amount = 5000.0;
CompletableFuture<Void> pipeline = fetchUser(userId)
.thenCompose(user -> processPayment(amount, user)) // flat map
.thenCompose(txnId -> sendConfirmation(txnId, userId))
.exceptionally(ex -> {
System.out.println("Pipeline failed: " + ex.getMessage());
return null;
});
pipeline.join(); // wait for completion
// ββ Parallel execution β combine independent futures βββββββ
CompletableFuture<String> userFuture = fetchUser(userId);
CompletableFuture<Double> balanceFuture = fetchAccountBalance(userId);
// Both run in parallel β thenCombine waits for both
CompletableFuture<String> combined = userFuture.thenCombine(
balanceFuture,
(user, balance) -> user + " has balance βΉ" + balance
);
System.out.println(combined.get()); // User:USR-1001 has balance βΉ50000.0
// ββ allOf β wait for ALL futures ββββββββββββββββββββββββββ
CompletableFuture<String>[] tasks = new CompletableFuture[]{
fetchUser("USR-001"), fetchUser("USR-002"), fetchUser("USR-003")
};
CompletableFuture.allOf(tasks).join();
System.out.println("All users fetched");
// ββ anyOf β return as soon as FIRST completes βββββββββββββ
CompletableFuture<Object> firstDone = CompletableFuture.anyOf(
fetchUser("USR-A"), fetchUser("USR-B"), fetchUser("USR-C")
);
System.out.println("First result: " + firstDone.get());
// ββ Error handling ββββββββββββββββββββββββββββββββββββββββ
CompletableFuture<String> withFallback =
processPayment(200000, userId) // will throw
.exceptionally(ex -> "FAILED:" + ex.getMessage())
.thenApply(result -> "Result: " + result);
System.out.println(withFallback.get());
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}Concurrent Collections β Thread-Safe Data Structures
Standard Java collections (ArrayList, HashMap, HashSet) are not thread-safe. Concurrent access without synchronisation causes corruption, ConcurrentModificationException, and infinite loops. The java.util.concurrent package provides production-ready thread-safe collections designed for high concurrency β far more efficient than wrapping everything in Collections.synchronizedXxx().
import java.util.concurrent.*;
import java.util.*;
public class ConcurrentCollections {
public static void main(String[] args) throws InterruptedException {
// ββ ConcurrentHashMap β high-performance thread-safe map ββ
ConcurrentHashMap<String, Integer> wordCount = new ConcurrentHashMap<>();
String[] words = {"java", "thread", "java", "concurrent", "thread", "java"};
// Thread-safe merge β no manual synchronization needed
for (String word : words) {
wordCount.merge(word, 1, Integer::sum);
}
System.out.println("Word counts: " + wordCount);
// putIfAbsent β atomic check-then-insert (no synchronized needed)
wordCount.putIfAbsent("kotlin", 0);
// computeIfAbsent β create value lazily
wordCount.computeIfAbsent("scala", k -> 0);
// ββ LinkedBlockingQueue β producer-consumer βββββββββββββββ
LinkedBlockingQueue<String> taskQueue = new LinkedBlockingQueue<>(10);
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 5; i++) {
taskQueue.put("Task-" + i); // blocks if full
System.out.println("Enqueued Task-" + i);
Thread.sleep(100);
}
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "Producer");
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
String task = taskQueue.take(); // blocks if empty
System.out.println("Processing: " + task);
Thread.sleep(200);
}
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "Consumer");
producer.start(); consumer.start();
producer.join(); consumer.join();
// ββ CopyOnWriteArrayList β read-heavy scenarios βββββββββββ
CopyOnWriteArrayList<String> listeners = new CopyOnWriteArrayList<>();
listeners.add("EmailNotifier");
listeners.add("SmsNotifier");
// Safe to iterate while another thread modifies:
ExecutorService pool = Executors.newFixedThreadPool(2);
pool.submit(() -> listeners.add("PushNotifier"));
// This iteration won't throw ConcurrentModificationException
listeners.forEach(l -> System.out.println("Listener: " + l));
pool.shutdown();
}
}Atomic Classes β Lock-Free Thread Safety
The java.util.concurrent.atomic package provides thread-safe classes for single-variable operations without locks. They use Compare-And-Swap (CAS) β a CPU-level instruction that atomically reads, compares, and writes a value only if it still matches the expected value. This is faster than synchronised methods for simple operations because there is no lock contention, no context switch, and no thread blocking.
import java.util.concurrent.atomic.*;
import java.util.concurrent.*;
public class AtomicClasses {
public static void main(String[] args) throws InterruptedException {
int threads = 100;
int perThread = 1000;
// ββ AtomicInteger β lock-free int operations ββββββββββββββ
AtomicInteger atomicCounter = new AtomicInteger(0);
ExecutorService pool = Executors.newFixedThreadPool(10);
for (int i = 0; i < threads; i++)
pool.submit(() -> {
for (int j = 0; j < perThread; j++)
atomicCounter.incrementAndGet(); // atomic: CAS loop
});
pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("AtomicInteger: " + atomicCounter.get()); // always 100000
// Key atomic methods:
AtomicInteger ai = new AtomicInteger(10);
System.out.println(ai.getAndIncrement()); // 10 (returns OLD value)
System.out.println(ai.incrementAndGet()); // 12 (returns NEW value)
System.out.println(ai.addAndGet(5)); // 17
System.out.println(ai.compareAndSet(17, 20)); // true β atomically set to 20
System.out.println(ai.compareAndSet(17, 99)); // false β value is 20, not 17
System.out.println(ai.get()); // 20
// ββ AtomicLong β for large counters (request count, bytes) β
AtomicLong requestCount = new AtomicLong(0L);
requestCount.incrementAndGet();
// ββ AtomicBoolean β for flags βββββββββββββββββββββββββββββ
AtomicBoolean isInitialised = new AtomicBoolean(false);
// Atomic check-and-set β only one thread will see true
if (isInitialised.compareAndSet(false, true)) {
System.out.println("This thread performed initialisation");
}
// ββ AtomicReference β for any object ββββββββββββββββββββββ
AtomicReference<String> currentConfig = new AtomicReference<>("v1.0");
String old = currentConfig.getAndSet("v2.0");
System.out.println("Updated from " + old + " to " + currentConfig.get());
// ββ LongAdder β better than AtomicLong for high-contention counters β
// Distributes updates across multiple cells; aggregates on sum()
LongAdder adder = new LongAdder();
ExecutorService pool2 = Executors.newFixedThreadPool(10);
for (int i = 0; i < threads; i++)
pool2.submit(() -> {
for (int j = 0; j < perThread; j++) adder.increment();
});
pool2.shutdown(); pool2.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("LongAdder sum: " + adder.sum()); // always 100000
// LongAdder significantly outperforms AtomicLong under heavy contention
}
}ReentrantLock, ReadWriteLock & Semaphore
java.util.concurrent.locks provides explicit lock classes that offer capabilities impossible with synchronized: tryLock with timeout (avoid deadlock), interruptible lock acquisition, fairness policies, separate read and write locks, and Condition objects for fine-grained thread coordination. These are power tools β use them when synchronized cannot meet the requirements.
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
public class LocksAndSemaphores {
// ββ ReentrantLock β explicit lock with tryLock ββββββββββββββββ
static class ThreadSafeCache {
private final java.util.Map<String, String> cache = new java.util.HashMap<>();
private final ReentrantLock lock = new ReentrantLock(true); // fair mode
public void put(String key, String value) throws InterruptedException {
// tryLock with timeout β avoids deadlock
if (lock.tryLock(5, TimeUnit.SECONDS)) {
try {
cache.put(key, value);
} finally {
lock.unlock(); // ALWAYS unlock in finally
}
} else {
System.out.println("Could not acquire lock β skipping put");
}
}
public String get(String key) throws InterruptedException {
if (lock.tryLock(5, TimeUnit.SECONDS)) {
try { return cache.get(key); }
finally { lock.unlock(); }
}
return null;
}
}
// ββ ReadWriteLock β concurrent reads, exclusive writes βββββββββ
static class ConfigStore {
private final java.util.Map<String, String> config = new java.util.HashMap<>();
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
// Multiple threads can read simultaneously
public String get(String key) {
readLock.lock();
try { return config.get(key); }
finally { readLock.unlock(); }
}
// Only ONE thread can write at a time; blocks all readers
public void set(String key, String value) {
writeLock.lock();
try { config.put(key, value); }
finally { writeLock.unlock(); }
}
}
// ββ Semaphore β limit concurrent access (connection pool) βββββ
static class DatabaseConnectionPool {
private final Semaphore semaphore;
private final int maxConnections;
DatabaseConnectionPool(int maxConnections) {
this.maxConnections = maxConnections;
this.semaphore = new Semaphore(maxConnections, true); // fair
}
public void executeQuery(String query, String threadName)
throws InterruptedException {
System.out.println(threadName + ": waiting for connection...");
semaphore.acquire(); // blocks until a permit is available
try {
System.out.println(threadName + ": acquired connection ("
+ (maxConnections - semaphore.availablePermits()) + " in use)");
Thread.sleep(500); // simulate query execution
System.out.println(threadName + ": query done");
} finally {
semaphore.release(); // ALWAYS release in finally
System.out.println(threadName + ": released connection");
}
}
}
public static void main(String[] args) throws Exception {
DatabaseConnectionPool pool = new DatabaseConnectionPool(3);
ExecutorService exec = Executors.newFixedThreadPool(7);
for (int i = 1; i <= 7; i++) {
final String name = "Thread-" + i;
exec.submit(() -> {
try { pool.executeQuery("SELECT ...", name); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
}
exec.shutdown();
exec.awaitTermination(30, TimeUnit.SECONDS);
}
}Deadlock β Detection, Prevention & Resolution
A deadlock is one of the most feared concurrency bugs β a situation where two or more threads are permanently blocked, each waiting for a resource held by another. The JVM does not automatically detect or resolve deadlocks (unlike databases). A deadlocked application simply hangs β no error, no log, no recovery β making it extremely hard to diagnose in production.
ALL four must hold simultaneously for deadlock to occur: 1. Mutual Exclusion β resources can only be held by one thread. 2. Hold and Wait β a thread holds one resource while waiting for another. 3. No Preemption β a resource cannot be forcibly taken from a thread. 4. Circular Wait β Thread A waits for Thread B, Thread B waits for Thread A. Breaking ANY one condition prevents deadlock.
Always acquire multiple locks in the SAME GLOBAL ORDER across ALL threads. Bad: Thread A acquires Lock1 then Lock2; Thread B acquires Lock2 then Lock1. Good: Both threads always acquire Lock1 first, then Lock2. This breaks Circular Wait β deadlock is impossible when all threads agree on lock order. For locks on domain objects, use a consistent ordering like object hash code or ID.
Use ReentrantLock.tryLock(timeout) instead of synchronized or lock.lock(). If the lock isn't available within the timeout, back off, release all held locks, and retry later. This breaks Hold-and-Wait β a thread that can't get all locks releases what it has. Java thread dumps (jstack) and VisualVM show BLOCKED threads β useful for detecting deadlock in production.
import java.util.concurrent.locks.*;
import java.util.concurrent.TimeUnit;
// ββ Classic Deadlock ββββββββββββββββββββββββββββββββββββββββββββββ
class Account {
private final int id;
private double balance;
Account(int id, double balance) {
this.id = id; this.balance = balance;
}
// β DEADLOCK RISK: lock order depends on argument order
public static void transferUnsafe(Account from, Account to, double amount)
throws InterruptedException {
synchronized (from) {
Thread.sleep(50); // simulate work β increases chance of deadlock
synchronized (to) {
from.balance -= amount;
to.balance += amount;
}
}
}
// β
FIX 1: Consistent lock ordering by account ID
public static void transferSafe(Account from, Account to, double amount) {
Account first = from.id < to.id ? from : to;
Account second = from.id < to.id ? to : from;
synchronized (first) {
synchronized (second) {
from.balance -= amount;
to.balance += amount;
System.out.printf("Transferred βΉ%.0f: Acc%dβAcc%d%n",
amount, from.id, to.id);
}
}
}
// β
FIX 2: tryLock with timeout β no indefinite blocking
private final ReentrantLock lock = new ReentrantLock();
public static boolean transferWithTimeout(Account from, Account to,
double amount) throws InterruptedException {
boolean fromLocked = false, toLocked = false;
try {
fromLocked = from.lock.tryLock(500, TimeUnit.MILLISECONDS);
toLocked = to.lock.tryLock(500, TimeUnit.MILLISECONDS);
if (fromLocked && toLocked) {
from.balance -= amount;
to.balance += amount;
System.out.printf("tryLock transfer: βΉ%.0f Acc%dβAcc%d%n",
amount, from.id, to.id);
return true;
}
System.out.println("Could not acquire both locks β will retry");
return false;
} finally {
// ALWAYS release in finally, in reverse order
if (toLocked) to.lock.unlock();
if (fromLocked) from.lock.unlock();
}
}
public int getId() { return id; }
public double getBalance() { return balance; }
}
public class DeadlockDemo {
public static void main(String[] args) throws InterruptedException {
Account acc1 = new Account(1, 100000);
Account acc2 = new Account(2, 50000);
// Safe transfer β no deadlock possible
Thread t1 = new Thread(() -> {
try { Account.transferSafe(acc1, acc2, 5000); }
catch (Exception e) { e.printStackTrace(); }
});
Thread t2 = new Thread(() -> {
try { Account.transferSafe(acc2, acc1, 3000); }
catch (Exception e) { e.printStackTrace(); }
});
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("Acc1: βΉ" + acc1.getBalance());
System.out.println("Acc2: βΉ" + acc2.getBalance());
}
}Virtual Threads β Project Loom (Java 21)
Virtual threads are the most significant concurrency feature added to Java in two decades, standardised in Java 21 (JEP 444). Traditional platform threads map 1:1 to OS threads β each consumes roughly 1β2 MB of stack memory and is expensive to create and context-switch. Applications serving 10,000 concurrent connections needed 10,000 OS threads, which is impractical. Virtual threads are managed entirely by the JVM β they are mounted on a small pool of underlying OS threads (carrier threads) and automatically unmounted when they block on I/O, freeing the carrier thread for another virtual thread.
Backed by an OS thread. Stack: 512KB β 2MB per thread. OS thread limit: typically ~thousands per JVM. Creation: slow (~1ms, system call required). Blocking I/O: OS thread is blocked β carrier thread occupied. Cost: expensive β thread pool required to manage lifecycle. Best for: CPU-bound tasks, low-concurrency scenarios.
Backed by JVM-managed carrier threads (ForkJoinPool). Stack: starts at ~KB, grows lazily on heap. Capacity: MILLIONS per JVM. Creation: fast β no OS system call. Blocking I/O: virtual thread unmounted from carrier β carrier free for others. Cost: cheap β create one per task (no pooling needed). Best for: I/O-bound tasks, high-concurrency server applications.
1. Pinning: a virtual thread is 'pinned' to its carrier if it holds a synchronized lock during I/O β blocking the carrier. Fix: replace synchronized with ReentrantLock inside code that does blocking I/O. 2. Thread-local variables: ThreadLocal with large values multiplied by millions of virtual threads = memory pressure. Consider alternatives. 3. Not for CPU-bound work: virtual threads don't add CPU parallelism β use ForkJoinPool/parallelStream for parallel computation. 4. Still in adaptation: many libraries need updates to use non-pinning I/O.
import java.util.concurrent.*;
public class VirtualThreads {
public static void main(String[] args) throws Exception {
// ββ Creating virtual threads ββββββββββββββββββββββββββββββ
// Method 1: Thread.ofVirtual()
Thread vt1 = Thread.ofVirtual()
.name("virtual-1")
.start(() -> System.out.println(
Thread.currentThread() + " is virtual: " +
Thread.currentThread().isVirtual()));
vt1.join();
// Method 2: Thread.startVirtualThread() β simplest
Thread vt2 = Thread.startVirtualThread(() ->
System.out.println("Quick virtual thread: " +
Thread.currentThread().isVirtual())); // true
vt2.join();
// Method 3: ExecutorService with virtual thread per task
// β
Recommended for server applications
try (ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor()) {
int requestCount = 10_000; // simulate 10K concurrent requests
CountDownLatch latch = new CountDownLatch(requestCount);
long start = System.currentTimeMillis();
for (int i = 0; i < requestCount; i++) {
final int reqId = i;
executor.submit(() -> {
try {
// Simulate I/O-bound work (DB query, HTTP call)
Thread.sleep(100); // virtual thread unmounts here!
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
latch.await();
long elapsed = System.currentTimeMillis() - start;
System.out.printf("%d tasks completed in %d ms%n",
requestCount, elapsed);
// With virtual threads: ~100ms (all run concurrently)
// With 10-thread pool: ~100_000ms (sequential batches)
}
// ββ Structured Concurrency (Java 21 Preview) βββββββββββββ
// Groups virtual threads that belong to the same logical task
// try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Future<String> user = scope.fork(() -> fetchUser("U1"));
// Future<Double> price = scope.fork(() -> fetchPrice("P1"));
// scope.join().throwIfFailed();
// process(user.resultNow(), price.resultNow());
// }
}
}Common Mistakes & Pitfalls β Bugs That Fool Everyone
Concurrency bugs are among the most insidious in software engineering β they are often non-deterministic, appear only under load, and vanish when you add logging or a debugger. These are the patterns that fill production incident reports.
// β MISTAKE 1: Calling run() instead of start()
Thread t = new Thread(() -> System.out.println("Task"));
t.run(); // β runs on CURRENT thread β no new thread created!
t.start(); // β
creates new OS thread, schedules it
// β MISTAKE 2: Swallowing InterruptedException
try { Thread.sleep(1000); }
catch (InterruptedException e) { /* β NEVER do this β interrupt lost */ }
// Fix:
try { Thread.sleep(1000); }
catch (InterruptedException e) {
Thread.currentThread().interrupt(); // β
re-interrupt
return; // or rethrow
}
// β MISTAKE 3: Non-atomic check-then-act on shared state
// Broken even if 'count' is volatile:
if (count < MAX) { // Thread A reads count=9
count++; // Thread B also reads count=9 β both increment β count=11!
}
// Fix: use synchronized block or AtomicInteger.compareAndSet()
// β MISTAKE 4: Locking on a non-final object
// lock reference can change between threads β different objects locked!
private String lockObj = "lock"; // β String interning makes this complex
private final Object LOCK = new Object(); // β
always final, always same object
// β MISTAKE 5: Creating new Thread per request in production
// server.onRequest(req -> new Thread(() -> handle(req)).start());
// β Under 10,000 concurrent requests: 10,000 OS threads β OOM
// Fix: submit to ExecutorService or use virtual threads
// β MISTAKE 6: Using HashMap in concurrent context
Map<String, Integer> map = new HashMap<>();
// Multiple threads writing β ConcurrentModificationException, infinite loop!
// Fix: ConcurrentHashMap
Map<String, Integer> safe = new ConcurrentHashMap<>();
// β MISTAKE 7: Not shutting down ExecutorService β JVM hangs
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.submit(() -> System.out.println("task"));
// Missing pool.shutdown() β non-daemon threads keep JVM alive forever!
pool.shutdown(); // β
always shutdown
pool.awaitTermination(30, TimeUnit.SECONDS); // β
wait for completion
// β MISTAKE 8: Waiting in synchronized block without while loop
synchronized (obj) {
if (queue.isEmpty()) obj.wait(); // β if β spurious wakeup proceeds!
process(queue.poll()); // may execute on empty queue
}
// Fix: while loop
synchronized (obj) {
while (queue.isEmpty()) obj.wait(); // β
recheck condition every wakeup
process(queue.poll());
}Bad Practices & Anti-Patterns β What Senior Developers Reject
These concurrency anti-patterns are the most common causes of production incidents, performance degradation, and system instability in Java applications.
Spawning a new thread for every incoming request without any limit β whether via raw Thread creation or an unbounded CachedThreadPool β will exhaust OS thread limits under load. Each OS thread costs 512KBβ2MB of stack. 10,000 requests = 10,000 threads = 10GB RAM for stacks alone. Use a bounded thread pool (ThreadPoolExecutor with bounded queue and CallerRunsPolicy) or virtual threads. Always design for the worst-case request concurrency.
Synchronising entire methods or large blocks when only a few lines access shared state serialises all callers, eliminating concurrency benefits. Example: synchronizing a method that does 1000ms of computation to protect a 1ms counter update. Fix: use fine-grained locks covering only the critical section; use read/write locks when reads vastly outnumber writes; use atomic classes for single-variable updates.
Submitting tasks to an executor without handling RejectedExecutionException or ignoring task queue depth. When the queue fills and all threads are busy, the default AbortPolicy silently discards tasks. In a payment or order system, this causes silent data loss. Always choose a RejectionPolicy deliberately, monitor queue depth with pool.getQueue().size(), and alert or back-pressure when saturation is detected.
Thread.stop() forcibly kills a thread while it holds locks β leaving shared objects in inconsistent state. Thread.suspend() holds locks while suspended β classic deadlock trigger. Both are deprecated since Java 1.2. Never use them. Use volatile flags, interrupt(), and cooperative shutdown patterns instead. The Java specification explicitly warns that these methods are fundamentally unsafe.
ThreadLocal stores per-thread data. In a thread pool, threads are reused β a ThreadLocal value set by one task is visible to the next task running on the same thread if it isn't cleared. This causes request context leakage: user A's authentication context leaking into user B's request is a security incident. Always call ThreadLocal.remove() in a finally block after the task completes when using thread pools.
while (!ready) {} β a busy-wait loop burns 100% CPU on a core doing nothing useful, and due to memory visibility rules, may loop forever without volatile. Use proper blocking mechanisms: CountDownLatch.await(), BlockingQueue.take(), CompletableFuture.join(), or wait()/notify(). Busy-waiting is only ever justified in very tight latency-sensitive lock-free algorithms, and even then with Thread.onSpinWait() hints to the CPU.
Real-World Production Code Examples β Multithreading in Context
The following examples model concurrency patterns used in real enterprise Java applications β patterns you will recognise from Spring Boot microservices, payment gateways, and high-throughput APIs.
package com.techsustainify.order;
import java.util.concurrent.*;
public class AsyncOrderProcessor {
private final ExecutorService executor;
public AsyncOrderProcessor(int threadPoolSize) {
this.executor = new ThreadPoolExecutor(
threadPoolSize, threadPoolSize * 2,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500),
r -> { Thread t = new Thread(r); t.setName("OrderProcessor-" +
t.getId()); t.setDaemon(true); return t; },
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
// Process order: validate user, check stock, process payment β in parallel
public CompletableFuture<OrderResult> processOrder(OrderRequest request) {
CompletableFuture<UserValidation> userCheck =
CompletableFuture.supplyAsync((),
() -> validateUser(request.getUserId()), executor);
CompletableFuture<StockReservation> stockCheck =
CompletableFuture.supplyAsync(
() -> reserveStock(request.getProductId(), request.getQty()), executor);
// Wait for BOTH user validation and stock to succeed
return userCheck.thenCombine(stockCheck, (user, stock) -> {
if (!user.isValid()) throw new OrderException("User not eligible");
if (!stock.isReserved()) throw new OrderException("Out of stock");
return new PaymentRequest(user, stock, request.getAmount());
})
.thenCompose(payReq ->
// Only after both pass β process payment async
CompletableFuture.supplyAsync(
() -> processPayment(payReq), executor))
.thenApply(txn -> OrderResult.success(txn.getTransactionId()))
.exceptionally(ex -> {
// Rollback stock if payment failed
rollbackStock(request.getProductId(), request.getQty());
return OrderResult.failure(ex.getMessage());
});
}
private UserValidation validateUser(String userId) { /* ... */ return null; }
private StockReservation reserveStock(String pid, int qty) { /* ... */ return null; }
private Transaction processPayment(PaymentRequest r) { /* ... */ return null; }
private void rollbackStock(String pid, int qty) { /* ... */ }
public void shutdown() throws InterruptedException {
executor.shutdown();
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
}
}package com.techsustainify.ratelimit;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
/**
* Token-bucket rate limiter β limits concurrent API calls per client.
* Uses Semaphore to enforce maximum concurrent requests.
*/
public class RateLimiter {
private final Semaphore permits;
private final int maxConcurrent;
private final long timeoutMs;
private final AtomicLong totalRequests = new AtomicLong(0);
private final AtomicLong rejectedRequests = new AtomicLong(0);
public RateLimiter(int maxConcurrent, long timeoutMs) {
this.maxConcurrent = maxConcurrent;
this.timeoutMs = timeoutMs;
this.permits = new Semaphore(maxConcurrent, true); // fair
}
public <T> T execute(Callable<T> task) throws Exception {
totalRequests.incrementAndGet();
boolean acquired = permits.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS);
if (!acquired) {
rejectedRequests.incrementAndGet();
throw new RateLimitExceededException(
"Rate limit exceeded. Max concurrent: " + maxConcurrent);
}
try {
return task.call();
} finally {
permits.release(); // ALWAYS release β even on exception
}
}
public int getAvailablePermits() { return permits.availablePermits(); }
public long getTotalRequests() { return totalRequests.get(); }
public long getRejectedRequests() { return rejectedRequests.get(); }
public double getRejectionRate() {
long total = totalRequests.get();
return total == 0 ? 0 : (double) rejectedRequests.get() / total * 100;
}
public static class RateLimitExceededException extends RuntimeException {
public RateLimitExceededException(String msg) { super(msg); }
}
public static void main(String[] args) throws Exception {
RateLimiter limiter = new RateLimiter(5, 100); // 5 concurrent, 100ms wait
ExecutorService exec = Executors.newFixedThreadPool(20);
for (int i = 0; i < 20; i++) {
final int reqId = i;
exec.submit(() -> {
try {
String result = limiter.execute(() -> {
Thread.sleep(300); // simulate API call
return "Response-" + reqId;
});
System.out.println("Success: " + result);
} catch (RateLimiter.RateLimitExceededException e) {
System.out.println("Rate limited: Request-" + reqId);
} catch (Exception e) {
e.printStackTrace();
}
});
}
exec.shutdown();
exec.awaitTermination(10, TimeUnit.SECONDS);
System.out.printf("Total: %d, Rejected: %d, Rate: %.1f%%%n",
limiter.getTotalRequests(), limiter.getRejectedRequests(),
limiter.getRejectionRate());
}
}Thread Lifecycle Flowchart
This flowchart shows the complete Java thread state machine β all states and every possible transition between them.
Code Execution Flow β from source to output
Java Multithreading Interview Questions β Beginner to Advanced
These questions are consistently asked in Java backend, system design, and senior developer interviews β covering both conceptual understanding and practical debugging skills.
Practice Questions β Test Your Multithreading Knowledge
Challenge yourself with these practice questions. Attempt each independently before reading the answer β concurrency bugs only reveal themselves when you reason carefully about interleaving.
1. What is the output of this code? Explain. class Counter { private int count = 0; public void increment() { count++; } public int getCount() { return count; } } // 10 threads each call increment() 1000 times. // What range of values can getCount() return?
Easy2. Will this program terminate? If not, why? Fix it. class Test { static boolean flag = false; public static void main(String[] args) throws Exception { new Thread(() -> { while (!flag) {} System.out.println("Thread done"); }).start(); Thread.sleep(100); flag = true; System.out.println("Main done"); } }
Easy3. Identify the deadlock scenario. What is the fix? Object lock1 = new Object(); Object lock2 = new Object(); Thread t1 = new Thread(() -> { synchronized(lock1) { Thread.sleep(50); synchronized(lock2) { System.out.println("T1 done"); } } }); Thread t2 = new Thread(() -> { synchronized(lock2) { Thread.sleep(50); synchronized(lock1) { System.out.println("T2 done"); } } });
Medium4. Implement a thread-safe Singleton using double-checked locking. Why is volatile necessary?
Medium5. Write a program that uses CountDownLatch to make the main thread wait until 5 worker threads complete their initialisation.
Medium6. Convert this blocking Future-based code to a non-blocking CompletableFuture pipeline: String userId = "U1"; Future<User> userFuture = executor.submit(() -> fetchUser(userId)); User user = userFuture.get(); // blocks! Future<Order> orderFuture = executor.submit(() -> fetchOrders(user)); Order order = orderFuture.get(); // blocks again! System.out.println(order);
Medium7. What is thread starvation? How can it happen even without deadlock? Write a scenario.
Hard8. Design a thread-safe in-memory rate limiter that allows at most N requests per second globally across multiple threads.
HardConclusion β Multithreading: The Engine Behind Every High-Performance Java Application
Multithreading is where Java's true power manifests β and where its most subtle bugs hide. Every high-performance Java application, from a REST API serving thousands of concurrent users to a trading platform processing millions of events per second, relies on carefully designed concurrency. The raw mechanisms (Thread, synchronized, wait/notify) give you full control; the high-level abstractions (ExecutorService, CompletableFuture, ConcurrentHashMap) give you safety and productivity.
The hallmark of a senior Java engineer in concurrent programming: they never create raw threads in production code, they understand memory visibility as deeply as mutual exclusion, they know when volatile is sufficient and when synchronized is necessary, they design for graceful shutdown from day one, they choose CompletableFuture pipelines over blocking Future.get(), and in Java 21 applications they reach for virtual threads first for I/O-bound concurrency.
Your next step: Java Collections Framework β where concurrency meets data structures, and you'll learn how ConcurrentHashMap, CopyOnWriteArrayList, and the full java.util.concurrent collection suite are designed internally to achieve both correctness and maximum throughput under concurrent access. β