Java's concurrency model is one of the richest in any mainstream language. Beyond the basics of synchronized and Runnable, Java provides an extensive toolkit: thread coordination primitives, work-stealing thread pools, structured concurrency, and now virtual threads that can run millions of lightweight threads on a handful of OS threads. This guide covers every essential concept with real, runnable code.
Step 1 — Thread Lifecycle in Full Detail
Every Java thread moves through exactly six states defined in Thread.State. Understanding these transitions is essential for diagnosing deadlocks and thread starvation using tools like jstack or VisualVM.
Thread.State enum values
- NEW: Thread object created but start() not yet called. No OS thread exists yet.
- RUNNABLE: Thread is executing or is ready to execute (waiting for CPU time). Includes threads blocked on I/O at the OS level — Java sees them as RUNNABLE.
- BLOCKED: Thread is waiting to acquire a monitor lock (synchronized block/method held by another thread).
- WAITING: Thread called wait(), join() with no timeout, or LockSupport.park(). Waits indefinitely for a signal.
- TIMED_WAITING: Thread called sleep(ms), wait(ms), join(ms), or LockSupport.parkNanos(). Wakes up after timeout or signal.
- TERMINATED: run() method has returned or threw an uncaught exception. Thread cannot be restarted.
public class ThreadLifecycle {
public static void main(String[] args) throws InterruptedException {
Object lock = new Object();
Thread t = new Thread(() -> {
synchronized (lock) {
try {
System.out.println("Thread: waiting...");
lock.wait(); // moves to WAITING state
System.out.println("Thread: notified, running again");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "demo-thread");
System.out.println("State after new: " + t.getState()); // NEW
t.start();
Thread.sleep(50); // let t reach wait()
System.out.println("State during wait: " + t.getState()); // WAITING
synchronized (lock) {
lock.notify();
}
t.join();
System.out.println("State after join: " + t.getState()); // TERMINATED
// --- Setting an uncaught exception handler ---
Thread worker = new Thread(() -> {
throw new RuntimeException("Oops");
});
worker.setUncaughtExceptionHandler((thread, ex) ->
System.err.println("[" + thread.getName() + "] uncaught: " + ex.getMessage())
);
worker.start();
worker.join();
// Output: [Thread-0] uncaught: Oops
}
}Step 2 — wait(), notify(), notifyAll() — Object Monitor Protocol
wait/notify is Java's lowest-level thread coordination mechanism. Every Java object has a monitor. When a thread calls wait() it releases the monitor and suspends. notify() wakes one waiting thread; notifyAll() wakes all. This is the foundation of classic producer-consumer patterns.
import java.util.LinkedList;
import java.util.Queue;
public class WaitNotifyExample {
private static final Queue<Integer> buffer = new LinkedList<>();
private static final int CAPACITY = 5;
private static final Object LOCK = new Object();
// Producer: adds items to buffer, waits when full
static class Producer implements Runnable {
@Override
public void run() {
int item = 0;
while (true) {
synchronized (LOCK) {
// RULE: always check condition in a LOOP, not an if-statement
// Reason: spurious wakeups — thread can wake without notify()
while (buffer.size() == CAPACITY) {
try {
System.out.println("Producer waiting — buffer full");
LOCK.wait(); // releases lock, suspends
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
buffer.offer(item);
System.out.println("Produced: " + item + " | buffer size: " + buffer.size());
item++;
LOCK.notifyAll(); // wake all consumers (safer than notify())
}
}
}
}
// Consumer: removes items, waits when empty
static class Consumer implements Runnable {
private final String name;
Consumer(String name) { this.name = name; }
@Override
public void run() {
while (true) {
synchronized (LOCK) {
while (buffer.isEmpty()) {
try {
System.out.println(name + " waiting — buffer empty");
LOCK.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
int item = buffer.poll();
System.out.println(name + " consumed: " + item
+ " | buffer size: " + buffer.size());
LOCK.notifyAll(); // wake producer
}
try { Thread.sleep(200); } catch (InterruptedException e) {
Thread.currentThread().interrupt(); return;
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread producer = new Thread(new Producer(), "Producer");
Thread c1 = new Thread(new Consumer("Consumer-1"), "Consumer-1");
Thread c2 = new Thread(new Consumer("Consumer-2"), "Consumer-2");
producer.start(); c1.start(); c2.start();
Thread.sleep(2000);
producer.interrupt(); c1.interrupt(); c2.interrupt();
producer.join(); c1.join(); c2.join();
}
}Step 3 — Semaphore: Controlling Concurrency Permits
A Semaphore maintains a set of permits. acquire() blocks until a permit is available; release() returns one. Use it to cap the number of threads that can access a resource simultaneously — database connections, API rate limits, file handles.
import java.util.concurrent.*;
public class SemaphoreExample {
// Simulates a connection pool limited to 3 concurrent connections
static final Semaphore permits = new Semaphore(3, true); // fair=true: FIFO queue
static void useConnection(String threadName) {
try {
System.out.println(threadName + " — waiting for permit...");
permits.acquire(); // blocks if no permits available
System.out.println(threadName + " — acquired permit. Permits left: "
+ permits.availablePermits());
// Simulate work with connection
Thread.sleep(ThreadLocalRandom.current().nextInt(500, 1500));
System.out.println(threadName + " — releasing permit");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
permits.release(); // ALWAYS in finally — even if exception thrown
}
}
public static void main(String[] args) throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int i = 1; i <= 8; i++) {
final String name = "Thread-" + i;
pool.submit(() -> useConnection(name));
}
// Only 3 threads ever hold permits simultaneously
// Other 5 queue up and wait for releases
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);
}
// tryAcquire() — non-blocking variant
static boolean tryUseConnection(String threadName) {
// Try to get a permit, but don't wait more than 500ms
try {
if (!permits.tryAcquire(500, TimeUnit.MILLISECONDS)) {
System.out.println(threadName + " — timed out waiting for permit");
return false;
}
try {
System.out.println(threadName + " — got permit immediately or within 500ms");
Thread.sleep(200);
return true;
} finally {
permits.release();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
}Step 4 — CountDownLatch: Wait for N Events
CountDownLatch is a one-time synchronization barrier. You initialize it with a count N. Threads call countDown() to decrement; await() blocks until the count reaches zero. Perfect for: waiting for N services to start, waiting for N parallel tasks to complete, or a starting gun pattern.
import java.util.concurrent.*;
public class CountDownLatchExample {
// ============================================================
// Pattern 1: Master waits for N workers to finish
// ============================================================
static void workerPoolPattern() throws InterruptedException {
int workerCount = 5;
CountDownLatch doneSignal = new CountDownLatch(workerCount);
ExecutorService pool = Executors.newFixedThreadPool(workerCount);
for (int i = 0; i < workerCount; i++) {
final int id = i;
pool.submit(() -> {
try {
System.out.println("Worker " + id + " started");
Thread.sleep(ThreadLocalRandom.current().nextInt(200, 1000));
System.out.println("Worker " + id + " done");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
doneSignal.countDown(); // decrement — ALWAYS in finally
}
});
}
System.out.println("Main thread waiting for all workers...");
doneSignal.await(); // blocks until count reaches 0
// doneSignal.await(5, TimeUnit.SECONDS); // with timeout
System.out.println("All workers done! Proceeding.");
pool.shutdown();
}
// ============================================================
// Pattern 2: Starting gun — all workers start simultaneously
// ============================================================
static void startingGunPattern() throws InterruptedException {
int workerCount = 5;
CountDownLatch startSignal = new CountDownLatch(1); // single "gun"
CountDownLatch doneSignal = new CountDownLatch(workerCount);
for (int i = 0; i < workerCount; i++) {
final int id = i;
new Thread(() -> {
try {
System.out.println("Worker " + id + " ready and waiting at start line");
startSignal.await(); // ALL workers block here until gun fires
System.out.println("Worker " + id + " GO!");
Thread.sleep(ThreadLocalRandom.current().nextInt(100, 500));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
doneSignal.countDown();
}
}).start();
}
Thread.sleep(100); // let workers reach their starting positions
System.out.println("--- FIRE ---");
startSignal.countDown(); // releases ALL waiting workers at once
doneSignal.await();
System.out.println("Race finished!");
}
public static void main(String[] args) throws InterruptedException {
System.out.println("=== Worker Pool Pattern ===");
workerPoolPattern();
System.out.println("\n=== Starting Gun Pattern ===");
startingGunPattern();
}
}Step 5 — CyclicBarrier: Reusable Rendezvous Point
CyclicBarrier is like CountDownLatch but reusable. N threads all call await() and block until ALL N have arrived. Then the barrier trips, optionally runs a barrier action, and all threads continue. The barrier resets automatically for the next round. Great for iterative parallel algorithms.
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class CyclicBarrierExample {
// Simulates a multi-phase parallel computation
// Phase 1: all workers compute partial results
// Phase 2: merge partial results (barrier action)
// Phase 3: all workers use merged result for next computation
// This cycle repeats indefinitely.
static final int WORKERS = 4;
static final int[] partialResults = new int[WORKERS];
static volatile int mergedResult = 0;
static AtomicInteger round = new AtomicInteger(1);
public static void main(String[] args) throws InterruptedException {
// Barrier action: runs once when all parties arrive, before they are released
// Runs on the LAST thread to call await()
Runnable barrierAction = () -> {
int sum = 0;
for (int r : partialResults) sum += r;
mergedResult = sum;
System.out.println(" [Barrier Action] Round " + round.getAndIncrement()
+ " merged result: " + mergedResult);
};
CyclicBarrier barrier = new CyclicBarrier(WORKERS, barrierAction);
ExecutorService pool = Executors.newFixedThreadPool(WORKERS);
for (int i = 0; i < WORKERS; i++) {
final int id = i;
pool.submit(() -> {
for (int phase = 0; phase < 3; phase++) { // 3 phases
try {
// Phase computation: each worker computes its partial result
int partial = (id + 1) * 10 * (phase + 1);
partialResults[id] = partial;
System.out.println("Worker-" + id + " phase " + phase
+ " computed: " + partial);
// Wait until ALL workers reach this point
barrier.await(); // blocks here — CyclicBarrier.await() not CountDownLatch.await()
// Now all workers have computed; mergedResult is ready
System.out.println("Worker-" + id + " phase " + phase
+ " sees merged: " + mergedResult);
barrier.await(); // second barrier: sync after reading merged result
} catch (InterruptedException | BrokenBarrierException e) {
Thread.currentThread().interrupt();
return;
}
}
});
}
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);
}
}Step 6 — Phaser: Flexible Multi-Phase Synchronization
Phaser is the most flexible synchronization barrier. Unlike CyclicBarrier (fixed party count), Phaser allows dynamic registration and deregistration of parties. Threads can join or leave the phaser at any time. It also supports phase-specific termination and hierarchical phasers for scalability.
import java.util.concurrent.*;
public class PhaserExample {
public static void main(String[] args) {
// ============================================================
// Basic phaser: 3 workers, 3 phases
// ============================================================
Phaser phaser = new Phaser(1); // register main thread (party count = 1)
for (int i = 0; i < 3; i++) {
final int id = i;
phaser.register(); // register each worker (party count increases)
new Thread(() -> {
for (int phase = 0; phase < 3; phase++) {
System.out.println("Worker-" + id + " doing phase " + phase + " work");
try { Thread.sleep(50 * (id + 1)); } // simulate work
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
// arriveAndAwaitAdvance(): arrive + wait for all others
// Returns the NEXT phase number
int nextPhase = phaser.arriveAndAwaitAdvance();
System.out.println("Worker-" + id + " advancing to phase " + nextPhase);
}
phaser.arriveAndDeregister(); // done — remove from party count
}).start();
}
// Main thread participates in phase 0 then watches
phaser.arriveAndAwaitAdvance(); // wait for phase 0 to complete
System.out.println("Main: all workers completed phase 0");
phaser.arriveAndDeregister(); // main is done
// ============================================================
// Terminating phaser: override onAdvance() to terminate after N phases
// ============================================================
int totalPhases = 3;
Phaser terminatingPhaser = new Phaser(1) {
@Override
protected boolean onAdvance(int phase, int registeredParties) {
System.out.println(" Phase " + phase + " complete. Parties: " + registeredParties);
return phase >= totalPhases - 1; // return true to TERMINATE after 3 phases
}
};
for (int i = 0; i < 2; i++) {
final int id = i;
terminatingPhaser.register();
new Thread(() -> {
// arriveAndAwaitAdvance() returns negative phase number when terminated
while (!terminatingPhaser.isTerminated()) {
System.out.println("Worker-" + id + " phase " + terminatingPhaser.getPhase());
try { Thread.sleep(100); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); break; }
terminatingPhaser.arriveAndAwaitAdvance();
}
System.out.println("Worker-" + id + " done (phaser terminated)");
}).start();
}
while (!terminatingPhaser.isTerminated()) {
terminatingPhaser.arriveAndAwaitAdvance();
}
System.out.println("Phaser terminated after " + totalPhases + " phases");
}
}Step 7 — StampedLock: Optimistic Read Locking
StampedLock (Java 8+) is a more capable alternative to ReadWriteLock. Its key feature is optimistic reads: you read without acquiring any lock, then validate whether a write happened. If no write occurred, you save the overhead of locking entirely. Optimistic reads work best when reads dominate and contention is low.
import java.util.concurrent.locks.StampedLock;
public class StampedLockExample {
private double x = 0.0;
private double y = 0.0;
private final StampedLock lock = new StampedLock();
// Write: acquires exclusive write lock
public void move(double dx, double dy) {
long stamp = lock.writeLock(); // blocks until no readers or writers
try {
x += dx;
y += dy;
} finally {
lock.unlockWrite(stamp); // stamp must match
}
}
// Optimistic read: try reading without a lock
// FASTEST path — no lock acquisition at all
public double distanceFromOrigin() {
// Step 1: try optimistic read
long stamp = lock.tryOptimisticRead();
double currentX = x;
double currentY = y;
// Step 2: validate — did a write happen between tryOptimisticRead() and now?
if (!lock.validate(stamp)) {
// A write occurred — fall back to regular read lock
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
// Step 3: use the consistently read values
return Math.hypot(currentX, currentY);
}
// Read with upgrade: start as read, upgrade to write if needed
public void moveIfAtOrigin(double newX, double newY) {
long stamp = lock.readLock(); // start with read
try {
while (x == 0.0 && y == 0.0) {
// Try to upgrade to write lock without releasing read lock
long writeStamp = lock.tryConvertToWriteLock(stamp);
if (writeStamp != 0L) {
stamp = writeStamp; // successfully upgraded
x = newX;
y = newY;
break;
} else {
// Upgrade failed: release read, get write, re-check condition
lock.unlockRead(stamp);
stamp = lock.writeLock();
}
}
} finally {
lock.unlock(stamp); // works for both read and write stamps
}
}
public static void main(String[] args) throws InterruptedException {
StampedLockExample point = new StampedLockExample();
Thread writer = new Thread(() -> {
for (int i = 0; i < 100; i++) {
point.move(1.0, 1.0);
try { Thread.sleep(10); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
});
Thread reader = new Thread(() -> {
for (int i = 0; i < 200; i++) {
double dist = point.distanceFromOrigin();
try { Thread.sleep(5); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
});
writer.start(); reader.start();
writer.join(); reader.join();
System.out.println("Final position: (" + point.x + ", " + point.y + ")");
}
}Step 8 — ThreadLocal: Per-Thread Data Storage
ThreadLocal gives each thread its own independent copy of a variable. No synchronization needed — each thread reads and writes its own isolated copy. Used widely in frameworks: Spring's request context, Hibernate's session, JDBC connections, user authentication context per request.
public class ThreadLocalExample {
// Each thread gets its own independent Integer value
// withInitial() sets the initial value when a thread first accesses it
private static final ThreadLocal<Integer> requestId =
ThreadLocal.withInitial(() -> -1);
// Simulates a per-request user context stored in ThreadLocal
static class UserContext {
final String username;
final String role;
UserContext(String username, String role) {
this.username = username;
this.role = role;
}
@Override public String toString() {
return username + "(" + role + ")";
}
}
static final ThreadLocal<UserContext> currentUser = new ThreadLocal<>();
// Simulates what a web framework does per HTTP request
static void handleRequest(int id, String username, String role) {
requestId.set(id); // store in this thread's slot
currentUser.set(new UserContext(username, role));
try {
processRequest(); // deep call chain — no need to pass context as params
} finally {
// CRITICAL: always remove in finally when using thread pools!
// Thread pool reuses threads — without remove(), old data leaks into next request
requestId.remove();
currentUser.remove();
}
}
static void processRequest() {
// Access ThreadLocal anywhere in the call stack — no parameter passing
System.out.println("[Request-" + requestId.get() + "] Processing for "
+ currentUser.get() + " on thread " + Thread.currentThread().getName());
checkPermission();
}
static void checkPermission() {
UserContext ctx = currentUser.get();
if (!".admin".equals(ctx.role)) {
System.out.println("[Request-" + requestId.get() + "] Permission denied for " + ctx.username);
}
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[4];
String[][] requests = {
{"alice", "admin"},
{"bob", "user"},
{"charlie", "admin"},
{"dave", "user"}
};
for (int i = 0; i < 4; i++) {
final int id = i + 1;
final String username = requests[i][0];
final String role = requests[i][1];
threads[i] = new Thread(() -> handleRequest(id, username, role), "worker-" + id);
threads[i].start();
}
for (Thread t : threads) t.join();
// InheritableThreadLocal: child thread inherits parent's value at creation time
InheritableThreadLocal<String> inherited = new InheritableThreadLocal<>();
inherited.set("parent-value");
Thread child = new Thread(() -> {
System.out.println("Child sees: " + inherited.get()); // parent-value
inherited.set("child-override"); // child can override without affecting parent
});
child.start();
child.join();
System.out.println("Parent still sees: " + inherited.get()); // parent-value
}
}Step 9 — Fork/Join Framework: Divide and Conquer
The Fork/Join framework (java.util.concurrent.ForkJoinPool) is designed for divide-and-conquer algorithms. A task splits itself into smaller subtasks (fork), processes them in parallel, then combines results (join). The work-stealing algorithm lets idle threads steal tasks from busy threads' queues, maximizing CPU utilization.
import java.util.concurrent.*;
public class ForkJoinExample {
// ============================================================
// RecursiveTask<V>: task that returns a result
// ============================================================
static class MergeSort extends RecursiveTask<int[]> {
private final int[] array;
private final int THRESHOLD = 100; // arrays smaller than this are sorted sequentially
MergeSort(int[] array) { this.array = array; }
@Override
protected int[] compute() {
if (array.length <= THRESHOLD) {
// Base case: small enough — sort directly
int[] copy = array.clone();
java.util.Arrays.sort(copy);
return copy;
}
// Split into two halves
int mid = array.length / 2;
int[] left = java.util.Arrays.copyOfRange(array, 0, mid);
int[] right = java.util.Arrays.copyOfRange(array, mid, array.length);
// Fork: schedule left half as an independent subtask
MergeSort leftTask = new MergeSort(left);
MergeSort rightTask = new MergeSort(right);
leftTask.fork(); // submit left to pool asynchronously
int[] sortedRight = rightTask.compute(); // compute right on THIS thread
int[] sortedLeft = leftTask.join(); // wait for left result
// Merge the two sorted halves
return merge(sortedLeft, sortedRight);
}
private int[] merge(int[] a, int[] b) {
int[] result = new int[a.length + b.length];
int i = 0, j = 0, k = 0;
while (i < a.length && j < b.length) {
result[k++] = (a[i] <= b[j]) ? a[i++] : b[j++];
}
while (i < a.length) result[k++] = a[i++];
while (j < b.length) result[k++] = b[j++];
return result;
}
}
// ============================================================
// RecursiveAction: task with no return value
// ============================================================
static class ParallelFill extends RecursiveAction {
private final long[] array;
private final int start;
private final int end;
private static final int THRESHOLD = 10_000;
ParallelFill(long[] array, int start, int end) {
this.array = array; this.start = start; this.end = end;
}
@Override
protected void compute() {
if (end - start <= THRESHOLD) {
// Base case: fill this chunk directly
for (int i = start; i < end; i++) {
array[i] = (long) i * i; // i-squared
}
return;
}
int mid = (start + end) / 2;
ParallelFill left = new ParallelFill(array, start, mid);
ParallelFill right = new ParallelFill(array, mid, end);
invokeAll(left, right); // fork BOTH and wait for both to complete
}
}
public static void main(String[] args) {
ForkJoinPool pool = ForkJoinPool.commonPool(); // shared pool (parallelism = CPU cores - 1)
// Or: new ForkJoinPool(4) for custom parallelism level
// Test MergeSort
int[] data = new java.util.Random().ints(1_000_000, 0, 1_000_000).toArray();
long start = System.currentTimeMillis();
int[] sorted = pool.invoke(new MergeSort(data));
System.out.println("Sorted 1M elements in " + (System.currentTimeMillis() - start) + "ms");
System.out.println("First 5: " + java.util.Arrays.toString(
java.util.Arrays.copyOfRange(sorted, 0, 5)));
// Test ParallelFill
long[] squares = new long[1_000_000];
pool.invoke(new ParallelFill(squares, 0, squares.length));
System.out.println("squares[999] = " + squares[999]); // 998001
// Also: parallel streams use ForkJoinPool.commonPool() internally
long sum = java.util.stream.LongStream
.rangeClosed(1, 1_000_000L)
.parallel() // uses ForkJoinPool under the hood
.sum();
System.out.println("Sum 1..1M = " + sum); // 500000500000
}
}Step 10 — Virtual Threads (Project Loom, Java 21)
Virtual threads (JEP 444, stable in Java 21) are lightweight threads managed by the JVM, not the OS. Creating a million virtual threads is practical because each one uses only a few hundred bytes of heap rather than the ~1MB stack OS threads need. Virtual threads are designed for I/O-bound workloads — they unmount from their carrier (OS) thread while blocked on I/O, freeing the carrier for other work.
import java.util.concurrent.*;
import java.net.http.*;
import java.net.URI;
import java.time.Duration;
public class VirtualThreads {
public static void main(String[] args) throws Exception {
// ============================================================
// Creating virtual threads — 3 ways
// ============================================================
// Way 1: Thread.ofVirtual()
Thread vt = Thread.ofVirtual()
.name("my-virtual-thread")
.start(() -> System.out.println("Running on virtual thread: "
+ Thread.currentThread().isVirtual())); // true
vt.join();
// Way 2: Executors.newVirtualThreadPerTaskExecutor()
// Creates a new virtual thread for EACH task submitted
// No pool — virtual threads are so cheap you don't need to pool them
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
final int id = i;
executor.submit(() -> {
// Each of these 10,000 tasks gets its own virtual thread
// Blocked I/O here unmounts the virtual thread from its carrier OS thread
try { Thread.sleep(Duration.ofMillis(100)); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
// System.out.println("Task " + id + " done");
});
}
} // executor.close() waits for all tasks — try-with-resources works here
System.out.println("10,000 virtual thread tasks done");
// Way 3: Thread.startVirtualThread() — quick one-liner
Thread.startVirtualThread(() -> System.out.println("Quick virtual thread")).join();
// ============================================================
// Key properties of virtual threads
// ============================================================
Thread vt2 = Thread.ofVirtual().unstarted(() -> {});
System.out.println("Is virtual: " + vt2.isVirtual()); // true
System.out.println("Is daemon: " + vt2.isDaemon()); // always true — cannot be changed
System.out.println("Priority: " + vt2.getPriority()); // always NORM_PRIORITY=5
// ============================================================
// Real use case: 1000 parallel HTTP calls (I/O-bound)
// With platform threads: need a large thread pool (expensive memory)
// With virtual threads: one per task, JVM manages carrier threads
// ============================================================
HttpClient client = HttpClient.newBuilder()
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = new java.util.ArrayList<Future<String>>();
for (int i = 0; i < 5; i++) { // 5 for demo — works for thousands
futures.add(exec.submit(() -> {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://httpbin.org/delay/1"))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
return "Status: " + response.statusCode();
}));
}
for (var f : futures) {
System.out.println(f.get());
}
}
// ============================================================
// What NOT to do with virtual threads
// ============================================================
// 1. Don't pool virtual threads — they are already cheap. Pooling defeats the purpose.
// BAD: Executors.newFixedThreadPool(200) for I/O tasks
// GOOD: Executors.newVirtualThreadPerTaskExecutor()
// 2. Don't hold monitors (synchronized) during blocking I/O
// synchronized blocks "pin" the virtual thread to its carrier OS thread,
// preventing the carrier from being reused by other virtual threads.
// Use ReentrantLock instead of synchronized when doing I/O inside a critical section.
// 3. Virtual threads are not faster for CPU-bound work.
// For pure computation, platform threads + ForkJoinPool is still better.
}
}Step 11 — Deadlock: Detection and Prevention
A deadlock occurs when two or more threads are each waiting for a lock held by another, forming a circular dependency. The JVM does not automatically detect or break deadlocks. They freeze affected threads permanently. jstack or jcmd can detect them in a running process.
import java.util.concurrent.locks.ReentrantLock;
public class DeadlockDemo {
// ============================================================
// Classic deadlock with synchronized
// ============================================================
static final Object lockA = new Object();
static final Object lockB = new Object();
static void thread1Work() {
synchronized (lockA) {
System.out.println("T1: acquired lockA, waiting for lockB...");
try { Thread.sleep(50); } catch (InterruptedException e) { return; }
synchronized (lockB) { // DEADLOCK: T2 holds lockB waiting for lockA
System.out.println("T1: acquired both locks"); // never reached
}
}
}
static void thread2Work() {
synchronized (lockB) {
System.out.println("T2: acquired lockB, waiting for lockA...");
try { Thread.sleep(50); } catch (InterruptedException e) { return; }
synchronized (lockA) { // DEADLOCK
System.out.println("T2: acquired both locks"); // never reached
}
}
}
// ============================================================
// FIX 1: Lock ordering — always acquire locks in the same order
// ============================================================
static void safeThread1() {
synchronized (lockA) { // both threads acquire A first, then B
synchronized (lockB) {
System.out.println("Safe T1: done");
}
}
}
static void safeThread2() {
synchronized (lockA) { // same order as thread1 — no circular dependency
synchronized (lockB) {
System.out.println("Safe T2: done");
}
}
}
// ============================================================
// FIX 2: tryLock() with timeout — give up if can't acquire
// ============================================================
static final ReentrantLock rlA = new ReentrantLock();
static final ReentrantLock rlB = new ReentrantLock();
static void tryLockThread(String name, ReentrantLock first, ReentrantLock second)
throws InterruptedException {
while (true) {
boolean gotFirst = first.tryLock();
boolean gotSecond = false;
try {
if (gotFirst) {
gotSecond = second.tryLock();
}
if (gotFirst && gotSecond) {
System.out.println(name + ": acquired both locks — doing work");
Thread.sleep(10); // do work
return; // success
}
} finally {
if (gotSecond) second.unlock();
if (gotFirst) first.unlock();
}
// Failed to get both: back off and retry after random delay
System.out.println(name + ": failed to get both locks, retrying...");
Thread.sleep((long)(Math.random() * 10));
}
}
// ============================================================
// Detecting deadlocks at runtime: use jstack or ThreadMXBean
// ============================================================
static void detectDeadlock() {
java.lang.management.ThreadMXBean tmx =
java.lang.management.ManagementFactory.getThreadMXBean();
long[] deadlockedIds = tmx.findDeadlockedThreads(); // null if none
if (deadlockedIds != null) {
java.lang.management.ThreadInfo[] info =
tmx.getThreadInfo(deadlockedIds, true, true);
for (java.lang.management.ThreadInfo ti : info) {
System.err.println("DEADLOCK DETECTED: " + ti.getThreadName());
System.err.println("Waiting for: " + ti.getLockName());
System.err.println("Held by: " + ti.getLockOwnerName());
}
} else {
System.out.println("No deadlocks detected");
}
}
public static void main(String[] args) throws InterruptedException {
// Demo fix 2
Thread t1 = new Thread(() -> {
try { tryLockThread("T1", rlA, rlB); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
Thread t2 = new Thread(() -> {
try { tryLockThread("T2", rlB, rlA); } // opposite order — but tryLock saves us
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("Both threads completed without deadlock");
// Check for deadlocks
detectDeadlock();
}
}Step 12 — Exchanger and SynchronousQueue
Exchanger allows two threads to swap objects at a synchronization point. SynchronousQueue is a zero-capacity queue where each put() must be paired with a matching take() — neither thread continues until both have arrived. Both are for direct handoff between exactly two threads.
import java.util.concurrent.*;
import java.util.ArrayList;
import java.util.List;
public class ExchangerExample {
// ============================================================
// Exchanger: two threads meet and swap objects
// Classic use: ping-pong buffers — one thread fills while other drains
// ============================================================
public static void main(String[] args) throws InterruptedException {
Exchanger<List<Integer>> exchanger = new Exchanger<>();
// Producer: fills a buffer then exchanges for an empty one
Thread producer = new Thread(() -> {
List<Integer> current = new ArrayList<>();
try {
for (int i = 0; i < 20; i++) {
current.add(i);
if (current.size() == 5) {
System.out.println("Producer: filled buffer " + current + ", exchanging...");
current = exchanger.exchange(current); // swap full buffer for empty
current.clear(); // reuse the returned (now empty) buffer
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Producer");
// Consumer: drains a buffer then exchanges for a full one
Thread consumer = new Thread(() -> {
List<Integer> current = new ArrayList<>();
try {
for (int round = 0; round < 4; round++) {
current = exchanger.exchange(current); // swap empty buffer for full
System.out.println("Consumer: got buffer " + current + ", processing...");
Thread.sleep(50); // simulate processing
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Consumer");
producer.start();
consumer.start();
producer.join();
consumer.join();
// ============================================================
// SynchronousQueue: direct handoff, zero buffering
// ============================================================
SynchronousQueue<String> sq = new SynchronousQueue<>();
Thread sender = new Thread(() -> {
try {
System.out.println("Sender: ready to hand off");
sq.put("Hello from sender"); // blocks until receiver calls take()
System.out.println("Sender: handoff complete");
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
Thread receiver = new Thread(() -> {
try {
Thread.sleep(200); // arrive 200ms later
String msg = sq.take(); // blocks until sender calls put()
System.out.println("Receiver got: " + msg);
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
sender.start();
receiver.start();
sender.join();
receiver.join();
}
}Step 13 — Best Practices and Common Pitfalls Summary
Rules for Writing Correct Concurrent Java Code
- Minimize shared mutable state. The safest concurrent code is code that shares nothing. Use local variables, method parameters, and immutable objects wherever possible.
- Always restore the interrupt flag. When catching InterruptedException, call Thread.currentThread().interrupt() before returning. Never swallow it silently with an empty catch block.
- Prefer higher-level abstractions. Use ExecutorService over raw Thread, use concurrent collections over synchronized wrappers, use CompletableFuture over wait/notify.
- Always unlock in finally. Any lock.lock() call must have a matching lock.unlock() in a finally block. Exceptions must not leave locks held.
- Always wait() in a loop. Spurious wakeups are real — always re-check the condition in a while loop, never an if statement.
- Use AtomicXxx for single-variable counters. AtomicInteger/AtomicLong are faster than synchronized for simple increment/decrement operations.
- Don't call thread.stop() or thread.suspend(). Both are deprecated and unsafe. Use a volatile boolean flag or interrupt() for cooperative cancellation.
- Always shutdown ExecutorServices. Failing to call shutdown() keeps the JVM alive. Use try-with-resources with AutoCloseable executors (Java 19+) or shutdown/awaitTermination in finally.
- Remove ThreadLocals in thread pools. Always call threadLocal.remove() after each task in a finally block to prevent data leaking across requests.
- Use synchronized or locks, not volatile, for compound operations. volatile guarantees visibility but not atomicity. count++ on a volatile is still a race condition.