Java Threading Masterclass
Build 25+ Java programs exploring every core concurrency concept.
Introduction
30 Second Summary
Every app you use juggles dozens of tasks at once. Your browser downloads images, renders text, and plays video all at the same time. Behind the scenes, threads make this possible.
In this project, you will build a comprehensive threading study lab in Java. You will write 25+ runnable programs that make invisible concurrency behavior visible, then prove your mastery by passing a rigorous final exam.
What You'll Build
A single IntelliJ IDEA project containing organized packages of Java programs that demonstrate every major concurrency concept, from OS-level thread scheduling to lock-free atomic operations.
By the end of this project, you'll have:
- A collection of 25+ runnable Java programs covering thread creation, synchronization, the Java Memory Model, explicit locks, executors, atomic variables, and synchronizers.
- Deep understanding of thread safety problems including deliberate programs that produce deadlocks, livelocks, and starvation so you can recognize and prevent them.
- A passing grade on a comprehensive final exam covering concurrency theory, bug identification in code snippets, and system design challenges.
- Secret Mission: benchmarked three different thread-safe bounded buffer implementations against each other to see which synchronization approach wins under real contention.
Are there any prerequisites?
You need a solid understanding of core Java including classes, interfaces, lambdas, and exceptions. Familiarity with basic OOP concepts is also expected.
You should already have JDK 21 and IntelliJ IDEA installed on your machine. No additional tools are required.
Set Up the Project
Before diving into threading theory and code, you need a well-organized workspace. This project will contain dozens of small Java programs across many packages, so setting up a clean structure from the start saves you headaches later.
In this step, you will create a new IntelliJ IDEA project configured for JDK 21 and build out the package structure that organizes each major concurrency topic into its own namespace.
In this step, get ready to:
- Create a new Java project in IntelliJ IDEA called java-threading-masterclass.
- Configure the project to use JDK 21 with language level 21.
- Create 9 packages to organize your concurrency programs by topic.
Create a new Java project
IntelliJ's new project wizard lets you set the project name, location, and JDK in a single dialog.
- In IntelliJ IDEA, click File in the top menu bar.
- Click New, then Project.
- In the Name field, enter java-threading-masterclass.
- In the Language dropdown, select Java.
- In the Build system dropdown, select IntelliJ.
- In the JDK dropdown, select your JDK 21 installation.
- Click Create.
Don't see JDK 21 in the dropdown?
Click the JDK dropdown, then select Download JDK. Choose version 21 from any vendor (Oracle or Eclipse Temurin both work) and IntelliJ will download and configure it for you.
IntelliJ creates your project and opens it. You should see a src folder in the Project panel on the left side.
Verify the language level is set to 21
The language level controls which Java language features are available in your code. Setting it to 21 ensures you can use all modern Java syntax.
- Click File in the top menu bar.
- Click Project Structure (or press Ctrl+Alt+Shift+S).
- In the left sidebar, click Project.
- Confirm the SDK field shows your JDK 21.
- Confirm the Language level dropdown is set to 21. If it shows a different value, change it to 21 - Record patterns, pattern matching for switch.
- Click OK to close the dialog.
Create the package structure
Each package will hold programs related to a specific concurrency topic. This keeps your study lab organized as you add programs across many steps.
- In the Project panel on the left, right-click on the src folder.
- Select New, then Package.
- Type multithreading and press Enter.
Repeat the same process (right-click src > New > Package) to create each of the remaining 8 packages:
- fundamentals
- synchronization
- communication
- problems
- memorymodel
- locks
- executors
- atomic
What do these packages represent?
Each package maps to a major topic in Java concurrency. As you progress through the project, you will fill each package with small, runnable programs that demonstrate specific concepts.
- multithreading covers processes, threads, and parallelism theory.
- fundamentals covers thread creation, lifecycle, and daemon threads.
- synchronization covers monitors, race conditions, and synchronized blocks.
- communication covers wait/notify and producer-consumer patterns.
- problems covers deadlock, livelock, and starvation scenarios.
- memorymodel covers the Java Memory Model, visibility, and volatile.
- locks covers ReentrantLock, ReadWriteLock, and Condition variables.
- executors covers thread pools, Future, and CompletableFuture.
- atomic covers CAS operations and concurrent collections.
When you are done, your Project panel should show all 9 packages listed under src.
Packages not appearing under src?
Make sure you are right-clicking on the src folder itself, not on the project root. If you accidentally created a package at the project root level, delete it and recreate it inside src.
Also check that src is marked as a Sources Root (it should have a blue icon). If it shows a regular folder icon, right-click it and select Mark Directory as > Sources Root.
Still stuck? Help me troubleshoot my IntelliJ package structure.
Your workspace is ready. In the next step, you will start filling the multithreading package with programs that explore how processes, threads, and parallelism work at the hardware and OS level.
Explore Processes, Threads, and Parallelism
Your project structure is ready. Now it is time to fill it with knowledge and code that makes invisible threading behavior visible.
Before you can write correct concurrent code, you need to understand what happens beneath it. In this step, you will study how your operating system manages processes and threads, what parallelism actually means at the hardware level, and why multithreading introduces problems that single-threaded code never faces.
In this step, get ready to:
- Study the theory of processes, threads, concurrency, parallelism, and OS scheduling.
- Write programs that observe multi-core speedup and non-deterministic thread interleaving.
- Measure thread creation overhead and demonstrate shared memory dangers.
Study the foundations of processes, threads, and parallelism
Every program you run on your computer is a process. Every process can contain one or more threads of execution.
What is a process?
A process is an independent executing program with its own memory space. This memory includes a heap, stack, code segment, and data segment.
Processes are isolated from each other by the operating system. If one process crashes, it does not bring down other processes. Communication between processes (called IPC) is expensive because it requires pipes, sockets, or shared memory regions managed by the OS.
What is a thread?
A thread is a lightweight unit of execution within a process. Each thread has its own stack, program counter, and register set, but all threads in the same process share the heap memory and code segment.
Thread creation and context switching is much cheaper than process creation because threads share memory. This shared memory is both the power and the danger of multithreading.
Why does multithreading exist?
There are two fundamental motivations. The first is responsiveness: keeping a UI or server responsive while doing background work. A web server can handle multiple requests without blocking because each request runs on its own thread.
The second is performance: exploiting multiple CPU cores to perform computations faster. If your machine has 8 cores, you can theoretically do 8 things at once.
What is the difference between concurrency and parallelism?
Concurrency means managing multiple tasks that can make progress within overlapping time periods. On a single-core machine, concurrency is achieved by time-slicing. The tasks interleave but are not literally simultaneous.
Parallelism means literally executing multiple tasks at the same instant on multiple CPU cores. Parallelism requires multi-core hardware. A concurrent program on 1 core interleaves tasks. The same concurrent program on 4 cores may run tasks in parallel.
Parallelism is a subset of concurrency. A concurrent program may or may not run in parallel depending on your hardware.
How does the OS thread scheduler work?
Modern operating systems use preemptive scheduling. The OS decides when to suspend one thread and run another. Threads do not voluntarily give up the CPU unless they sleep, wait, or block on I/O.
Each thread gets a time quantum (typically 1-20ms). When the quantum expires, the scheduler saves the thread's state (registers, program counter, stack pointer) and loads another thread's state. This is called a context switch and it costs microseconds each time.
The thread scheduler is non-deterministic. You cannot predict the order in which threads will execute. This is the root cause of most concurrency bugs.
How do Java threads map to OS threads?
In the HotSpot JVM (the standard JDK implementation), each Java thread is backed by a native OS thread. This is a 1:1 mapping, also called "platform threads."
Thread creation involves a system call to the OS, allocation of a thread stack (default ~1MB on 64-bit systems), and registration with the scheduler. This is why creating thousands of threads is expensive and why thread pools exist.
What is the multi-core architecture you are working with?
Modern CPUs have multiple cores, each capable of executing instructions independently. Each core has its own L1/L2 cache, and there is usually a shared L3 cache.
The cache coherence protocol (e.g., MESI) keeps caches consistent across cores, but this has a performance cost. This hardware reality is why visibility problems exist in Java. Threads on different cores may see stale cached values of the same variable.
Your first program will check how many cores your machine exposes to the JVM.
- Right-click the multithreading package in the Project panel on the left.
- Select New then Java Class.
- Name it ProcessorCount and press Enter.
- Replace the generated content with the following code:
package multithreading;
public class ProcessorCount {
public static void main(String[] args) {
// Query the JVM for the number of available processor cores
int cores = Runtime.getRuntime().availableProcessors();
System.out.println("Available processor cores: " + cores);
System.out.println("This is how many threads can truly run in parallel on your machine.");
}
}
What does this code do?
Runtime.getRuntime().availableProcessors() returns the number of processor cores the JVM can use. This number is your upper limit for true parallelism. If it returns 8, at most 8 threads can execute instructions at the exact same instant.
- Run the program by clicking the green play icon next to main in the gutter, then selecting Run 'ProcessorCount.main()'.
- Check the Run panel at the bottom. You should see a number like Available processor cores: 8 (your number will depend on your machine).
Remember this number. It determines the maximum speedup you can achieve through parallelism on your specific hardware.
Observe parallelism and non-deterministic scheduling
Knowing your core count is the starting point. The next question is: how much faster does a program actually get when you add threads?
What is Amdahl's Law?
Amdahl's Law defines the maximum speedup from parallelism: Speedup = 1 / (S + (1-S)/N), where S is the fraction of the program that must run sequentially and N is the number of cores.
If 25% of your program is sequential, the maximum speedup is 4x regardless of how many cores you add. This is why reducing lock contention and minimizing the synchronized portion of your code matters enormously.
Your next program demonstrates Amdahl's Law in action by splitting CPU-bound work across threads.
- Create a new Java class in the multithreading package named SpeedupDemo.
- Replace the generated content with the following code:
package multithreading;
public class SpeedupDemo {
// Size of the array to sum (100 million elements)
private static final int ARRAY_SIZE = 100_000_000;
public static void main(String[] args) throws InterruptedException {
// Create and fill a large array with values
long[] numbers = new long[ARRAY_SIZE];
for (int i = 0; i < ARRAY_SIZE; i++) {
numbers[i] = i + 1;
}
// Single-threaded sum: one thread does all the work
long startTime = System.nanoTime();
long singleThreadSum = 0;
for (long num : numbers) {
singleThreadSum += num;
}
long singleThreadTime = System.nanoTime() - startTime;
System.out.println("Single-threaded sum: " + singleThreadSum);
System.out.println("Single-threaded time: " + singleThreadTime / 1_000_000 + " ms");
// Multi-threaded sum: split the work across available cores
int cores = Runtime.getRuntime().availableProcessors();
long[] partialSums = new long[cores];
Thread[] threads = new Thread[cores];
int chunkSize = ARRAY_SIZE / cores;
startTime = System.nanoTime();
for (int i = 0; i < cores; i++) {
final int threadIndex = i;
final int start = i * chunkSize;
final int end = (i == cores - 1) ? ARRAY_SIZE : start + chunkSize;
threads[i] = new Thread(() -> {
// Each thread sums its own chunk of the array
long sum = 0;
for (int j = start; j < end; j++) {
sum += numbers[j];
}
partialSums[threadIndex] = sum;
});
threads[i].start();
}
// Wait for all threads to finish
for (Thread thread : threads) {
thread.join();
}
// Combine partial results from all threads
long multiThreadSum = 0;
for (long partial : partialSums) {
multiThreadSum += partial;
}
long multiThreadTime = System.nanoTime() - startTime;
System.out.println("\nMulti-threaded sum (" + cores + " threads): " + multiThreadSum);
System.out.println("Multi-threaded time: " + multiThreadTime / 1_000_000 + " ms");
System.out.printf("Speedup: %.2fx%n", (double) singleThreadTime / multiThreadTime);
}
}
What does this code do?
This program sums 100 million numbers two ways. First, a single thread does all the work. Then the array is split into chunks equal to your core count, and each thread sums its own chunk independently.
The join() calls ensure the main thread waits until all worker threads have finished before combining the partial results. The speedup you see is Amdahl's Law in action. It will not be a perfect Nx improvement because thread creation, coordination, and memory access add overhead.
- Run SpeedupDemo using the green play icon.
- You should see both sums match and a speedup factor printed (typically 2x-6x depending on your core count).
Seeing a very low speedup (close to 1x)?
On some machines, the JIT compiler optimizes the single-threaded loop so aggressively that parallelism barely helps at the default array size. Try increasing ARRAY_SIZE to 500 million, or run the program multiple times to let the JVM warm up.
Help me understand why my multi-threaded speedup is lower than expected.
Now you will observe the non-deterministic nature of thread scheduling. This is the single most important thing to internalize about multithreading.
- Create a new Java class in the multithreading package named InterleavingDemo.
- Replace the generated content with the following code:
package multithreading;
public class InterleavingDemo {
public static void main(String[] args) {
// Create 5 threads that each print numbers 1 through 10
for (int i = 1; i <= 5; i++) {
final int threadNumber = i;
Thread thread = new Thread(() -> {
for (int num = 1; num <= 10; num++) {
// Print the thread identifier and its current number
System.out.println("Thread-" + threadNumber + ": " + num);
}
});
thread.start();
}
// This line may print before, during, or after the thread output
System.out.println("Main thread finished starting all threads.");
}
}
What does this code do?
Five threads are created, each printing numbers 1 through 10 with its own thread identifier. Because the OS scheduler decides which thread runs when, the output order is different every time you run the program.
The main thread's final print statement may appear at the beginning, middle, or end of the output. This proves that you cannot rely on execution order without explicit synchronization.
- Run InterleavingDemo three or four times in a row.
- Compare the output across runs. You should see the thread messages appear in a different order each time.
Run it repeatedly to see the chaos
Sometimes the output looks orderly by coincidence (especially on fast machines where each thread finishes its 10 prints before the scheduler preempts it). Run it 5+ times and you will eventually see heavy interleaving. This randomness is exactly what makes multithreaded bugs so hard to reproduce.
Measure thread creation costs and shared memory risks
You now know that threads run in parallel and that the scheduler is unpredictable. The next question is: how expensive is creating threads, and what goes wrong when they share memory?
Why not just create a thread for every task?
The simplest multithreading model is "thread-per-task": create one thread for every unit of work. This does not scale. Each thread consumes ~1MB of stack memory. 10,000 threads means ~10GB of stack space alone, plus OS scheduling overhead.
The alternative is a thread pool: a fixed set of threads that processes many tasks from a queue. Tasks wait in the queue until a thread is free. This controls resource usage and avoids the cost of creating and destroying threads.
- Create a new Java class in the multithreading package named ThreadCreationOverhead.
- Replace the generated content with the following code:
package multithreading;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ThreadCreationOverhead {
private static final int TASK_COUNT = 10_000;
public static void main(String[] args) throws InterruptedException {
// Approach 1: Create 10,000 raw threads
long startTime = System.nanoTime();
Thread[] threads = new Thread[TASK_COUNT];
for (int i = 0; i < TASK_COUNT; i++) {
threads[i] = new Thread(() -> {
// Minimal work to isolate creation overhead
int x = 1 + 1;
});
threads[i].start();
}
// Wait for all threads to complete
for (Thread t : threads) {
t.join();
}
long rawThreadTime = System.nanoTime() - startTime;
System.out.println("Raw threads (10,000): " + rawThreadTime / 1_000_000 + " ms");
// Approach 2: Submit 10,000 tasks to a fixed thread pool
ExecutorService pool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
startTime = System.nanoTime();
for (int i = 0; i < TASK_COUNT; i++) {
pool.submit(() -> {
// Same minimal work
int x = 1 + 1;
});
}
pool.shutdown();
pool.awaitTermination(60, TimeUnit.SECONDS);
long poolTime = System.nanoTime() - startTime;
System.out.println("Thread pool (10,000 tasks): " + poolTime / 1_000_000 + " ms");
// Compare the two approaches
System.out.printf("Thread pool was %.2fx faster%n", (double) rawThreadTime / poolTime);
}
}
What does this code do?
The first approach creates and starts 10,000 separate OS threads. Each thread does trivial work so the timing captures pure creation overhead. The second approach reuses a small fixed pool of threads (one per core) and queues 10,000 tasks into it.
The difference is dramatic. Thread pools avoid the system call, stack allocation, and scheduler registration that each raw thread requires. You will study executors and thread pools in detail in a later step.
- Run ThreadCreationOverhead and observe the timing difference in the Run panel.
- You should see the thread pool complete significantly faster (typically 5x-20x on most machines).
Getting an OutOfMemoryError?
If your machine runs low on memory when creating 10,000 threads, reduce TASK_COUNT to 5,000. The relative difference between raw threads and the pool will still be clearly visible.
Help me fix an OutOfMemoryError when creating many threads.
The final program in this step demonstrates the most dangerous consequence of threads sharing memory. When two threads modify the same variable without coordination, updates get lost.
What is thread safety?
A class is thread-safe if it behaves correctly when accessed from multiple threads, regardless of the scheduling or interleaving of those threads, with no additional synchronization on the part of the calling code.
The next program is deliberately NOT thread-safe. It exists to show you what broken concurrent code looks like so you can recognize and prevent this in real projects.
- Create a new Java class in the multithreading package named SharedMemoryDemo.
- Replace the generated content with the following code:
package multithreading;
public class SharedMemoryDemo {
// Shared mutable state: both threads will modify this counter
private static int sharedCounter = 0;
private static final int INCREMENTS = 100_000;
public static void main(String[] args) throws InterruptedException {
// Thread 1: increment the counter 100,000 times
Thread thread1 = new Thread(() -> {
for (int i = 0; i < INCREMENTS; i++) {
sharedCounter++;
}
}, "Incrementer-1");
// Thread 2: also increment the counter 100,000 times
Thread thread2 = new Thread(() -> {
for (int i = 0; i < INCREMENTS; i++) {
sharedCounter++;
}
}, "Incrementer-2");
thread1.start();
thread2.start();
// Wait for both threads to finish
thread1.join();
thread2.join();
// Expected: 200,000. Actual: less due to race condition
System.out.println("Expected counter value: " + (INCREMENTS * 2));
System.out.println("Actual counter value: " + sharedCounter);
System.out.println("Lost updates: " + ((INCREMENTS * 2) - sharedCounter));
System.out.println("\nThis demonstrates why shared mutable state needs synchronization!");
}
}
What does this code do?
Two threads each increment a shared int counter 100,000 times. If everything were sequential, the final value would be 200,000. But sharedCounter++ is actually three separate operations: read the value, add 1, write the result back.
When two threads interleave these three steps, updates get lost. Thread A reads 42, Thread B reads 42, both write 43. One increment disappeared. This is called a race condition and you will learn how to fix it in the synchronization step.
- Run SharedMemoryDemo several times.
- You should see the actual counter value is less than 200,000, and it is a different number each time you run it.
Getting exactly 200,000 every time?
On very fast machines with few cores, the scheduler might let one thread finish entirely before starting the other. Try increasing INCREMENTS to 1,000,000 or 10,000,000. The larger the loop, the more likely the threads overlap and lose updates.
Help me make the race condition visible in SharedMemoryDemo.
✔️ Awesome, I've got everything!
Great. Double check that all five files are saved in your multithreading package.
ⓧ I'd like to double check the full code
Here are all five files for your multithreading package.
package multithreading;
public class ProcessorCount {
public static void main(String[] args) {
// Query the JVM for the number of available processor cores
int cores = Runtime.getRuntime().availableProcessors();
System.out.println("Available processor cores: " + cores);
System.out.println("This is how many threads can truly run in parallel on your machine.");
}
}
package multithreading;
public class SpeedupDemo {
// Size of the array to sum (100 million elements)
private static final int ARRAY_SIZE = 100_000_000;
public static void main(String[] args) throws InterruptedException {
// Create and fill a large array with values
long[] numbers = new long[ARRAY_SIZE];
for (int i = 0; i < ARRAY_SIZE; i++) {
numbers[i] = i + 1;
}
// Single-threaded sum: one thread does all the work
long startTime = System.nanoTime();
long singleThreadSum = 0;
for (long num : numbers) {
singleThreadSum += num;
}
long singleThreadTime = System.nanoTime() - startTime;
System.out.println("Single-threaded sum: " + singleThreadSum);
System.out.println("Single-threaded time: " + singleThreadTime / 1_000_000 + " ms");
// Multi-threaded sum: split the work across available cores
int cores = Runtime.getRuntime().availableProcessors();
long[] partialSums = new long[cores];
Thread[] threads = new Thread[cores];
int chunkSize = ARRAY_SIZE / cores;
startTime = System.nanoTime();
for (int i = 0; i < cores; i++) {
final int threadIndex = i;
final int start = i * chunkSize;
final int end = (i == cores - 1) ? ARRAY_SIZE : start + chunkSize;
threads[i] = new Thread(() -> {
// Each thread sums its own chunk of the array
long sum = 0;
for (int j = start; j < end; j++) {
sum += numbers[j];
}
partialSums[threadIndex] = sum;
});
threads[i].start();
}
// Wait for all threads to finish
for (Thread thread : threads) {
thread.join();
}
// Combine partial results from all threads
long multiThreadSum = 0;
for (long partial : partialSums) {
multiThreadSum += partial;
}
long multiThreadTime = System.nanoTime() - startTime;
System.out.println("\nMulti-threaded sum (" + cores + " threads): " + multiThreadSum);
System.out.println("Multi-threaded time: " + multiThreadTime / 1_000_000 + " ms");
System.out.printf("Speedup: %.2fx%n", (double) singleThreadTime / multiThreadTime);
}
}
package multithreading;
public class InterleavingDemo {
public static void main(String[] args) {
// Create 5 threads that each print numbers 1 through 10
for (int i = 1; i <= 5; i++) {
final int threadNumber = i;
Thread thread = new Thread(() -> {
for (int num = 1; num <= 10; num++) {
// Print the thread identifier and its current number
System.out.println("Thread-" + threadNumber + ": " + num);
}
});
thread.start();
}
// This line may print before, during, or after the thread output
System.out.println("Main thread finished starting all threads.");
}
}
package multithreading;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ThreadCreationOverhead {
private static final int TASK_COUNT = 10_000;
public static void main(String[] args) throws InterruptedException {
// Approach 1: Create 10,000 raw threads
long startTime = System.nanoTime();
Thread[] threads = new Thread[TASK_COUNT];
for (int i = 0; i < TASK_COUNT; i++) {
threads[i] = new Thread(() -> {
// Minimal work to isolate creation overhead
int x = 1 + 1;
});
threads[i].start();
}
// Wait for all threads to complete
for (Thread t : threads) {
t.join();
}
long rawThreadTime = System.nanoTime() - startTime;
System.out.println("Raw threads (10,000): " + rawThreadTime / 1_000_000 + " ms");
// Approach 2: Submit 10,000 tasks to a fixed thread pool
ExecutorService pool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
startTime = System.nanoTime();
for (int i = 0; i < TASK_COUNT; i++) {
pool.submit(() -> {
// Same minimal work
int x = 1 + 1;
});
}
pool.shutdown();
pool.awaitTermination(60, TimeUnit.SECONDS);
long poolTime = System.nanoTime() - startTime;
System.out.println("Thread pool (10,000 tasks): " + poolTime / 1_000_000 + " ms");
// Compare the two approaches
System.out.printf("Thread pool was %.2fx faster%n", (double) rawThreadTime / poolTime);
}
}
package multithreading;
public class SharedMemoryDemo {
// Shared mutable state: both threads will modify this counter
private static int sharedCounter = 0;
private static final int INCREMENTS = 100_000;
public static void main(String[] args) throws InterruptedException {
// Thread 1: increment the counter 100,000 times
Thread thread1 = new Thread(() -> {
for (int i = 0; i < INCREMENTS; i++) {
sharedCounter++;
}
}, "Incrementer-1");
// Thread 2: also increment the counter 100,000 times
Thread thread2 = new Thread(() -> {
for (int i = 0; i < INCREMENTS; i++) {
sharedCounter++;
}
}, "Incrementer-2");
thread1.start();
thread2.start();
// Wait for both threads to finish
thread1.join();
thread2.join();
// Expected: 200,000. Actual: less due to race condition
System.out.println("Expected counter value: " + (INCREMENTS * 2));
System.out.println("Actual counter value: " + sharedCounter);
System.out.println("Lost updates: " + ((INCREMENTS * 2) - sharedCounter));
System.out.println("\nThis demonstrates why shared mutable state needs synchronization!");
}
}
What are the common multithreading models?
You have now seen two models in action. Thread-per-task (InterleavingDemo, SharedMemoryDemo) creates one thread per unit of work. It is simple but does not scale, as ThreadCreationOverhead proved.
Thread pool (the second half of ThreadCreationOverhead) reuses a fixed set of threads for many tasks. This is the model used in production servers and frameworks. A third model, event-driven, uses a single thread processing events from a queue (as in GUI frameworks and Node.js).
You have built a complete mental model of processes, threads, parallelism, and scheduling. Next up, you will dive into the Java Thread API itself and learn how to control thread lifecycle, priorities, and interruption.
Create and Manage Threads
In the previous step, you explored the theoretical foundations of multithreading. You understand what threads are, how the OS scheduler works, and why shared memory creates both power and danger. Now it is time to actually create and control threads in Java.
Java gives you two primary ways to create threads, a rich API for controlling their behavior, and a cooperative mechanism for stopping them gracefully. By the end of this step, you will have working programs that demonstrate every fundamental thread operation.
In this step, get ready to:
- Create threads using Thread subclass and Runnable interface.
- Control thread lifecycle with daemon threads and join.
- Implement cooperative thread interruption.
Create threads using Thread subclass and Runnable
Java provides two fundamental approaches for creating threads. You can extend the Thread class directly, or you can implement the Runnable interface and pass it to a Thread constructor.
Why does Runnable exist when you can extend Thread?
Java only allows single inheritance. If your class extends Thread, it cannot extend anything else. The Runnable interface solves this by separating the task (what to run) from the mechanism (the thread itself).
Runnable is also a functional interface, which means you can use lambda expressions for concise thread creation. In practice, Runnable is almost always preferred over extending Thread.
- In the fundamentals package, create a new Java class called ThreadSubclassDemo and paste in the following code:
package fundamentals;
// Demonstrates creating a thread by extending the Thread class
public class ThreadSubclassDemo extends Thread {
@Override
public void run() {
// This code runs in the new thread
System.out.println("Thread running: " + Thread.currentThread().getName());
System.out.println("Thread priority: " + Thread.currentThread().getPriority());
}
public static void main(String[] args) {
// Create and start a thread using the subclass approach
ThreadSubclassDemo thread = new ThreadSubclassDemo();
thread.setName("MyCustomThread");
thread.start();
System.out.println("Main thread: " + Thread.currentThread().getName());
}
}
What does this code do?
- The class extends Thread and overrides run(). The run() method is the entry point for the new thread's execution.
- setName("MyCustomThread") assigns a human-readable name to the thread. This is critical for debugging and reading log output.
- start() causes the JVM to create a new OS thread and invoke run() on it. You can only call start() once per Thread object.
- Right-click ThreadSubclassDemo.java and select **Run 'ThreadSubclassDemo.main()'**.
- You should see output showing both MyCustomThread and main thread names. The order may vary between runs.
Seeing a compilation error?
- Make sure the class name ThreadSubclassDemo exactly matches the file name.
- Confirm the package fundamentals; declaration matches the folder the file lives in.
- Help me fix a compilation error in my ThreadSubclassDemo
Now let's see the preferred approach using Runnable with lambda expressions.
- Create a new Java class called RunnableDemo in the fundamentals package and paste in the following code:
package fundamentals;
// Demonstrates creating threads using Runnable interface and lambda syntax
public class RunnableDemo {
public static void main(String[] args) {
// Approach 1: Anonymous class implementing Runnable
Runnable task1 = new Runnable() {
@Override
public void run() {
System.out.println("Task 1 running on: " + Thread.currentThread().getName());
}
};
// Approach 2: Lambda expression (preferred - concise and readable)
Runnable task2 = () -> {
System.out.println("Task 2 running on: " + Thread.currentThread().getName());
};
// Create and start threads with Runnable
Thread thread1 = new Thread(task1, "Worker-1");
Thread thread2 = new Thread(task2, "Worker-2");
thread1.start();
thread2.start();
System.out.println("Main thread: " + Thread.currentThread().getName());
}
}
What does this code do?
- task1 uses an anonymous class, the pre-Java-8 way to implement Runnable.
- task2 uses a lambda expression. Since Runnable has exactly one abstract method, Java allows this shorthand.
- The Thread(Runnable, String) constructor separates the task from the thread. The second argument names the thread.
- Run RunnableDemo. You should see three lines of output showing Task 1 on Worker-1, Task 2 on Worker-2, and Main on main.
A critical distinction to understand is the difference between calling run() directly versus calling start(). Many beginners accidentally call run() and wonder why their code is not concurrent.
- Create a new Java class called RunVsStartDemo in the fundamentals package and paste in the following code:
package fundamentals;
// Demonstrates the critical difference between run() and start()
public class RunVsStartDemo {
public static void main(String[] args) {
Runnable task = () -> {
System.out.println("Executing on: " + Thread.currentThread().getName());
};
Thread thread = new Thread(task, "NewThread");
// Calling run() directly - executes in the CURRENT thread (main)
System.out.println("Calling run() directly:");
thread.run();
// Calling start() - executes in a NEW thread
System.out.println("\nCalling start():");
thread.start();
}
}
What does this code do?
When you call run() directly, no new thread is created. The method simply executes in the calling thread (main). The output will show Executing on: main.
When you call start(), the JVM creates a new OS thread and invokes run() on it. The output will show Executing on: NewThread.
- Run RunVsStartDemo and verify that the first call prints main and the second call prints NewThread.
Finally, let's observe thread interleaving in action to reinforce that scheduling is non-deterministic.
- Create a new Java class called ThreadInterleavingDemo in the fundamentals package and paste in the following code:
package fundamentals;
// Demonstrates that thread scheduling is non-deterministic
public class ThreadInterleavingDemo {
public static void main(String[] args) {
// Create 3 threads that each print numbers 1-5
for (int i = 1; i <= 3; i++) {
final int threadNum = i;
Thread thread = new Thread(() -> {
for (int j = 1; j <= 5; j++) {
System.out.println("Thread-" + threadNum + ": " + j);
}
}, "Thread-" + i);
thread.start();
}
}
}
What does this code do?
Three threads are created in a loop, each printing numbers 1 through 5 with its thread identifier. Because the OS thread scheduler uses preemptive time-slicing, the output ordering is unpredictable.
The final int threadNum = i is required because lambda expressions can only capture effectively final variables.
- Run ThreadInterleavingDemo multiple times. You should see the output ordering change between runs.
Why does the output change each time?
The OS thread scheduler is preemptive. It decides when to suspend one thread and run another. Each thread gets a time quantum (typically 1-20ms). When that expires, the scheduler picks a different thread. You have zero control over this ordering from Java code.
This non-determinism is the root cause of most concurrency bugs. Code that "works" on one run may fail on the next.
What about thread priority?
Java threads have a priority between MIN_PRIORITY (1) and MAX_PRIORITY (10), with NORM_PRIORITY (5) as the default. You can set priority with setPriority().
Priority is a hint to the scheduler, not a guarantee. The scheduler may completely ignore it depending on the OS. Never rely on priority for correctness.
Control thread lifecycle with daemon threads and join
Java distinguishes between two types of threads: user threads and daemon threads. This distinction determines when the JVM shuts down.
What are daemon threads?
User threads keep the JVM alive. The JVM will not shut down until all user threads have terminated. Daemon threads do not prevent JVM shutdown. When only daemon threads remain, the JVM exits immediately, abruptly terminating daemon threads without running finally blocks.
You must call setDaemon(true) before calling start(). Typical use cases for daemon threads include garbage collection, background monitoring, and periodic cleanup tasks.
- Create a new Java class called DaemonThreadDemo in the fundamentals package and paste in the following code:
package fundamentals;
// Demonstrates daemon thread behavior - JVM exits before daemon finishes
public class DaemonThreadDemo {
public static void main(String[] args) {
Thread daemonThread = new Thread(() -> {
// This loop will be cut short when the JVM exits
for (int i = 1; i <= 10; i++) {
System.out.println("Daemon working: iteration " + i);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
System.out.println("Daemon interrupted");
}
}
// This line will likely never print
System.out.println("Daemon finished all work");
}, "DaemonWorker");
// Must call setDaemon before start()
daemonThread.setDaemon(true);
daemonThread.start();
// Main thread sleeps briefly then exits
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Main thread ending - JVM will exit, killing daemon");
}
}
What does this code do?
- The daemon thread attempts 10 iterations with 500ms sleeps between each (5 seconds total).
- The main thread sleeps for only 1500ms, then exits. Since main is the only user thread, the JVM shuts down.
- The daemon thread is abruptly killed mid-work. You should see roughly 3 iterations printed before the JVM exits.
- Run DaemonThreadDemo. You should see about 3 "Daemon working" lines, followed by "Main thread ending". The "Daemon finished all work" line will not appear.
Seeing all 10 iterations?
- Confirm that setDaemon(true) is called before start(). If you set it after, you will get an IllegalThreadStateException.
- If the daemon completes all iterations, your main thread might be sleeping longer than expected. Make sure the main sleep is 1500ms.
- Help me understand why my daemon thread is not being killed
The opposite problem is wanting to wait for a thread to finish. The join() method blocks the calling thread until the target thread terminates.
What does join() guarantee?
When thread A calls threadB.join(), thread A is suspended until thread B terminates. This establishes a happens-before relationship: all actions in thread B are visible to thread A after join() returns.
You can also pass a timeout: join(2000) waits at most 2 seconds. If the timeout expires, the calling thread resumes regardless.
- Create a new Java class called JoinDemo in the fundamentals package and paste in the following code:
package fundamentals;
// Demonstrates join() to wait for thread completion
public class JoinDemo {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
System.out.println("Worker started");
try {
// Simulate some work
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Worker finished");
}, "Worker");
worker.start();
System.out.println("Main waiting for worker to finish...");
// join() blocks the current thread until worker terminates
worker.join();
// This line only executes AFTER the worker thread finishes
System.out.println("Main continues after worker completed");
System.out.println("Worker is alive? " + worker.isAlive());
}
}
What does this code do?
- The worker thread simulates 2 seconds of work using Thread.sleep(2000).
- worker.join() pauses the main thread until the worker finishes. Without this call, main would race ahead and print its message immediately.
- After join() returns, isAlive() returns false because the worker thread has terminated.
- Run JoinDemo. You should see "Worker started", a 2-second pause, "Worker finished", then "Main continues after worker completed". The ordering is guaranteed.
Implement cooperative thread interruption
Java does not allow you to forcibly kill a thread. The deprecated Thread.stop() method was removed because forcibly stopping a thread leaves shared objects in inconsistent states. Instead, Java uses cooperative cancellation through the interrupt mechanism.
How does the interrupt mechanism work?
Calling thread.interrupt() sets the target thread's interrupt flag. What happens next depends on what the thread is doing:
- If the thread is sleeping, waiting, or in a timed wait, it immediately receives an InterruptedException and the interrupt flag is cleared.
- If the thread is doing computation, nothing happens automatically. The thread must periodically check its interrupt flag using Thread.interrupted() or isInterrupted().
Interruption is a polite request, not a command. The target thread decides how to respond.
- Create a new Java class called InterruptDemo in the fundamentals package and paste in the following code:
package fundamentals;
// Demonstrates interrupting a sleeping thread
public class InterruptDemo {
public static void main(String[] args) throws InterruptedException {
Thread sleeper = new Thread(() -> {
System.out.println("Sleeper going to sleep for 10 seconds...");
try {
Thread.sleep(10000);
System.out.println("Sleeper woke up naturally");
} catch (InterruptedException e) {
// Thread was interrupted while sleeping
System.out.println("Sleeper was interrupted! " + e.getMessage());
System.out.println("Interrupt flag after catch: " + Thread.currentThread().isInterrupted());
}
}, "Sleeper");
sleeper.start();
// Give the sleeper time to fall asleep
Thread.sleep(1000);
// Interrupt the sleeping thread
System.out.println("Main thread interrupting sleeper...");
sleeper.interrupt();
sleeper.join();
System.out.println("Program complete");
}
}
What does this code do?
- The sleeper thread calls Thread.sleep(10000) to sleep for 10 seconds.
- After 1 second, the main thread calls sleeper.interrupt(). Since the sleeper is in a sleep state, it immediately throws InterruptedException.
- Notice that after catching the exception, isInterrupted() returns false. Throwing the exception clears the interrupt flag.
- Run InterruptDemo. You should see the sleeper get interrupted after about 1 second (not 10). The interrupt flag will show false after the catch block.
Program hangs for 10 seconds?
- Make sure sleeper.interrupt() is being called after the 1-second sleep on the main thread. If the interrupt never fires, the sleeper sleeps the full 10 seconds.
- Confirm you are calling interrupt() on the sleeper reference, not on the current thread.
- Help me understand why my thread interrupt is not working
There is a subtle but important difference between the two ways to check the interrupt flag.
Thread.interrupted() vs isInterrupted()
- Thread.interrupted() is a static method. It checks the current thread's interrupt flag and CLEARS it. Calling it twice in a row returns true then false.
- isInterrupted() is an instance method. It checks the flag WITHOUT clearing it. Calling it twice returns true both times.
Use isInterrupted() when you want to check without consuming the signal. Use Thread.interrupted() when you want to check and acknowledge the interrupt in one step.
- Create a new Java class called InterruptedFlagDemo in the fundamentals package and paste in the following code:
package fundamentals;
// Demonstrates the difference between Thread.interrupted() and isInterrupted()
public class InterruptedFlagDemo {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
// Busy work loop that checks for interruption
while (!Thread.currentThread().isInterrupted()) {
// Simulate work
System.out.println("Working...");
}
// After exiting the loop, demonstrate the two methods
System.out.println("\nLoop exited. Checking interrupt status:");
// isInterrupted() - reads flag WITHOUT clearing it
System.out.println("isInterrupted(): " + Thread.currentThread().isInterrupted());
System.out.println("isInterrupted() again: " + Thread.currentThread().isInterrupted());
// Thread.interrupted() - reads flag AND CLEARS it
System.out.println("\nThread.interrupted(): " + Thread.interrupted());
System.out.println("Thread.interrupted() again: " + Thread.interrupted());
}, "InterruptFlagWorker");
worker.start();
// Let the worker run briefly
Thread.sleep(50);
// Interrupt the worker
worker.interrupt();
worker.join();
}
}
What does this code do?
- The worker thread spins in a loop, checking isInterrupted() each iteration. This is the pattern for interruptible computation (no blocking calls to throw the exception for you).
- After the loop exits, it demonstrates that isInterrupted() returns true twice (does not clear the flag).
- Then Thread.interrupted() returns true the first time (and clears the flag), then false the second time.
- Run InterruptedFlagDemo. After the "Working..." lines stop, you should see isInterrupted() return true twice, then Thread.interrupted() return true then false.
Writing interruptible code in practice
When your thread does long-running computation (no sleep or wait calls), you must manually check the interrupt flag in your loop. The pattern is: while (!Thread.currentThread().isInterrupted()) { ... }.
When your thread calls blocking methods like sleep() or wait(), those methods throw InterruptedException for you. Either propagate the exception or re-interrupt the thread in the catch block.
Extra for Experts: Why was Thread.stop() deprecated?
Thread.stop() forcibly terminates a thread by throwing a ThreadDeath exception at an arbitrary point. If the thread was in the middle of updating a shared data structure, that structure is left in a corrupted, partially-updated state. Other threads that access it later will see inconsistent data.
Cooperative interruption solves this by letting the thread choose a safe point to stop, ensuring all invariants are preserved before termination.
✔️ Awesome, I've got everything!
Great. Double check that you have saved all 8 files in the fundamentals package and that each one runs without errors.
ⓧ I'd like to double check the full code
Here are all 8 files you should have in the fundamentals package:
package fundamentals;
// Demonstrates creating a thread by extending the Thread class
public class ThreadSubclassDemo extends Thread {
@Override
public void run() {
// This code runs in the new thread
System.out.println("Thread running: " + Thread.currentThread().getName());
System.out.println("Thread priority: " + Thread.currentThread().getPriority());
}
public static void main(String[] args) {
// Create and start a thread using the subclass approach
ThreadSubclassDemo thread = new ThreadSubclassDemo();
thread.setName("MyCustomThread");
thread.start();
System.out.println("Main thread: " + Thread.currentThread().getName());
}
}
package fundamentals;
// Demonstrates creating threads using Runnable interface and lambda syntax
public class RunnableDemo {
public static void main(String[] args) {
// Approach 1: Anonymous class implementing Runnable
Runnable task1 = new Runnable() {
@Override
public void run() {
System.out.println("Task 1 running on: " + Thread.currentThread().getName());
}
};
// Approach 2: Lambda expression (preferred - concise and readable)
Runnable task2 = () -> {
System.out.println("Task 2 running on: " + Thread.currentThread().getName());
};
// Create and start threads with Runnable
Thread thread1 = new Thread(task1, "Worker-1");
Thread thread2 = new Thread(task2, "Worker-2");
thread1.start();
thread2.start();
System.out.println("Main thread: " + Thread.currentThread().getName());
}
}
package fundamentals;
// Demonstrates the critical difference between run() and start()
public class RunVsStartDemo {
public static void main(String[] args) {
Runnable task = () -> {
System.out.println("Executing on: " + Thread.currentThread().getName());
};
Thread thread = new Thread(task, "NewThread");
// Calling run() directly - executes in the CURRENT thread (main)
System.out.println("Calling run() directly:");
thread.run();
// Calling start() - executes in a NEW thread
System.out.println("\nCalling start():");
thread.start();
}
}
package fundamentals;
// Demonstrates that thread scheduling is non-deterministic
public class ThreadInterleavingDemo {
public static void main(String[] args) {
// Create 3 threads that each print numbers 1-5
for (int i = 1; i <= 3; i++) {
final int threadNum = i;
Thread thread = new Thread(() -> {
for (int j = 1; j <= 5; j++) {
System.out.println("Thread-" + threadNum + ": " + j);
}
}, "Thread-" + i);
thread.start();
}
}
}
package fundamentals;
// Demonstrates daemon thread behavior - JVM exits before daemon finishes
public class DaemonThreadDemo {
public static void main(String[] args) {
Thread daemonThread = new Thread(() -> {
// This loop will be cut short when the JVM exits
for (int i = 1; i <= 10; i++) {
System.out.println("Daemon working: iteration " + i);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
System.out.println("Daemon interrupted");
}
}
// This line will likely never print
System.out.println("Daemon finished all work");
}, "DaemonWorker");
// Must call setDaemon before start()
daemonThread.setDaemon(true);
daemonThread.start();
// Main thread sleeps briefly then exits
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Main thread ending - JVM will exit, killing daemon");
}
}
package fundamentals;
// Demonstrates join() to wait for thread completion
public class JoinDemo {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
System.out.println("Worker started");
try {
// Simulate some work
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Worker finished");
}, "Worker");
worker.start();
System.out.println("Main waiting for worker to finish...");
// join() blocks the current thread until worker terminates
worker.join();
// This line only executes AFTER the worker thread finishes
System.out.println("Main continues after worker completed");
System.out.println("Worker is alive? " + worker.isAlive());
}
}
package fundamentals;
// Demonstrates interrupting a sleeping thread
public class InterruptDemo {
public static void main(String[] args) throws InterruptedException {
Thread sleeper = new Thread(() -> {
System.out.println("Sleeper going to sleep for 10 seconds...");
try {
Thread.sleep(10000);
System.out.println("Sleeper woke up naturally");
} catch (InterruptedException e) {
// Thread was interrupted while sleeping
System.out.println("Sleeper was interrupted! " + e.getMessage());
System.out.println("Interrupt flag after catch: " + Thread.currentThread().isInterrupted());
}
}, "Sleeper");
sleeper.start();
// Give the sleeper time to fall asleep
Thread.sleep(1000);
// Interrupt the sleeping thread
System.out.println("Main thread interrupting sleeper...");
sleeper.interrupt();
sleeper.join();
System.out.println("Program complete");
}
}
package fundamentals;
// Demonstrates the difference between Thread.interrupted() and isInterrupted()
public class InterruptedFlagDemo {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
// Busy work loop that checks for interruption
while (!Thread.currentThread().isInterrupted()) {
// Simulate work
System.out.println("Working...");
}
// After exiting the loop, demonstrate the two methods
System.out.println("\nLoop exited. Checking interrupt status:");
// isInterrupted() - reads flag WITHOUT clearing it
System.out.println("isInterrupted(): " + Thread.currentThread().isInterrupted());
System.out.println("isInterrupted() again: " + Thread.currentThread().isInterrupted());
// Thread.interrupted() - reads flag AND CLEARS it
System.out.println("\nThread.interrupted(): " + Thread.interrupted());
System.out.println("Thread.interrupted() again: " + Thread.interrupted());
}, "InterruptFlagWorker");
worker.start();
// Let the worker run briefly
Thread.sleep(50);
// Interrupt the worker
worker.interrupt();
worker.join();
}
}
You now have a complete set of programs demonstrating every fundamental thread operation in Java. Next up, you will observe thread lifecycle states in real time and learn how threads transition between NEW, RUNNABLE, BLOCKED, WAITING, and TERMINATED.
Observe the Thread Lifecycle
In the previous step, you created threads, joined them, interrupted them, and observed daemon behavior. You now know how to launch threads and communicate with them.
But what is a thread actually doing at any given moment? Every thread exists in one of six precisely defined states, and understanding these states is essential for diagnosing concurrency bugs like deadlocks.
In this step, get ready to:
- Study all six thread states and every transition between them.
- Write programs that force threads into each state and observe them.
- Print a full thread dump and learn to read it.
Study the six thread states
Java defines every possible thread state in the Thread.State enum. There are exactly six values, and a thread is always in exactly one of them.
What are the six thread states?
- NEW - the thread object has been created but start() has not been called yet. The thread does not exist in the OS scheduler.
- RUNNABLE - the thread is eligible to run. It may be actively executing on a CPU core, or it may be waiting for the OS scheduler to give it a time slice. The JVM does not distinguish between these two sub-states.
- BLOCKED - the thread is waiting to acquire a monitor lock. This happens when the thread attempts to enter a synchronized block or method, but another thread already holds that lock.
- WAITING - the thread is waiting indefinitely for another thread to perform a specific action. Triggered by calling Object.wait(), Thread.join() with no timeout, or LockSupport.park().
- TIMED_WAITING - the thread is waiting for a specified amount of time. Triggered by calling Thread.sleep(ms), Object.wait(ms), Thread.join(ms), or LockSupport.parkNanos().
- TERMINATED - the thread has completed execution. Either its run() method returned normally or it threw an unhandled exception. The thread object still exists in memory but cannot be restarted.
What triggers each state transition?
Every transition has a specific trigger. Here is the complete map:
- NEW to RUNNABLE: calling start().
- RUNNABLE to BLOCKED: attempting to enter a synchronized block when the lock is held by another thread.
- BLOCKED to RUNNABLE: the lock becomes available and this thread acquires it.
- RUNNABLE to WAITING: calling Object.wait(), Thread.join(), or LockSupport.park() with no timeout.
- RUNNABLE to TIMED_WAITING: calling Thread.sleep(ms), Object.wait(ms), Thread.join(ms), or LockSupport.parkNanos().
- WAITING or TIMED_WAITING to RUNNABLE: notification received (notify()/notifyAll()), timeout expired, target thread finished (for join()), or thread interrupted.
- Any state to TERMINATED: the run() method completes (normally or via unhandled exception).
Why is there no RUNNING state?
You might expect a state for when the thread is actually executing on a CPU core. The JVM does not provide one because it cannot know this at any given instant. Only the OS scheduler knows which threads are physically running on cores at any moment.
A thread in the RUNNABLE state might be executing right now, or it might be sitting in the OS run queue waiting for its next time slice. From the JVM's perspective, both situations are the same: the thread is ready and able to execute.
You can observe any thread's current state programmatically by calling thread.getState(). This returns a Thread.State enum value. Let's write programs that put threads into each state and prove it.
- In IntelliJ, right-click the fundamentals package in the Project panel.
- Select New, then Java Class.
- Name it LifecycleDemo and press Enter.
- Replace the file contents with this code:
package fundamentals;
public class LifecycleDemo {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
try {
// Thread is RUNNABLE while executing this line
System.out.println("Inside run(): " + Thread.currentThread().getState());
// Thread moves to TIMED_WAITING during sleep
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "lifecycle-thread");
// State: NEW (created but not yet started)
System.out.println("After creation: " + thread.getState());
// Transition: NEW -> RUNNABLE
thread.start();
// Give the thread a moment to enter sleep
Thread.sleep(50);
// State: should be TIMED_WAITING (inside sleep)
System.out.println("After sleep starts: " + thread.getState());
// Wait for the thread to finish
thread.join();
// State: TERMINATED (run() completed)
System.out.println("After completion: " + thread.getState());
}
}
What does this code do?
- After creating the thread object but before calling start(), the state is NEW. The thread exists as a Java object but has no corresponding OS thread.
- Inside run(), the thread reports itself as RUNNABLE because it is actively executing code.
- The main thread waits 50ms then checks the state. By that point, the lifecycle thread has entered sleep(), so it reports TIMED_WAITING.
- After join() returns, the thread is done. Its state is TERMINATED.
- Right-click inside the file and select Run 'LifecycleDemo.main()'.
You should see output showing NEW, RUNNABLE, TIMED_WAITING, and TERMINATED printed in sequence. This single program walks a thread through four of the six states.
Seeing RUNNABLE instead of TIMED_WAITING?
If the 50ms pause in the main thread is not long enough for the lifecycle thread to enter sleep(), you might catch it still RUNNABLE. Try increasing the pause to Thread.sleep(200) to give it more time.
Help me understand why my thread state observation is inconsistent.
Force threads into BLOCKED, WAITING, and TIMED_WAITING states
The lifecycle demo showed TIMED_WAITING and TERMINATED clearly. Now you will write three focused programs that isolate the remaining states: BLOCKED, WAITING, and a dedicated TIMED_WAITING demo.
Each of these states has a different cause. Understanding the cause tells you how to fix a stuck thread in production.
- Create a new class in the fundamentals package called BlockedStateDemo.
- Paste the following code:
package fundamentals;
public class BlockedStateDemo {
// Shared lock object that both threads will contend for
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// Thread 1 acquires the lock and holds it
Thread holder = new Thread(() -> {
synchronized (lock) {
try {
// Hold the lock for 3 seconds so the other thread blocks
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "lock-holder");
// Thread 2 tries to acquire the same lock
Thread waiter = new Thread(() -> {
synchronized (lock) {
// This only runs after holder releases the lock
System.out.println("Waiter acquired the lock!");
}
}, "lock-waiter");
holder.start();
// Give holder time to acquire the lock
Thread.sleep(100);
waiter.start();
// Give waiter time to attempt lock acquisition
Thread.sleep(100);
// Waiter should be BLOCKED (waiting to enter synchronized block)
System.out.println("Waiter state: " + waiter.getState());
holder.join();
waiter.join();
}
}
What does this code do?
The holder thread enters the synchronized (lock) block first and sleeps for 3 seconds while holding the lock. When the waiter thread starts and tries to enter its own synchronized (lock) block, it cannot proceed because the lock is already held.
The JVM puts the waiter into the BLOCKED state until the holder exits its synchronized block and releases the lock. This is the only way a thread enters the BLOCKED state: contending for a monitor lock.
- Run BlockedStateDemo. You should see Waiter state: BLOCKED followed by Waiter acquired the lock! once the holder finishes sleeping.
- Create another new class in fundamentals called WaitingStateDemo.
- Paste the following code:
package fundamentals;
public class WaitingStateDemo {
public static void main(String[] args) throws InterruptedException {
// A long-running thread that the joiner will wait for
Thread longTask = new Thread(() -> {
try {
// Simulate a long task
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "long-task");
// This thread calls join() on longTask without a timeout
Thread joiner = new Thread(() -> {
try {
// join() with no timeout puts this thread into WAITING state
longTask.join();
System.out.println("Long task finished, joiner resumes.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "joiner-thread");
longTask.start();
joiner.start();
// Give joiner time to call join()
Thread.sleep(100);
// Joiner should be WAITING (blocked on join with no timeout)
System.out.println("Joiner state: " + joiner.getState());
longTask.join();
joiner.join();
}
}
What does this code do?
The joiner thread calls longTask.join() with no timeout argument. This means it will wait indefinitely for longTask to complete. The JVM puts the joiner into the WAITING state (not TIMED_WAITING) because there is no upper bound on how long it will wait.
The distinction matters for diagnosis. WAITING means "waiting forever until something specific happens." TIMED_WAITING means "waiting, but will give up after a timeout."
- Run WaitingStateDemo. You should see Joiner state: WAITING printed to the console.
- Create one more class in fundamentals called TimedWaitingStateDemo.
- Paste the following code:
package fundamentals;
public class TimedWaitingStateDemo {
public static void main(String[] args) throws InterruptedException {
Thread sleeper = new Thread(() -> {
try {
// sleep() puts the thread into TIMED_WAITING state
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "sleeper-thread");
sleeper.start();
// Give the thread time to enter sleep
Thread.sleep(100);
// Sleeper should be TIMED_WAITING
System.out.println("Sleeper state: " + sleeper.getState());
sleeper.join();
// After completion, the state changes to TERMINATED
System.out.println("Sleeper state after completion: " + sleeper.getState());
}
}
What does this code do?
This is the simplest state demo. Thread.sleep(3000) is the most common way a thread enters TIMED_WAITING. Unlike WAITING, this state has a built-in expiration. The thread will automatically return to RUNNABLE after 3 seconds even if nothing else happens.
- Run TimedWaitingStateDemo. You should see Sleeper state: TIMED_WAITING followed by Sleeper state after completion: TERMINATED.
How do BLOCKED and WAITING differ?
This distinction confuses many developers. The difference is about why the thread is stuck:
- BLOCKED: the thread wants to enter a synchronized block but cannot because another thread holds the lock. It did not choose to wait. It is involuntarily stuck.
- WAITING: the thread deliberately asked to wait for something (join(), wait(), park()). It voluntarily suspended itself until a specific event occurs.
In production thread dumps, many threads in BLOCKED state often signals lock contention. Many threads in WAITING state is normal and expected.
Seeing unexpected states?
Thread state observation is inherently timing-dependent. If you see RUNNABLE when you expected BLOCKED or WAITING, the thread has not yet reached the blocking call. Increase the Thread.sleep() delay before the state check to give the target thread more time to transition.
Help me debug my thread state observation.
Print a thread dump
In production, you diagnose stuck applications by taking a thread dump. A thread dump shows every live thread, its current state, and its stack trace. This tells you exactly what each thread is doing and what it is waiting for.
Java provides Thread.getAllStackTraces() to capture a programmatic thread dump. Let's use it to see every thread in your JVM at once.
- Create a new class in fundamentals called ThreadDumpDemo.
- Paste the following code:
package fundamentals;
import java.util.Map;
public class ThreadDumpDemo {
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// Create threads in various states for the dump
Thread sleeping = new Thread(() -> {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
// Expected when we interrupt during cleanup
}
}, "sleeping-thread");
Thread waiting = new Thread(() -> {
synchronized (lock) {
try {
// wait() puts thread in WAITING state
lock.wait();
} catch (InterruptedException e) {
// Expected when we interrupt during cleanup
}
}
}, "waiting-thread");
sleeping.start();
waiting.start();
// Give threads time to enter their states
Thread.sleep(200);
// Print a full thread dump
System.out.println("=== THREAD DUMP ===");
Map<Thread, StackTraceElement[]> allThreads = Thread.getAllStackTraces();
for (Map.Entry<Thread, StackTraceElement[]> entry : allThreads.entrySet()) {
Thread thread = entry.getKey();
StackTraceElement[] stackTrace = entry.getValue();
System.out.println("\nThread: " + thread.getName());
System.out.println(" State: " + thread.getState());
System.out.println(" Daemon: " + thread.isDaemon());
System.out.println(" Priority: " + thread.getPriority());
// Print the stack trace showing where the thread is executing
for (StackTraceElement element : stackTrace) {
System.out.println(" at " + element);
}
}
// Clean up by interrupting our threads
sleeping.interrupt();
waiting.interrupt();
sleeping.join();
waiting.join();
}
}
What does this code do?
- It creates two threads in different states: one sleeping (TIMED_WAITING) and one calling lock.wait() (WAITING).
- Thread.getAllStackTraces() returns a Map of every live thread to its current stack trace. This includes JVM internal threads like the garbage collector and finalizer.
- For each thread, we print its name, state, daemon flag, priority, and the full stack trace showing exactly which method it is currently inside.
- The cleanup section interrupts both threads so the program exits cleanly instead of hanging.
- Run ThreadDumpDemo.
You should see a wall of output. Look for your named threads: sleeping-thread in TIMED_WAITING and waiting-thread in WAITING. You will also see JVM-internal threads like Reference Handler and Finalizer.
How do I read a thread dump?
In production, thread dumps are your primary tool for diagnosing hangs, deadlocks, and performance issues. Here is what to look for:
- Thread name: tells you which logical task or request this thread handles. This is why naming your threads matters.
- State: tells you whether the thread is running, stuck on a lock, or deliberately waiting. Many threads in BLOCKED state is a red flag for lock contention.
- Stack trace: tells you exactly which line of code the thread is currently sitting on. The top of the stack is where the thread is right now.
- Lock information: in production JVM tools like jstack, you also see which locks a thread holds and which lock it is waiting to acquire. This reveals deadlock cycles.
Program hangs and does not exit?
If the program hangs after printing the dump, the interrupt calls may not have reached the threads before join() was called. Click the red stop button in IntelliJ's Run panel to kill the process. Then check that both interrupt() calls appear before the join() calls in your code.
Help me understand why my ThreadDumpDemo is hanging.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all five files: LifecycleDemo.java, BlockedStateDemo.java, WaitingStateDemo.java, TimedWaitingStateDemo.java, and ThreadDumpDemo.java.
ⓧ I'd like to double check the full code
Here are all five files in full for comparison.
package fundamentals;
public class LifecycleDemo {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
try {
// Thread is RUNNABLE while executing this line
System.out.println("Inside run(): " + Thread.currentThread().getState());
// Thread moves to TIMED_WAITING during sleep
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "lifecycle-thread");
// State: NEW (created but not yet started)
System.out.println("After creation: " + thread.getState());
// Transition: NEW -> RUNNABLE
thread.start();
// Give the thread a moment to enter sleep
Thread.sleep(50);
// State: should be TIMED_WAITING (inside sleep)
System.out.println("After sleep starts: " + thread.getState());
// Wait for the thread to finish
thread.join();
// State: TERMINATED (run() completed)
System.out.println("After completion: " + thread.getState());
}
}
package fundamentals;
public class BlockedStateDemo {
// Shared lock object that both threads will contend for
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// Thread 1 acquires the lock and holds it
Thread holder = new Thread(() -> {
synchronized (lock) {
try {
// Hold the lock for 3 seconds so the other thread blocks
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "lock-holder");
// Thread 2 tries to acquire the same lock
Thread waiter = new Thread(() -> {
synchronized (lock) {
// This only runs after holder releases the lock
System.out.println("Waiter acquired the lock!");
}
}, "lock-waiter");
holder.start();
// Give holder time to acquire the lock
Thread.sleep(100);
waiter.start();
// Give waiter time to attempt lock acquisition
Thread.sleep(100);
// Waiter should be BLOCKED (waiting to enter synchronized block)
System.out.println("Waiter state: " + waiter.getState());
holder.join();
waiter.join();
}
}
package fundamentals;
public class WaitingStateDemo {
public static void main(String[] args) throws InterruptedException {
// A long-running thread that the joiner will wait for
Thread longTask = new Thread(() -> {
try {
// Simulate a long task
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "long-task");
// This thread calls join() on longTask without a timeout
Thread joiner = new Thread(() -> {
try {
// join() with no timeout puts this thread into WAITING state
longTask.join();
System.out.println("Long task finished, joiner resumes.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "joiner-thread");
longTask.start();
joiner.start();
// Give joiner time to call join()
Thread.sleep(100);
// Joiner should be WAITING (blocked on join with no timeout)
System.out.println("Joiner state: " + joiner.getState());
longTask.join();
joiner.join();
}
}
package fundamentals;
public class TimedWaitingStateDemo {
public static void main(String[] args) throws InterruptedException {
Thread sleeper = new Thread(() -> {
try {
// sleep() puts the thread into TIMED_WAITING state
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "sleeper-thread");
sleeper.start();
// Give the thread time to enter sleep
Thread.sleep(100);
// Sleeper should be TIMED_WAITING
System.out.println("Sleeper state: " + sleeper.getState());
sleeper.join();
// After completion, the state changes to TERMINATED
System.out.println("Sleeper state after completion: " + sleeper.getState());
}
}
package fundamentals;
import java.util.Map;
public class ThreadDumpDemo {
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// Create threads in various states for the dump
Thread sleeping = new Thread(() -> {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
// Expected when we interrupt during cleanup
}
}, "sleeping-thread");
Thread waiting = new Thread(() -> {
synchronized (lock) {
try {
// wait() puts thread in WAITING state
lock.wait();
} catch (InterruptedException e) {
// Expected when we interrupt during cleanup
}
}
}, "waiting-thread");
sleeping.start();
waiting.start();
// Give threads time to enter their states
Thread.sleep(200);
// Print a full thread dump
System.out.println("=== THREAD DUMP ===");
Map<Thread, StackTraceElement[]> allThreads = Thread.getAllStackTraces();
for (Map.Entry<Thread, StackTraceElement[]> entry : allThreads.entrySet()) {
Thread thread = entry.getKey();
StackTraceElement[] stackTrace = entry.getValue();
System.out.println("\nThread: " + thread.getName());
System.out.println(" State: " + thread.getState());
System.out.println(" Daemon: " + thread.isDaemon());
System.out.println(" Priority: " + thread.getPriority());
// Print the stack trace showing where the thread is executing
for (StackTraceElement element : stackTrace) {
System.out.println(" at " + element);
}
}
// Clean up by interrupting our threads
sleeping.interrupt();
waiting.interrupt();
sleeping.join();
waiting.join();
}
}
You can now observe and reason about thread state at any point in a program's execution. Next up, you will learn what happens when two threads access shared data without coordination. The results are broken in ways that are invisible without the right knowledge.
Synchronize Shared State
In the previous step, you observed thread states and transitions as threads moved through their lifecycle. Now you understand what threads do. The next question is: what happens when two threads touch the same data at the same time?
The answer is: things break in subtle, unpredictable ways. In this step, you will see a race condition happen in real time, then learn the mechanisms Java provides to prevent it.
In this step, get ready to:
- Demonstrate a race condition with unsynchronized shared state.
- Fix race conditions using synchronized methods and synchronized blocks.
- Explore reentrant locking and common lock mistakes.
Demonstrate a race condition
Before you can fix concurrency bugs, you need to see one happen. The most common concurrency bug is called thread interference.
What is thread interference?
Thread interference occurs when two threads perform operations on shared data and their operations interleave. The classic case is the read-modify-write problem.
Consider the innocent-looking expression count++. It looks like one operation, but it is actually three: read the current value of count from memory, add 1 to it, then write the result back. If two threads execute these three steps at overlapping times, one thread's update gets overwritten by the other.
For example: Thread A reads count (value 5), Thread B reads count (still 5), Thread A writes 6, Thread B writes 6. One increment is lost. The counter should be 7, but it is 6.
What are memory consistency errors?
Memory consistency errors occur when different threads have inconsistent views of the same data. A thread may write a value to a field, but another thread continues seeing the old value because it is reading from its CPU cache rather than main memory.
Without synchronization, the Java Memory Model does not guarantee that writes by one thread are visible to another. You will explore this in depth in a later step. For now, know that synchronization solves both interference and visibility.
Let's make thread interference visible with a program that breaks reliably.
- In IntelliJ, right-click the synchronization package and select New then Java Class.
- Name it RaceConditionDemo and press Enter.
- Replace the file contents with this code:
package synchronization;
public class RaceConditionDemo {
// Shared counter with no synchronization
private int count = 0;
public void increment() {
count++; // NOT atomic: read -> modify -> write
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
RaceConditionDemo demo = new RaceConditionDemo();
// Two threads each increment 100,000 times
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.increment();
}
});
t1.start();
t2.start();
// Wait for both to finish
t1.join();
t2.join();
// Expected: 200,000. Actual: less due to race condition
System.out.println("Expected: 200000");
System.out.println("Actual: " + demo.getCount());
}
}
What does this code do?
Two threads each call increment() 100,000 times on the same object. If count++ were atomic, the final count would always be 200,000.
Because it is not atomic, the threads' read-modify-write operations interleave. Some increments are lost, so the final count is less than 200,000. The exact number varies on every run because thread scheduling is non-deterministic.
- Run RaceConditionDemo by clicking the green play button next to the main method.
- Note the actual count printed in the console. It should be less than 200,000.
- Run it 3-4 more times. Notice that the result is different every time.
Seeing exactly 200000 every time?
On some machines with specific JVM optimizations, the race condition may not manifest every run. Try increasing the loop count to 1_000_000 in both threads to make the race more likely to appear.
If you still see 200000 consistently, that does not mean the code is safe. It means you got lucky. The bug is still there. On a different machine, a different day, or under different load, it will surface.
help me understand why my race condition program shows 200000 every time
Fix race conditions with synchronization
You have seen the problem. Now you will learn the solution: the Java monitor.
What is a monitor?
Every Java object has an associated monitor (also called an intrinsic lock or monitor lock). A monitor enforces mutual exclusion: only one thread can hold an object's monitor at a time.
When a thread enters a synchronized method or block, it acquires the monitor. Any other thread that tries to enter a synchronized region protected by the same monitor is blocked until the first thread releases it.
This creates a critical section: a block of code that only one thread can execute at a time. The read-modify-write of count++ becomes safe because no two threads can interleave within the critical section.
How does synchronized work?
There are two forms. A synchronized instance method locks on this (the current object). A synchronized static method locks on the Class object.
A synchronized block lets you choose which object to lock on. This gives you finer control over the scope of synchronization. Synchronizing on a dedicated private lock object is often preferred because it prevents external code from accidentally acquiring your lock.
First, you will fix the race condition using synchronized methods.
- Create a new Java class in the synchronization package called SynchronizedMethodFix.
- Replace the file contents with this code:
package synchronization;
public class SynchronizedMethodFix {
private int count = 0;
// synchronized locks on 'this' - only one thread can execute at a time
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
SynchronizedMethodFix demo = new SynchronizedMethodFix();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// Always prints 200,000 - synchronized guarantees correctness
System.out.println("Expected: 200000");
System.out.println("Actual: " + demo.getCount());
}
}
What does this code do?
The synchronized keyword on increment() means the thread must acquire this object's monitor before entering the method. Only one thread can hold that monitor at a time, so the read-modify-write of count is now exclusive.
Notice that getCount() is also synchronized. This ensures the reading thread sees the most recent write. Without it, visibility is not guaranteed.
- Run SynchronizedMethodFix multiple times.
- You should see Actual: 200000 printed every single time.
Now let's see the second approach: a synchronized block with a dedicated lock object.
- Create a new Java class in the synchronization package called SynchronizedBlockFix.
- Replace the file contents with this code:
package synchronization;
public class SynchronizedBlockFix {
private int count = 0;
// Dedicated lock object - only used for synchronization
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
synchronized (lock) {
return count;
}
}
public static void main(String[] args) throws InterruptedException {
SynchronizedBlockFix demo = new SynchronizedBlockFix();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// Always prints 200,000 - synchronized block on dedicated lock
System.out.println("Expected: 200000");
System.out.println("Actual: " + demo.getCount());
}
}
Why use a dedicated lock object?
Synchronizing on this exposes your lock to the outside world. Any code with a reference to your object can synchronize on it too, potentially causing unexpected contention or deadlock.
A private final Object lock is invisible outside the class. It is final to prevent anyone from reassigning it, which would create a subtle bug where threads synchronize on different objects.
What is the cost of synchronization?
Synchronization is not free. When a thread attempts to acquire a lock that another thread holds, it enters the BLOCKED state. The OS must perform a context switch to resume it later. Under high contention (many threads competing for the same lock), throughput drops because threads spend more time waiting than working.
This is why you should synchronize on the smallest possible block of code. Keep the critical section short to minimize the time other threads are blocked. Amdahl's Law applies directly here: the more code that runs under a lock, the less parallelism you get.
- Run SynchronizedBlockFix and confirm it also prints Actual: 200000 consistently.
Seeing a compilation error?
Make sure the lock field is declared as private final Object lock = new Object() inside the class body, not inside a method.
Also check that your synchronized block uses parentheses: synchronized (lock) not synchronized lock.
help me fix my SynchronizedBlockFix compilation error
Explore reentrant locking and lock mistakes
You now know that synchronized blocks enforce mutual exclusion. But what happens when a thread that already holds a lock tries to enter another synchronized region on the same object?
What is reentrant locking?
Java's intrinsic locks are reentrant. This means a thread that already holds an object's monitor can enter another synchronized method or block protected by the same monitor without blocking itself.
The monitor maintains an acquisition count. Each time the thread enters a synchronized region on that object, the count increments. Each time it exits, the count decrements. The lock is only truly released when the count reaches zero.
Without reentrancy, a synchronized method calling another synchronized method on the same object would deadlock. The thread would wait forever for a lock it already holds.
- Create a new Java class in the synchronization package called ReentrantLockDemo.
- Replace the file contents with this code:
package synchronization;
public class ReentrantLockDemo {
private int count = 0;
// First synchronized method
public synchronized void incrementAndPrint() {
count++;
// Calls another synchronized method on the same object
printCount();
}
// Second synchronized method - same lock (this)
public synchronized void printCount() {
System.out.println("Count: " + count + " [" + Thread.currentThread().getName() + "]");
}
public static void main(String[] args) {
ReentrantLockDemo demo = new ReentrantLockDemo();
// If locks were NOT reentrant, this would deadlock
// Thread holds 'this' lock in incrementAndPrint(),
// then tries to acquire 'this' lock again in printCount()
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
demo.incrementAndPrint();
}
}, "Worker-1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
demo.incrementAndPrint();
}
}, "Worker-2");
t1.start();
t2.start();
}
}
What does this code do?
The method incrementAndPrint() acquires the lock on this, increments the counter, then calls printCount() which also requires the lock on this.
Because the lock is reentrant, the same thread can re-acquire it. The lock's internal count goes from 1 to 2 when entering printCount(), then back to 1 on exit, then to 0 when incrementAndPrint() returns.
- Run ReentrantLockDemo and observe the output.
- You should see 10 lines of output showing both workers printing interleaved count values. The program completes without deadlocking.
Now let's demonstrate a common synchronization mistake: using different lock objects.
Common synchronization mistakes
Synchronization only provides mutual exclusion when threads compete for the same lock. If Thread A synchronizes on object X and Thread B synchronizes on object Y, they can both execute their critical sections simultaneously.
Other common mistakes include: synchronizing on mutable references (the reference can change, so threads lock on different objects), synchronizing on String literals (they are interned and shared across the JVM), and synchronizing on boxed primitives like Integer (small values are cached and shared).
- Create a new Java class in the synchronization package called WrongLockDemo.
- Replace the file contents with this code:
package synchronization;
public class WrongLockDemo {
private int count = 0;
// Two DIFFERENT lock objects - this is the bug
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public void incrementWithLock1() {
synchronized (lock1) {
count++; // Synchronized on lock1
}
}
public void incrementWithLock2() {
synchronized (lock2) {
count++; // Synchronized on lock2 - different lock!
}
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
WrongLockDemo demo = new WrongLockDemo();
// Thread 1 uses lock1, Thread 2 uses lock2
// They do NOT exclude each other - race condition still exists
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.incrementWithLock1();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.incrementWithLock2();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// Still inconsistent - different locks provide no mutual exclusion
System.out.println("Expected: 200000");
System.out.println("Actual: " + demo.getCount());
}
}
What does this code do?
Both methods synchronize, but on different objects. Thread 1 acquires lock1 and Thread 2 acquires lock2. These are two separate monitors. Neither thread ever blocks the other.
The result is identical to having no synchronization at all. The race condition reappears because the critical sections are not actually exclusive. Both threads can execute count++ simultaneously.
- Run WrongLockDemo several times.
- You should see the actual count come in below 200,000, just like RaceConditionDemo. The synchronized keyword is there, but the protection is meaningless because the locks are different.
WrongLockDemo showing 200000?
Same situation as before. On some machines under low load, the JVM may happen to serialize the operations by chance. Increase both loops to 1_000_000 to create more opportunity for interleaving.
The key takeaway is structural, not statistical: two different lock objects cannot protect the same shared state.
help me understand why using different lock objects breaks synchronization
✔️ Awesome, I've got everything!
Great. Double check you have saved all five files in the synchronization package.
ⓧ I'd like to double check the full code
Here are all five files in the synchronization package.
package synchronization;
public class RaceConditionDemo {
// Shared counter with no synchronization
private int count = 0;
public void increment() {
count++; // NOT atomic: read -> modify -> write
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
RaceConditionDemo demo = new RaceConditionDemo();
// Two threads each increment 100,000 times
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.increment();
}
});
t1.start();
t2.start();
// Wait for both to finish
t1.join();
t2.join();
// Expected: 200,000. Actual: less due to race condition
System.out.println("Expected: 200000");
System.out.println("Actual: " + demo.getCount());
}
}
package synchronization;
public class SynchronizedMethodFix {
private int count = 0;
// synchronized locks on 'this' - only one thread can execute at a time
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
SynchronizedMethodFix demo = new SynchronizedMethodFix();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// Always prints 200,000 - synchronized guarantees correctness
System.out.println("Expected: 200000");
System.out.println("Actual: " + demo.getCount());
}
}
package synchronization;
public class SynchronizedBlockFix {
private int count = 0;
// Dedicated lock object - only used for synchronization
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
synchronized (lock) {
return count;
}
}
public static void main(String[] args) throws InterruptedException {
SynchronizedBlockFix demo = new SynchronizedBlockFix();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// Always prints 200,000 - synchronized block on dedicated lock
System.out.println("Expected: 200000");
System.out.println("Actual: " + demo.getCount());
}
}
package synchronization;
public class ReentrantLockDemo {
private int count = 0;
// First synchronized method
public synchronized void incrementAndPrint() {
count++;
// Calls another synchronized method on the same object
printCount();
}
// Second synchronized method - same lock (this)
public synchronized void printCount() {
System.out.println("Count: " + count + " [" + Thread.currentThread().getName() + "]");
}
public static void main(String[] args) {
ReentrantLockDemo demo = new ReentrantLockDemo();
// If locks were NOT reentrant, this would deadlock
// Thread holds 'this' lock in incrementAndPrint(),
// then tries to acquire 'this' lock again in printCount()
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
demo.incrementAndPrint();
}
}, "Worker-1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
demo.incrementAndPrint();
}
}, "Worker-2");
t1.start();
t2.start();
}
}
package synchronization;
public class WrongLockDemo {
private int count = 0;
// Two DIFFERENT lock objects - this is the bug
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public void incrementWithLock1() {
synchronized (lock1) {
count++; // Synchronized on lock1
}
}
public void incrementWithLock2() {
synchronized (lock2) {
count++; // Synchronized on lock2 - different lock!
}
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
WrongLockDemo demo = new WrongLockDemo();
// Thread 1 uses lock1, Thread 2 uses lock2
// They do NOT exclude each other - race condition still exists
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.incrementWithLock1();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
demo.incrementWithLock2();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// Still inconsistent - different locks provide no mutual exclusion
System.out.println("Expected: 200000");
System.out.println("Actual: " + demo.getCount());
}
}
You now have five programs in the synchronization package that demonstrate the core mechanics of Java's intrinsic locks. Next, you will build on these foundations to explore how threads communicate with each other using wait and notify.
Implement Inter-Thread Communication
In the previous step, you mastered synchronization and monitors. You can now protect shared data from concurrent access using synchronized blocks and methods. But there is a missing piece.
Synchronization prevents threads from corrupting shared data, but it does not allow threads to coordinate with each other. What if one thread needs to wait for another thread to finish producing a result before it can proceed? Without a communication mechanism, the waiting thread would have to repeatedly check a flag in a loop, burning CPU cycles for nothing. Java provides a built-in communication protocol through wait() and notify() that lets threads efficiently signal each other.
In this step, get ready to:
- Write a guarded block program that demonstrates wait/notify communication between two threads.
- Implement the classic producer-consumer pattern with a bounded buffer.
- Explore edge cases including IllegalMonitorStateException, notify vs. notifyAll, and spurious wakeup handling.
Write the guarded block demo
Imagine a thread that needs data produced by another thread. The simplest approach is a busy-wait loop that checks a flag over and over. This is wasteful because the checking thread consumes 100% of a CPU core doing nothing useful, and it may even prevent the producing thread from running.
What is busy-waiting and why is it bad?
Busy-waiting (also called spin-waiting) is a pattern where a thread continuously checks a condition in a loop without releasing the CPU. It looks like while (!ready) { }. The thread burns CPU cycles on every iteration even though nothing has changed.
Java solves this with the wait/notify mechanism. When a thread calls wait(), it releases the lock, enters the WAITING state, and consumes zero CPU until another thread signals it. This is cooperative, efficient communication.
How does the wait/notify mechanism work?
Every Java object has an associated monitor with a wait set. When a thread calls wait() on an object, three things happen: the thread releases the object's monitor lock, the thread is added to the object's wait set, and the thread moves to the WAITING state.
When another thread calls notify() on the same object, one arbitrary thread is removed from the wait set and becomes eligible to re-acquire the lock. Calling notifyAll() wakes all threads in the wait set. The woken thread must re-acquire the lock before it can continue executing after the wait() call.
The critical rule: wait() must be called inside a synchronized block on the same object you are calling wait on. Otherwise Java throws IllegalMonitorStateException. This makes sense because you need to own the monitor to release it.
Why must wait() be inside a while loop?
The JVM specification allows spurious wakeups. A thread can wake up from wait() without being notified by another thread. This is a low-level OS behavior that Java exposes rather than hiding.
If you use if (!condition) { wait(); }, a spurious wakeup causes the thread to proceed even though the condition is still false. The correct pattern is while (!condition) { wait(); }. The while loop re-checks the condition after every wakeup, regardless of why the thread woke up.
A guarded block is the simplest application of wait/notify. One thread waits until a condition (the guard) becomes true, while another thread makes it true and sends a notification.
- In IntelliJ, right-click the communication package and create a new Java class called GuardedBlockDemo.
- Add the following code:
package communication;
public class GuardedBlockDemo {
// Shared flag that one thread waits on
private boolean ready = false;
// Method that waits until the flag is set
public synchronized void waitForReady() throws InterruptedException {
// Must use while loop, not if, to handle spurious wakeups
while (!ready) {
System.out.println(Thread.currentThread().getName() + " - Flag is false, waiting...");
wait(); // Releases the lock and enters WAITING state
}
System.out.println(Thread.currentThread().getName() + " - Flag is true, proceeding!");
}
// Method that sets the flag and notifies waiting threads
public synchronized void setReady() {
ready = true;
System.out.println(Thread.currentThread().getName() + " - Setting flag to true and notifying.");
notify(); // Wakes up one thread from the wait set
}
public static void main(String[] args) {
GuardedBlockDemo demo = new GuardedBlockDemo();
// Thread that waits for the flag
Thread waiter = new Thread(() -> {
try {
demo.waitForReady();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Waiter");
// Thread that sets the flag after a delay
Thread setter = new Thread(() -> {
try {
Thread.sleep(2000); // Simulate some work
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
demo.setReady();
}, "Setter");
waiter.start();
setter.start();
}
}
What does this code do?
The waitForReady() method demonstrates a guarded block. The thread enters a while loop checking the ready flag. If it is false, the thread calls wait() which releases the monitor lock and puts the thread to sleep.
The setReady() method sets the flag to true, then calls notify(). This wakes the waiting thread, which re-acquires the lock, re-checks the while condition, finds it true, and proceeds past the loop.
- Run GuardedBlockDemo by right-clicking the file and selecting Run.
- You should see the Waiter thread print that it is waiting, then after a 2-second pause, the Setter thread sets the flag and the Waiter proceeds.
Program hangs forever?
If the program never terminates, check that both waitForReady() and setReady() are synchronized on the same object (the demo instance). If they synchronize on different objects, the notify will never reach the waiting thread.
help me understand why my GuardedBlockDemo hangs
What is the lost notification problem?
If notify() is called before any thread calls wait(), the notification is lost forever. There is no "pending notification" queue. The notify simply has no effect because no thread is in the wait set at that moment.
This is why the guarded block pattern checks the condition before waiting. If the condition is already true (meaning the notification already happened), the thread never enters wait() in the first place.
Implement the producer-consumer pattern
The producer-consumer pattern is the most important real-world application of wait/notify. A producer thread generates data and places it in a shared buffer. A consumer thread takes data from the buffer and processes it.
Why is the producer-consumer pattern so useful?
The pattern decouples the production rate from the consumption rate. The producer does not need to wait for the consumer to finish processing before generating the next item. The bounded buffer acts as a shock absorber between the two.
Two coordination points are needed. Producers must wait when the buffer is full (no space to add items). Consumers must wait when the buffer is empty (no items to take). Both use notifyAll() after modifying the buffer to wake threads that might be waiting for space or items.
- In the communication package, create a new Java class called ProducerConsumerDemo.
- Add the following code:
package communication;
import java.util.LinkedList;
import java.util.Queue;
public class ProducerConsumerDemo {
private final Queue<Integer> buffer = new LinkedList<>();
private final int capacity = 5;
// Producer adds items to the buffer
public synchronized void produce(int item) throws InterruptedException {
// Wait while buffer is full
while (buffer.size() == capacity) {
System.out.println("Producer waiting - buffer is full");
wait();
}
buffer.add(item);
System.out.println("Produced: " + item + " | Buffer size: " + buffer.size());
// Notify all waiting threads (consumers that might be waiting for items)
notifyAll();
}
// Consumer removes items from the buffer
public synchronized int consume() throws InterruptedException {
// Wait while buffer is empty
while (buffer.isEmpty()) {
System.out.println("Consumer waiting - buffer is empty");
wait();
}
int item = buffer.poll();
System.out.println("Consumed: " + item + " | Buffer size: " + buffer.size());
// Notify all waiting threads (producers that might be waiting for space)
notifyAll();
return item;
}
public static void main(String[] args) {
ProducerConsumerDemo demo = new ProducerConsumerDemo();
// Producer thread generates 20 items
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 20; i++) {
demo.produce(i);
Thread.sleep(100); // Simulate production time
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Producer");
// Consumer thread consumes items
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
demo.consume();
Thread.sleep(300); // Consumer is slower than producer
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Consumer");
producer.start();
consumer.start();
}
}
What does this code do?
The produce() method checks if the buffer is full. If it is, the producer calls wait() and sleeps until a consumer removes an item and calls notifyAll(). After adding an item, the producer calls notifyAll() to wake any sleeping consumers.
The consume() method mirrors this logic. It waits when the buffer is empty, takes an item when available, then notifies waiting producers. The consumer sleeps 300ms between takes while the producer sleeps only 100ms, so you will observe the buffer filling up and the producer occasionally waiting.
Why notifyAll() instead of notify() here?
With multiple producers and consumers sharing the same lock, notify() wakes one arbitrary thread. It might wake a producer when only consumers are needed (because the buffer is full), or vice versa. The wrong thread wakes up, finds its condition still false, goes back to sleep, and the system hangs.
Using notifyAll() wakes all waiting threads. Each re-checks its condition in the while loop. Threads that cannot proceed go back to sleep. Threads that can proceed continue. This is less efficient but always correct.
- Run ProducerConsumerDemo.
- Watch the output carefully. You should see the producer filling the buffer until it reaches capacity 5, then waiting. The consumer drains items, notifying the producer to resume.
Output looks garbled or interleaved?
This is normal and expected. The console output from multiple threads can interleave between print statements. What matters is that the buffer size never exceeds 5 and never goes below 0. Focus on the buffer size numbers to verify correctness.
help me understand my ProducerConsumerDemo output
Explore common pitfalls and edge cases
Three common mistakes trip up developers working with wait/notify. You will now write a program for each one to see exactly what goes wrong and how to handle it correctly.
- In the communication package, create a new Java class called WaitOutsideSyncDemo.
- Add the following code:
package communication;
public class WaitOutsideSyncDemo {
public static void main(String[] args) {
Object lock = new Object();
// Attempt to call wait() without holding the monitor
try {
lock.wait(); // This will throw IllegalMonitorStateException
} catch (IllegalMonitorStateException e) {
System.out.println("Caught IllegalMonitorStateException!");
System.out.println("Message: " + e.getMessage());
System.out.println("You must call wait() inside a synchronized block on the same object.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Correct usage for comparison
System.out.println("\nCorrect usage:");
synchronized (lock) {
System.out.println("Inside synchronized block - calling wait() here is legal.");
// We will not actually wait because nobody will notify us
}
}
}
What does this code do?
This program deliberately calls wait() outside a synchronized block to trigger an IllegalMonitorStateException. The exception occurs because the thread does not own the object's monitor lock. You cannot release a lock you do not hold.
- Run WaitOutsideSyncDemo.
- You should see the caught exception message followed by the correct usage confirmation.
Next, you will observe why notifyAll() is generally safer than notify() when multiple threads wait on different conditions.
- In the communication package, create a new Java class called NotifyVsNotifyAllDemo.
- Add the following code:
package communication;
public class NotifyVsNotifyAllDemo {
private final Object lock = new Object();
private String availableResource = null;
// Thread that waits for a specific resource type
public void waitForResource(String needed) throws InterruptedException {
synchronized (lock) {
// Each thread waits for its specific resource
while (availableResource == null || !availableResource.equals(needed)) {
System.out.println(Thread.currentThread().getName()
+ " waiting for: " + needed);
lock.wait();
// After waking, check if this is the resource we need
if (availableResource != null && !availableResource.equals(needed)) {
System.out.println(Thread.currentThread().getName()
+ " woke up but needs " + needed + ", not " + availableResource);
}
}
System.out.println(Thread.currentThread().getName()
+ " got resource: " + availableResource);
availableResource = null; // Consume the resource
}
}
// Provides a resource and wakes waiting threads
public void provideResource(String resource, boolean useNotifyAll) {
synchronized (lock) {
availableResource = resource;
System.out.println("\n--- Providing resource: " + resource
+ " (using " + (useNotifyAll ? "notifyAll" : "notify") + ") ---");
if (useNotifyAll) {
lock.notifyAll(); // Wakes ALL waiting threads
} else {
lock.notify(); // Wakes only ONE (arbitrary) waiting thread
}
}
}
public static void main(String[] args) throws InterruptedException {
NotifyVsNotifyAllDemo demo = new NotifyVsNotifyAllDemo();
// Create threads waiting for different resources
Thread t1 = new Thread(() -> {
try { demo.waitForResource("DISK"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "Thread-DISK-1");
Thread t2 = new Thread(() -> {
try { demo.waitForResource("NETWORK"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "Thread-NETWORK");
Thread t3 = new Thread(() -> {
try { demo.waitForResource("DISK"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "Thread-DISK-2");
t1.start();
t2.start();
t3.start();
Thread.sleep(1000); // Let all threads start waiting
// Using notifyAll - all threads wake up and check the condition
demo.provideResource("NETWORK", true);
Thread.sleep(1000);
// Provide DISK resources for the remaining threads
demo.provideResource("DISK", true);
Thread.sleep(1000);
demo.provideResource("DISK", true);
t1.join();
t2.join();
t3.join();
System.out.println("\nAll threads completed.");
}
}
What does this code do?
Three threads wait on the same lock, but for different conditions. Thread-DISK-1 and Thread-DISK-2 need a "DISK" resource. Thread-NETWORK needs a "NETWORK" resource.
When notifyAll() is used, all three threads wake up. Only the one whose condition matches proceeds. The others see that the resource is not what they need and go back to sleep. If notify() were used instead, it might wake a DISK thread when a NETWORK resource is available. That thread cannot use it, goes back to sleep, and the NETWORK thread never gets woken.
- Run NotifyVsNotifyAllDemo.
- Observe how threads that wake up for the wrong resource type print a message and go back to waiting. All threads eventually complete because notifyAll ensures every thread gets a chance to check its condition.
Finally, you will write a program that demonstrates the correct way to guard against spurious wakeups.
- In the communication package, create a new Java class called SpuriousWakeupDemo.
- Add the following code:
package communication;
public class SpuriousWakeupDemo {
private boolean conditionMet = false;
// WRONG: Using if (vulnerable to spurious wakeups)
public synchronized void waitWithIf() throws InterruptedException {
if (!conditionMet) {
// BUG: if the thread wakes spuriously, it proceeds without the condition being true
System.out.println(Thread.currentThread().getName() + " - Waiting with IF...");
wait();
}
// After a spurious wakeup, this executes even though conditionMet might still be false
System.out.println(Thread.currentThread().getName()
+ " - Proceeded. conditionMet = " + conditionMet);
}
// CORRECT: Using while (safe against spurious wakeups)
public synchronized void waitWithWhile() throws InterruptedException {
while (!conditionMet) {
// Re-checks condition after every wakeup
System.out.println(Thread.currentThread().getName() + " - Waiting with WHILE...");
wait();
}
// Only reaches here when conditionMet is truly true
System.out.println(Thread.currentThread().getName()
+ " - Proceeded. conditionMet = " + conditionMet);
}
public synchronized void setCondition() {
conditionMet = true;
System.out.println(Thread.currentThread().getName() + " - Condition set to true, notifying all.");
notifyAll();
}
public static void main(String[] args) throws InterruptedException {
System.out.println("=== Demonstrating correct spurious wakeup handling ===");
System.out.println("The JVM specification allows threads to wake from wait()");
System.out.println("without being notified. Always use a while loop.\n");
SpuriousWakeupDemo demo = new SpuriousWakeupDemo();
// Thread using the correct while-loop pattern
Thread correctThread = new Thread(() -> {
try {
demo.waitWithWhile();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "CorrectThread");
correctThread.start();
Thread.sleep(1000);
// Set condition and notify
demo.setCondition();
correctThread.join();
System.out.println("\nKey takeaway: Always use while(!condition) before wait(),");
System.out.println("never if(!condition). The while loop re-checks the condition");
System.out.println("every time the thread wakes up, whether notified or spurious.");
}
}
What does this code do?
This program defines two approaches. The waitWithIf() method uses an if statement, which is vulnerable. If a spurious wakeup occurs, the thread skips past the check without the condition being true.
The waitWithWhile() method uses a while loop, which is correct. Every time the thread wakes up (whether from a genuine notification or a spurious wakeup), it re-evaluates the condition. The thread only proceeds when the condition is actually true.
- Run SpuriousWakeupDemo.
- You should see the CorrectThread print that it is waiting, then proceed only after the condition is set to true.
Don't see an actual spurious wakeup?
Spurious wakeups are rare in practice and cannot be forced on demand. The point of this demo is to show the correct defensive pattern. Even if a spurious wakeup never happens on your machine today, the JVM specification permits it. Production code must always guard against it with a while loop.
help me understand spurious wakeups in Java
✔️ Awesome, I've got everything!
Great. Double check you have saved all five files in the communication package.
ⓧ I'd like to double check the full code
Here are all five files. Compare each one against your code in IntelliJ.
package communication;
public class GuardedBlockDemo {
// Shared flag that one thread waits on
private boolean ready = false;
// Method that waits until the flag is set
public synchronized void waitForReady() throws InterruptedException {
// Must use while loop, not if, to handle spurious wakeups
while (!ready) {
System.out.println(Thread.currentThread().getName() + " - Flag is false, waiting...");
wait(); // Releases the lock and enters WAITING state
}
System.out.println(Thread.currentThread().getName() + " - Flag is true, proceeding!");
}
// Method that sets the flag and notifies waiting threads
public synchronized void setReady() {
ready = true;
System.out.println(Thread.currentThread().getName() + " - Setting flag to true and notifying.");
notify(); // Wakes up one thread from the wait set
}
public static void main(String[] args) {
GuardedBlockDemo demo = new GuardedBlockDemo();
// Thread that waits for the flag
Thread waiter = new Thread(() -> {
try {
demo.waitForReady();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Waiter");
// Thread that sets the flag after a delay
Thread setter = new Thread(() -> {
try {
Thread.sleep(2000); // Simulate some work
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
demo.setReady();
}, "Setter");
waiter.start();
setter.start();
}
}
package communication;
import java.util.LinkedList;
import java.util.Queue;
public class ProducerConsumerDemo {
private final Queue<Integer> buffer = new LinkedList<>();
private final int capacity = 5;
// Producer adds items to the buffer
public synchronized void produce(int item) throws InterruptedException {
// Wait while buffer is full
while (buffer.size() == capacity) {
System.out.println("Producer waiting - buffer is full");
wait();
}
buffer.add(item);
System.out.println("Produced: " + item + " | Buffer size: " + buffer.size());
// Notify all waiting threads (consumers that might be waiting for items)
notifyAll();
}
// Consumer removes items from the buffer
public synchronized int consume() throws InterruptedException {
// Wait while buffer is empty
while (buffer.isEmpty()) {
System.out.println("Consumer waiting - buffer is empty");
wait();
}
int item = buffer.poll();
System.out.println("Consumed: " + item + " | Buffer size: " + buffer.size());
// Notify all waiting threads (producers that might be waiting for space)
notifyAll();
return item;
}
public static void main(String[] args) {
ProducerConsumerDemo demo = new ProducerConsumerDemo();
// Producer thread generates 20 items
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 20; i++) {
demo.produce(i);
Thread.sleep(100); // Simulate production time
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Producer");
// Consumer thread consumes items
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
demo.consume();
Thread.sleep(300); // Consumer is slower than producer
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Consumer");
producer.start();
consumer.start();
}
}
package communication;
public class WaitOutsideSyncDemo {
public static void main(String[] args) {
Object lock = new Object();
// Attempt to call wait() without holding the monitor
try {
lock.wait(); // This will throw IllegalMonitorStateException
} catch (IllegalMonitorStateException e) {
System.out.println("Caught IllegalMonitorStateException!");
System.out.println("Message: " + e.getMessage());
System.out.println("You must call wait() inside a synchronized block on the same object.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Correct usage for comparison
System.out.println("\nCorrect usage:");
synchronized (lock) {
System.out.println("Inside synchronized block - calling wait() here is legal.");
// We will not actually wait because nobody will notify us
}
}
}
package communication;
public class NotifyVsNotifyAllDemo {
private final Object lock = new Object();
private String availableResource = null;
// Thread that waits for a specific resource type
public void waitForResource(String needed) throws InterruptedException {
synchronized (lock) {
// Each thread waits for its specific resource
while (availableResource == null || !availableResource.equals(needed)) {
System.out.println(Thread.currentThread().getName()
+ " waiting for: " + needed);
lock.wait();
// After waking, check if this is the resource we need
if (availableResource != null && !availableResource.equals(needed)) {
System.out.println(Thread.currentThread().getName()
+ " woke up but needs " + needed + ", not " + availableResource);
}
}
System.out.println(Thread.currentThread().getName()
+ " got resource: " + availableResource);
availableResource = null; // Consume the resource
}
}
// Provides a resource and wakes waiting threads
public void provideResource(String resource, boolean useNotifyAll) {
synchronized (lock) {
availableResource = resource;
System.out.println("\n--- Providing resource: " + resource
+ " (using " + (useNotifyAll ? "notifyAll" : "notify") + ") ---");
if (useNotifyAll) {
lock.notifyAll(); // Wakes ALL waiting threads
} else {
lock.notify(); // Wakes only ONE (arbitrary) waiting thread
}
}
}
public static void main(String[] args) throws InterruptedException {
NotifyVsNotifyAllDemo demo = new NotifyVsNotifyAllDemo();
// Create threads waiting for different resources
Thread t1 = new Thread(() -> {
try { demo.waitForResource("DISK"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "Thread-DISK-1");
Thread t2 = new Thread(() -> {
try { demo.waitForResource("NETWORK"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "Thread-NETWORK");
Thread t3 = new Thread(() -> {
try { demo.waitForResource("DISK"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "Thread-DISK-2");
t1.start();
t2.start();
t3.start();
Thread.sleep(1000); // Let all threads start waiting
// Using notifyAll - all threads wake up and check the condition
demo.provideResource("NETWORK", true);
Thread.sleep(1000);
// Provide DISK resources for the remaining threads
demo.provideResource("DISK", true);
Thread.sleep(1000);
demo.provideResource("DISK", true);
t1.join();
t2.join();
t3.join();
System.out.println("\nAll threads completed.");
}
}
package communication;
public class SpuriousWakeupDemo {
private boolean conditionMet = false;
// WRONG: Using if (vulnerable to spurious wakeups)
public synchronized void waitWithIf() throws InterruptedException {
if (!conditionMet) {
// BUG: if the thread wakes spuriously, it proceeds without the condition being true
System.out.println(Thread.currentThread().getName() + " - Waiting with IF...");
wait();
}
// After a spurious wakeup, this executes even though conditionMet might still be false
System.out.println(Thread.currentThread().getName()
+ " - Proceeded. conditionMet = " + conditionMet);
}
// CORRECT: Using while (safe against spurious wakeups)
public synchronized void waitWithWhile() throws InterruptedException {
while (!conditionMet) {
// Re-checks condition after every wakeup
System.out.println(Thread.currentThread().getName() + " - Waiting with WHILE...");
wait();
}
// Only reaches here when conditionMet is truly true
System.out.println(Thread.currentThread().getName()
+ " - Proceeded. conditionMet = " + conditionMet);
}
public synchronized void setCondition() {
conditionMet = true;
System.out.println(Thread.currentThread().getName() + " - Condition set to true, notifying all.");
notifyAll();
}
public static void main(String[] args) throws InterruptedException {
System.out.println("=== Demonstrating correct spurious wakeup handling ===");
System.out.println("The JVM specification allows threads to wake from wait()");
System.out.println("without being notified. Always use a while loop.\n");
SpuriousWakeupDemo demo = new SpuriousWakeupDemo();
// Thread using the correct while-loop pattern
Thread correctThread = new Thread(() -> {
try {
demo.waitWithWhile();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "CorrectThread");
correctThread.start();
Thread.sleep(1000);
// Set condition and notify
demo.setCondition();
correctThread.join();
System.out.println("\nKey takeaway: Always use while(!condition) before wait(),");
System.out.println("never if(!condition). The while loop re-checks the condition");
System.out.println("every time the thread wakes up, whether notified or spurious.");
}
}
You now have a working mental model of how threads communicate through wait and notify. The producer-consumer pattern you built here is the foundation for many real-world systems like message queues and task dispatchers. Next up, you will explore what happens when thread communication goes wrong with deadlocks, livelocks, and starvation.
Identify and Fix Thread Safety Problems
Your synchronization and communication programs prove that threads can share data safely when you coordinate them properly. But what happens when coordination breaks down?
In this step, you will create programs that deliberately produce the three most dangerous concurrency bugs: deadlock, livelock, and starvation. You will observe each one in action, diagnose it programmatically, and learn the strategies that prevent each from occurring in production code.
In this step, get ready to:
- Create a deliberate deadlock and detect it programmatically.
- Fix the deadlock using a global lock ordering strategy.
- Implement livelock and starvation scenarios to observe their distinct behaviors.
Create a deliberate deadlock
A deadlock occurs when two or more threads are each waiting for a lock held by the other. Neither thread can proceed, and the program freezes forever.
What is deadlock?
Imagine Thread A holds Lock 1 and needs Lock 2 to continue. At the same time, Thread B holds Lock 2 and needs Lock 1. Both threads are permanently stuck because neither will release the lock the other one needs.
Deadlock requires all four Coffman conditions to be true simultaneously:
- Mutual exclusion: at least one resource is held in a non-shareable mode (only one thread can hold the lock at a time).
- Hold and wait: a thread holds at least one resource while waiting to acquire additional resources.
- No preemption: resources cannot be forcibly taken from a thread. They must be voluntarily released.
- Circular wait: a circular chain of threads exists where each holds a resource needed by the next thread in the chain.
If you break any one of these four conditions, deadlock becomes impossible. Lock ordering breaks the circular wait condition, which is why it is the most common prevention strategy.
- Right-click the problems package in the Project tool window.
- Select New, then Java Class.
- Name the class DeadlockDemo and press Enter.
- Replace the file contents with the following code:
package problems;
public class DeadlockDemo {
// Two shared lock objects
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
// Thread A acquires lockA first, then tries to acquire lockB
Thread threadA = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread A: holding lockA, waiting for lockB...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("Thread A: holding both locks!");
}
}
}, "Thread-A");
// Thread B acquires lockB first, then tries to acquire lockA
Thread threadB = new Thread(() -> {
synchronized (lockB) {
System.out.println("Thread B: holding lockB, waiting for lockA...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockA) {
System.out.println("Thread B: holding both locks!");
}
}
}, "Thread-B");
threadA.start();
threadB.start();
System.out.println("Main: both threads started. Program will hang if deadlock occurs.");
}
}
What does this code do?
- Two Object instances act as locks. Thread A acquires lockA first, then tries to acquire lockB.
- Thread B does the opposite: it acquires lockB first, then tries to acquire lockA. This creates a lock ordering violation.
- The Thread.sleep(100) ensures both threads have time to grab their first lock before attempting the second one. This guarantees the deadlock occurs reliably.
- You will never see the "holding both locks" messages print because neither thread can proceed past its second synchronized block.
- Save DeadlockDemo.java.
- Run the program by clicking the green play icon next to main().
You should see both "holding" messages print, but the program never terminates. The two "holding both locks" messages never appear because both threads are frozen.
- Click the red square Stop button in IntelliJ's Run tool window to terminate the hung program.
Program terminates immediately?
- If you see "holding both locks" messages, the timing was off on that particular run. Increase the sleep from 100 to 500 milliseconds and try again.
- Make sure both threads use static final lock objects. If each thread creates its own lock, they are not competing for the same resource.
Why is my DeadlockDemo not producing a deadlock?
The dining philosophers problem
The classic illustration of deadlock is the dining philosophers problem. Five philosophers sit around a circular table with a fork between each pair. Each philosopher needs two forks to eat. If all five pick up their left fork simultaneously, nobody can pick up their right fork. Everyone starves.
Your two-thread program is a simplified version of this. Thread A holds one "fork" (lockA) and needs the other. Thread B holds the other "fork" (lockB) and needs the first. Same circular wait, same deadlock.
Now let's detect this deadlock programmatically, then fix it.
Detect and fix the deadlock
In production, deadlocks are diagnosed using thread dumps. A thread dump shows every thread's current state and what lock it holds or is waiting for. Java provides Thread.getAllStackTraces() to generate this programmatically.
What about jstack?
In production, you can run jstack <pid> from the terminal to get a full thread dump of a running JVM process. The JVM automatically detects deadlocks and prints them in a special section at the bottom of the dump.
For this lab, you will use the programmatic approach so you can see the detection happen inside your code.
- Create a new class called DeadlockDetectionDemo in the problems package.
- Replace the file contents with the following code:
package problems;
import java.util.Map;
public class DeadlockDetectionDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) throws InterruptedException {
// Create the same deadlock scenario
Thread threadA = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread A: holding lockA");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("Thread A: holding both locks");
}
}
}, "Thread-A");
Thread threadB = new Thread(() -> {
synchronized (lockB) {
System.out.println("Thread B: holding lockB");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockA) {
System.out.println("Thread B: holding both locks");
}
}
}, "Thread-B");
threadA.start();
threadB.start();
// Wait for the deadlock to establish
Thread.sleep(500);
// Print all thread states to detect the deadlock
System.out.println("\n--- Thread Dump (Deadlock Detection) ---");
Map<Thread, StackTraceElement[]> allThreads = Thread.getAllStackTraces();
for (Map.Entry<Thread, StackTraceElement[]> entry : allThreads.entrySet()) {
Thread thread = entry.getKey();
StackTraceElement[] stack = entry.getValue();
System.out.println("\nThread: " + thread.getName() + " | State: " + thread.getState());
for (StackTraceElement element : stack) {
System.out.println(" at " + element);
}
}
// Check if our threads are blocked (indicating deadlock)
System.out.println("\n--- Deadlock Analysis ---");
System.out.println("Thread-A state: " + threadA.getState());
System.out.println("Thread-B state: " + threadB.getState());
if (threadA.getState() == Thread.State.BLOCKED
&& threadB.getState() == Thread.State.BLOCKED) {
System.out.println("DEADLOCK DETECTED: Both threads are BLOCKED!");
}
}
}
What does this code do?
- Thread.getAllStackTraces() returns a map of every thread in the JVM to its current stack trace. This is the programmatic equivalent of running jstack.
- The main thread sleeps for 500ms to give the deadlock time to form before inspecting thread states.
- If both threads show a state of BLOCKED, the program confirms a deadlock. In the thread dump output, you can see exactly where each thread is stuck in its stack trace.
- Save DeadlockDetectionDemo.java and run it.
You should see a thread dump listing all JVM threads with their states, followed by "DEADLOCK DETECTED: Both threads are BLOCKED!" in the analysis section. The program itself will still hang after printing because the deadlocked threads never terminate.
- Click the red Stop button to terminate the program.
Thread dump does not show BLOCKED state?
- If the sleep of 500ms is not long enough for the deadlock to form on your machine, increase it to 1000ms.
- Make sure both lockA and lockB are static final fields, not local variables.
Why is my deadlock detection not working?
Now that you can observe a deadlock, let's fix it. The most reliable prevention strategy is to enforce a global lock ordering. If every thread always acquires locks in the same order, circular wait becomes impossible.
- Create a new class called DeadlockFixDemo in the problems package.
- Replace the file contents with the following code:
package problems;
public class DeadlockFixDemo {
// Same two lock objects
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) throws InterruptedException {
// Both threads acquire locks in the SAME order: lockA first, then lockB
Thread threadA = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread A: holding lockA, waiting for lockB...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("Thread A: holding both locks! Work done.");
}
}
}, "Thread-A");
Thread threadB = new Thread(() -> {
// Fixed: acquire lockA first (same order as Thread A)
synchronized (lockA) {
System.out.println("Thread B: holding lockA, waiting for lockB...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("Thread B: holding both locks! Work done.");
}
}
}, "Thread-B");
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println("Main: both threads completed successfully. No deadlock!");
}
}
What does this code do?
- Both threads now acquire lockA first, then lockB. This consistent ordering means one thread will acquire lockA and the other will wait. No circular dependency can form.
- The join() calls ensure the main thread waits for both threads to complete before printing the success message.
- This is the lock ordering strategy in action. By establishing a global rule (always acquire A before B), you break the circular wait condition. One of the four Coffman conditions is eliminated, so deadlock is impossible.
- Save DeadlockFixDemo.java and run it.
You should see both threads acquire both locks and print "Work done." followed by the success message. The program terminates normally.
Other deadlock prevention strategies
Lock ordering is the simplest strategy, but there are others for more complex scenarios:
- Lock timeout: use tryLock(timeout) from ReentrantLock. If a thread cannot acquire the second lock within a time limit, it releases the first lock and retries. You will use this in the locks step.
- Lock-free algorithms: use atomic compare-and-swap (CAS) operations instead of locks. No locks means no deadlock. You will explore this in the atomic variables step.
- Reducing lock granularity: hold locks for the shortest possible time and minimize the number of locks a single thread needs to hold simultaneously.
Program still hanging?
- Double-check that Thread B's outer synchronized block uses lockA (not lockB). If you accidentally left it as lockB, you still have the original deadlock-prone ordering.
Help me fix my DeadlockFixDemo so it completes without deadlock.
Demonstrate livelock and starvation
Deadlock is not the only way threads can fail to make progress. Two other problems are more subtle because the threads are technically not blocked.
What is livelock?
In a livelock, threads are not blocked. They are actively running and responding to each other. But their responses cancel each other out, so no thread makes actual progress.
The real-world analogy is two people meeting in a narrow hallway. Both step aside to let the other pass. Both step to the same side. Both step back. They keep "politely" yielding to each other but neither moves forward.
What is starvation?
Starvation happens when a thread is technically able to run but never gets access to a shared resource because other threads continuously monopolize it. The thread is not blocked or livelocked. It simply never wins the competition.
Common causes include unfair locks, priority inversion (a high-priority thread holds a lock needed by a low-priority thread, while a medium-priority thread keeps preempting the low-priority one), and threads that hold locks for excessively long periods.
- Create a new class called LivelockDemo in the problems package.
- Replace the file contents with the following code:
package problems;
public class LivelockDemo {
// Shared flags indicating each thread's willingness to act
private static volatile boolean thread1Active = true;
private static volatile boolean thread2Active = true;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
int attempts = 0;
while (attempts < 10) {
// If the other thread is active, yield and try again
if (thread2Active) {
System.out.println("Thread 1: Thread 2 is active, yielding...");
thread1Active = false;
Thread.yield();
thread1Active = true;
attempts++;
continue;
}
// Do work
System.out.println("Thread 1: working!");
break;
}
if (attempts >= 10) {
System.out.println("Thread 1: gave up after " + attempts + " attempts (LIVELOCK)");
}
}, "Thread-1");
Thread thread2 = new Thread(() -> {
int attempts = 0;
while (attempts < 10) {
// If the other thread is active, yield and try again
if (thread1Active) {
System.out.println("Thread 2: Thread 1 is active, yielding...");
thread2Active = false;
Thread.yield();
thread2Active = true;
attempts++;
continue;
}
// Do work
System.out.println("Thread 2: working!");
break;
}
if (attempts >= 10) {
System.out.println("Thread 2: gave up after " + attempts + " attempts (LIVELOCK)");
}
}, "Thread-2");
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("Main: Both threads finished. Neither made progress (livelock).");
}
}
What does this code do?
- Each thread checks if the other is active. If so, it deactivates itself, yields, reactivates, and retries. Both threads keep "politely" backing off for each other.
- The volatile keyword ensures each thread sees the other's latest flag value. Without it, the threads might not notice each other's changes.
- The attempt limit prevents the program from running forever. In a real livelock, the threads would spin indefinitely. The "gave up" messages confirm that neither thread reached the "working!" line.
- Save LivelockDemo.java and run it.
You should see alternating "yielding" messages from both threads, followed by "gave up" messages. Neither thread ever prints "working!" because each one keeps deferring to the other.
One thread prints "working!" instead of giving up?
- Due to thread scheduling, one thread might occasionally catch the other in its deactivated state. This is normal non-determinism. Run it a few times. In most runs, both threads give up.
- If one thread consistently "wins", your volatile keywords might be missing. Make sure both flags are declared volatile.
Why is my livelock demo not behaving as expected?
Now let's create the starvation scenario.
- Create a new class called StarvationDemo in the problems package.
- Replace the file contents with the following code:
package problems;
public class StarvationDemo {
// Shared lock
private static final Object sharedLock = new Object();
public static void main(String[] args) throws InterruptedException {
// High-priority thread continuously holds the lock in a tight loop
Thread greedyThread = new Thread(() -> {
for (int i = 0; i < 100; i++) {
synchronized (sharedLock) {
System.out.println("Greedy (HIGH priority): acquired lock, iteration " + i);
// Simulate some work
try { Thread.sleep(10); } catch (InterruptedException e) {}
}
// Brief release, but immediately re-acquires
}
}, "Greedy-Thread");
greedyThread.setPriority(Thread.MAX_PRIORITY);
// Multiple low-priority threads trying to acquire the same lock
Runnable starvingTask = () -> {
for (int i = 0; i < 5; i++) {
synchronized (sharedLock) {
System.out.println(Thread.currentThread().getName()
+ " (LOW priority): acquired lock!");
try { Thread.sleep(10); } catch (InterruptedException e) {}
}
}
};
Thread starved1 = new Thread(starvingTask, "Starved-1");
Thread starved2 = new Thread(starvingTask, "Starved-2");
Thread starved3 = new Thread(starvingTask, "Starved-3");
starved1.setPriority(Thread.MIN_PRIORITY);
starved2.setPriority(Thread.MIN_PRIORITY);
starved3.setPriority(Thread.MIN_PRIORITY);
greedyThread.start();
starved1.start();
starved2.start();
starved3.start();
greedyThread.join();
starved1.join();
starved2.join();
starved3.join();
System.out.println("Main: All threads completed.");
System.out.println("Notice how the low-priority threads barely ran while the greedy thread dominated.");
}
}
What does this code do?
- The greedy thread runs at MAX_PRIORITY (10) and iterates 100 times, grabbing and releasing the lock in a tight loop.
- Three starved threads run at MIN_PRIORITY (1) and only need 5 iterations each. Despite needing far less lock time, the scheduler favors the high-priority thread.
- When you look at the output, the greedy thread dominates the log. The starved threads appear infrequently and often cluster at the end after the greedy thread finishes. This is starvation in action.
- Save StarvationDemo.java and run it.
You should see the console flooded with "Greedy (HIGH priority)" messages. The "Starved" messages appear rarely or are clustered at the end after the greedy thread finishes its 100 iterations.
Why is thread priority unreliable?
Thread priority is a hint to the OS scheduler, not a guarantee. Different operating systems handle priorities differently. On some systems, you may see very pronounced starvation. On others, the scheduler may be more fair.
This is why you should never rely on thread priority for correctness. Use proper synchronization mechanisms (fair locks, queues) instead of assuming priority will enforce ordering.
Starved threads run just as often as the greedy thread?
- Some operating systems and JVM implementations do not strongly honor thread priorities. This is expected behavior. The demo still illustrates the concept even if starvation is not extreme on your system.
- Try increasing the greedy thread's loop count from 100 to 500 to make the disparity more visible.
Why am I not seeing starvation in my StarvationDemo output?
✔️ Awesome, I've got everything!
Great. Make sure you have saved all five files in your problems package.
ⓧ I'd like to double check the full code
Here are all five files in the problems package for comparison.
package problems;
public class DeadlockDemo {
// Two shared lock objects
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
// Thread A acquires lockA first, then tries to acquire lockB
Thread threadA = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread A: holding lockA, waiting for lockB...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("Thread A: holding both locks!");
}
}
}, "Thread-A");
// Thread B acquires lockB first, then tries to acquire lockA
Thread threadB = new Thread(() -> {
synchronized (lockB) {
System.out.println("Thread B: holding lockB, waiting for lockA...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockA) {
System.out.println("Thread B: holding both locks!");
}
}
}, "Thread-B");
threadA.start();
threadB.start();
System.out.println("Main: both threads started. Program will hang if deadlock occurs.");
}
}
package problems;
import java.util.Map;
public class DeadlockDetectionDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) throws InterruptedException {
// Create the same deadlock scenario
Thread threadA = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread A: holding lockA");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("Thread A: holding both locks");
}
}
}, "Thread-A");
Thread threadB = new Thread(() -> {
synchronized (lockB) {
System.out.println("Thread B: holding lockB");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockA) {
System.out.println("Thread B: holding both locks");
}
}
}, "Thread-B");
threadA.start();
threadB.start();
// Wait for the deadlock to establish
Thread.sleep(500);
// Print all thread states to detect the deadlock
System.out.println("\n--- Thread Dump (Deadlock Detection) ---");
Map<Thread, StackTraceElement[]> allThreads = Thread.getAllStackTraces();
for (Map.Entry<Thread, StackTraceElement[]> entry : allThreads.entrySet()) {
Thread thread = entry.getKey();
StackTraceElement[] stack = entry.getValue();
System.out.println("\nThread: " + thread.getName() + " | State: " + thread.getState());
for (StackTraceElement element : stack) {
System.out.println(" at " + element);
}
}
// Check if our threads are blocked (indicating deadlock)
System.out.println("\n--- Deadlock Analysis ---");
System.out.println("Thread-A state: " + threadA.getState());
System.out.println("Thread-B state: " + threadB.getState());
if (threadA.getState() == Thread.State.BLOCKED
&& threadB.getState() == Thread.State.BLOCKED) {
System.out.println("DEADLOCK DETECTED: Both threads are BLOCKED!");
}
}
}
package problems;
public class DeadlockFixDemo {
// Same two lock objects
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) throws InterruptedException {
// Both threads acquire locks in the SAME order: lockA first, then lockB
Thread threadA = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread A: holding lockA, waiting for lockB...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("Thread A: holding both locks! Work done.");
}
}
}, "Thread-A");
Thread threadB = new Thread(() -> {
// Fixed: acquire lockA first (same order as Thread A)
synchronized (lockA) {
System.out.println("Thread B: holding lockA, waiting for lockB...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("Thread B: holding both locks! Work done.");
}
}
}, "Thread-B");
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println("Main: both threads completed successfully. No deadlock!");
}
}
package problems;
public class LivelockDemo {
// Shared flags indicating each thread's willingness to act
private static volatile boolean thread1Active = true;
private static volatile boolean thread2Active = true;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
int attempts = 0;
while (attempts < 10) {
// If the other thread is active, yield and try again
if (thread2Active) {
System.out.println("Thread 1: Thread 2 is active, yielding...");
thread1Active = false;
Thread.yield();
thread1Active = true;
attempts++;
continue;
}
// Do work
System.out.println("Thread 1: working!");
break;
}
if (attempts >= 10) {
System.out.println("Thread 1: gave up after " + attempts + " attempts (LIVELOCK)");
}
}, "Thread-1");
Thread thread2 = new Thread(() -> {
int attempts = 0;
while (attempts < 10) {
// If the other thread is active, yield and try again
if (thread1Active) {
System.out.println("Thread 2: Thread 1 is active, yielding...");
thread2Active = false;
Thread.yield();
thread2Active = true;
attempts++;
continue;
}
// Do work
System.out.println("Thread 2: working!");
break;
}
if (attempts >= 10) {
System.out.println("Thread 2: gave up after " + attempts + " attempts (LIVELOCK)");
}
}, "Thread-2");
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("Main: Both threads finished. Neither made progress (livelock).");
}
}
package problems;
public class StarvationDemo {
// Shared lock
private static final Object sharedLock = new Object();
public static void main(String[] args) throws InterruptedException {
// High-priority thread continuously holds the lock in a tight loop
Thread greedyThread = new Thread(() -> {
for (int i = 0; i < 100; i++) {
synchronized (sharedLock) {
System.out.println("Greedy (HIGH priority): acquired lock, iteration " + i);
// Simulate some work
try { Thread.sleep(10); } catch (InterruptedException e) {}
}
// Brief release, but immediately re-acquires
}
}, "Greedy-Thread");
greedyThread.setPriority(Thread.MAX_PRIORITY);
// Multiple low-priority threads trying to acquire the same lock
Runnable starvingTask = () -> {
for (int i = 0; i < 5; i++) {
synchronized (sharedLock) {
System.out.println(Thread.currentThread().getName()
+ " (LOW priority): acquired lock!");
try { Thread.sleep(10); } catch (InterruptedException e) {}
}
}
};
Thread starved1 = new Thread(starvingTask, "Starved-1");
Thread starved2 = new Thread(starvingTask, "Starved-2");
Thread starved3 = new Thread(starvingTask, "Starved-3");
starved1.setPriority(Thread.MIN_PRIORITY);
starved2.setPriority(Thread.MIN_PRIORITY);
starved3.setPriority(Thread.MIN_PRIORITY);
greedyThread.start();
starved1.start();
starved2.start();
starved3.start();
greedyThread.join();
starved1.join();
starved2.join();
starved3.join();
System.out.println("Main: All threads completed.");
System.out.println("Notice how the low-priority threads barely ran while the greedy thread dominated.");
}
}
You now have direct experience with the three most dangerous concurrency bugs. You can create them, observe them, and fix them. Next up, you will explore the Java Memory Model to understand why threads can see stale values even without these high-level problems.
Understand the Java Memory Model
In the previous step, you observed deadlock, livelock, and starvation in action. Those problems occur because of how threads compete for locks. This step tackles a subtler category of bug: threads that never see each other's changes at all.
The Java Memory Model (JMM) defines exactly when a write by one thread becomes visible to another thread. Without understanding it, you can write code that appears correct, passes testing, and yet fails silently in production because threads cache stale values.
In this step, get ready to:
- Demonstrate a visibility bug where one thread never sees another thread's update.
- Fix the bug with volatile and prove that volatile does not solve compound actions.
- Explore happens-before relationships and implement correct double-checked locking.
Observe visibility bugs and fix them with volatile
Modern CPUs do not read from main memory on every access. Each core has its own L1 and L2 cache, and the JVM is allowed to keep thread-local copies of variables in registers or cache. A write by Thread A may sit in Thread A's cache indefinitely without ever being flushed to main memory where Thread B can see it.
Why does the JMM exist?
Hardware caches, compiler optimizations, and instruction reordering all exist to make single-threaded code faster. The JMM defines the rules that constrain these optimizations so that multithreaded programs can reason about visibility. Without it, every concurrent program would be at the mercy of unpredictable hardware behavior.
The key insight: a write by one thread is NOT guaranteed to be visible to another thread without explicit synchronization. This is by design for performance.
What is thread-local working memory?
Each thread conceptually has its own "working memory" (backed by CPU caches and registers). When a thread reads a variable, it may read from its local cache instead of main memory. When it writes, the new value may stay in the local cache without being flushed.
On a multi-core machine, Thread A on Core 1 and Thread B on Core 2 each have their own L1/L2 cache. A write to a field by Thread A updates Core 1's cache. Thread B on Core 2 still sees the old cached value. The cache coherence protocol (e.g., MESI) can synchronize them, but the JVM is not required to trigger this immediately.
Your first program demonstrates this visibility failure directly.
- Create a new Java class called VisibilityBugDemo in the memorymodel package.
- Copy the following code into it:
package memorymodel;
public class VisibilityBugDemo {
// No volatile keyword - the reader thread may cache this value forever
private static boolean running = true;
public static void main(String[] args) throws InterruptedException {
// Reader thread: spins until running becomes false
Thread reader = new Thread(() -> {
System.out.println("Reader started, waiting for flag to change...");
int count = 0;
while (running) {
count++;
}
System.out.println("Reader finished after " + count + " iterations");
});
reader.start();
// Give the reader time to start and cache the value
Thread.sleep(1000);
// Writer: sets the flag to false from the main thread
System.out.println("Main thread setting running = false");
running = false;
// Wait to see if the reader ever notices the change
reader.join(3000);
if (reader.isAlive()) {
System.out.println("BUG: Reader thread NEVER saw the update!");
System.out.println("The thread is stuck in an infinite loop.");
System.out.println("(Terminating program with System.exit)");
System.exit(0);
}
}
}
What does this code do?
The reader thread enters a tight while (running) loop. The main thread waits one second, then sets running = false. Without any synchronization mechanism, the JIT compiler may hoist the field read outside the loop (since it appears constant from the reader thread's perspective). The reader thread never re-reads from main memory.
The program uses reader.join(3000) to wait 3 seconds. If the reader is still alive after that, the visibility bug is confirmed.
- Run VisibilityBugDemo.
You should see the message "BUG: Reader thread NEVER saw the update!" printed after 3 seconds. The reader thread is stuck forever in its loop because it cached the value of running and never re-read it from main memory.
Results may vary by JVM mode
If you run in interpreted mode (no JIT optimization), the bug might not manifest because the JVM re-reads the field on every loop iteration. The JIT compiler aggressively optimizes hot loops, which is when visibility bugs surface. Run the program multiple times. In server mode with JIT enabled (the default for JDK 21), the bug should appear reliably.
Reader thread terminates immediately?
If the reader terminates quickly, the JIT may not have had time to optimize the loop. Try increasing the Thread.sleep(1000) to Thread.sleep(2000) to give the JIT more time to compile the hot loop. The key lesson remains: without volatile, visibility is not guaranteed.
Help me understand why my VisibilityBugDemo reader thread exits instead of getting stuck
Now fix the bug. The volatile keyword forces every read to go directly to main memory and every write to flush immediately.
What does volatile guarantee?
- Visibility: reads always see the most recent write from any thread.
- Ordering: the JVM inserts a memory fence around volatile accesses, preventing instruction reordering across the fence.
- Does NOT guarantee atomicity. A volatile int incremented with ++ is still three separate operations (read, add, write) that can interleave.
Use volatile when you have a single writer thread updating a flag or status that other threads read. It is insufficient for compound actions like incrementing.
- Create a new Java class called VolatileFixDemo in the memorymodel package.
- Copy the following code into it:
package memorymodel;
public class VolatileFixDemo {
// volatile guarantees visibility: reads go to main memory, writes flush immediately
private static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException {
// Reader thread: same spinning loop as the buggy version
Thread reader = new Thread(() -> {
System.out.println("Reader started, waiting for flag to change...");
int count = 0;
while (running) {
count++;
}
System.out.println("Reader finished after " + count + " iterations");
});
reader.start();
// Give the reader time to start
Thread.sleep(1000);
// Writer: sets the flag to false
System.out.println("Main thread setting running = false");
running = false;
// The reader WILL see the update because of volatile
reader.join(3000);
if (reader.isAlive()) {
System.out.println("Reader is still running (unexpected with volatile)");
} else {
System.out.println("SUCCESS: Reader saw the update and terminated.");
}
}
}
What changed?
The only difference is private static volatile boolean running. That single keyword forces the reader to re-read the field from main memory on every loop iteration. The JIT compiler cannot hoist the read outside the loop because the volatile access acts as a memory fence.
- Run VolatileFixDemo.
You should see "SUCCESS: Reader saw the update and terminated." printed. The reader thread exits cleanly because volatile guarantees visibility.
Now prove that volatile is not a universal fix. It solves visibility but not atomicity.
- Create a new Java class called VolatileNotAtomicDemo in the memorymodel package.
- Copy the following code into it:
package memorymodel;
public class VolatileNotAtomicDemo {
// volatile does NOT make the ++ operator atomic
private static volatile int counter = 0;
private static final int ITERATIONS = 100_000;
public static void main(String[] args) throws InterruptedException {
// Two threads both incrementing the same volatile counter
Thread t1 = new Thread(() -> {
for (int i = 0; i < ITERATIONS; i++) {
counter++; // NOT atomic: read counter, add 1, write back
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < ITERATIONS; i++) {
counter++; // Same non-atomic read-modify-write
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// Expected 200,000 but actual will be less due to lost updates
System.out.println("Expected: " + (2 * ITERATIONS));
System.out.println("Actual: " + counter);
System.out.println("Lost updates: " + (2 * ITERATIONS - counter));
if (counter < 2 * ITERATIONS) {
System.out.println("DEMONSTRATED: volatile does not make ++ atomic.");
System.out.println("The read-modify-write is still three separate operations.");
}
}
}
Why does volatile fail here?
The expression counter++ compiles to three distinct operations: (1) read the current value, (2) add 1, (3) write the result back. Volatile guarantees each individual read and write goes to main memory. But between the read and the write, another thread can read the same old value, increment it, and write back. Both threads write the same result, losing one update.
For atomic compound operations, you need either synchronized blocks or the java.util.concurrent.atomic classes (covered in a later step).
- Run VolatileNotAtomicDemo.
The actual count should be less than 200,000. Run it several times to see the lost updates vary. This proves that volatile alone cannot protect compound actions.
Counter shows exactly 200,000?
On some machines with very fast single-core scheduling, one thread might finish before the other starts. Try increasing ITERATIONS to 1_000_000 or add more threads to increase contention. The point is that correctness is never guaranteed without proper synchronization.
Help me understand why my volatile counter is not showing lost updates
Prove happens-before guarantees with synchronized blocks
Volatile solves visibility for individual variables. But the JMM provides a more general mechanism for reasoning about multi-variable visibility: the happens-before relationship.
What is happens-before?
If action A happens-before action B, then the memory effects of A are guaranteed to be visible to B. This is the JMM's formal visibility contract. The key happens-before rules are:
- Program order rule: each action in a thread happens-before every subsequent action in that same thread.
- Monitor lock rule: an unlock on a monitor happens-before every subsequent lock on that same monitor.
- Volatile variable rule: a write to a volatile field happens-before every subsequent read of that field.
- Thread start rule: a call to Thread.start() happens-before any action in the started thread.
- Thread join rule: all actions in a thread happen-before the return of Thread.join() on that thread.
- Transitivity: if A happens-before B, and B happens-before C, then A happens-before C.
Why does instruction reordering matter?
Both the CPU and the JIT compiler may reorder instructions for performance. For example, if you write to fields A and B in that order, another thread might observe the write to B before the write to A. The happens-before rules define when reordering is forbidden. Inside a happens-before relationship, all prior writes become visible in program order.
The next program demonstrates the monitor lock happens-before rule. Thread A writes multiple fields inside a synchronized block, and Thread B acquires the same lock to read those fields.
- Create a new Java class called HappensBeforeDemo in the memorymodel package.
- Copy the following code into it:
package memorymodel;
public class HappensBeforeDemo {
// Non-volatile fields - visibility depends entirely on happens-before
private static int x = 0;
private static int y = 0;
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// Thread A: writes values then releases the lock
Thread writer = new Thread(() -> {
synchronized (lock) {
x = 42;
y = 99;
System.out.println("Writer: set x=" + x + ", y=" + y);
} // Unlock happens-before the reader's lock acquisition
});
// Thread B: acquires the same lock, then reads the values
Thread reader = new Thread(() -> {
// Small delay to let writer go first
try { Thread.sleep(100); } catch (InterruptedException e) { }
synchronized (lock) {
// Monitor lock rule guarantees x=42 and y=99 are visible here
System.out.println("Reader: sees x=" + x + ", y=" + y);
if (x == 42 && y == 99) {
System.out.println("SUCCESS: Happens-before guarantees visibility.");
System.out.println("All writes before unlock are visible after lock.");
}
}
});
writer.start();
reader.start();
writer.join();
reader.join();
}
}
What does this code demonstrate?
The fields x and y are NOT volatile. Without the synchronized blocks, the reader thread would have no guarantee of seeing the writer's updates. The monitor lock rule ensures that everything the writer did before releasing the lock is visible to the reader after acquiring the same lock.
This is why synchronized provides both mutual exclusion AND memory visibility. It is not just about preventing two threads from entering at the same time.
- Run HappensBeforeDemo.
You should see the SUCCESS message confirming that the reader sees both values written by the writer. The happens-before guarantee makes non-volatile fields safely visible across the lock boundary.
Implement correct double-checked locking
Double-checked locking is a pattern for lazy initialization of a singleton. It is also one of the most famous examples of a subtle JMM bug. Without volatile, the pattern is broken because a partially constructed object can be visible to other threads.
Why is double-checked locking broken without volatile?
The statement instance = new Singleton() is not atomic at the bytecode level. It involves: (1) allocate memory, (2) run the constructor, (3) assign the reference to the field. The JVM is allowed to reorder steps 2 and 3. If it does, another thread can see a non-null reference that points to an object whose constructor has not finished running.
Marking the field volatile prevents this reordering. The volatile write (step 3) cannot be moved before the constructor (step 2) because volatile acts as a memory fence.
What is the object publication problem?
An incompletely constructed object can "escape" if a reference to it becomes visible to other threads before the constructor finishes. This happens in double-checked locking without volatile. It can also happen if you pass this to another thread inside a constructor.
Safe publication idioms include: volatile fields, final fields (Java guarantees final fields are visible once the constructor completes), static initializers, and handing off via a synchronized block.
- Create a new Java class called DoubleCheckedLockingDemo in the memorymodel package.
- Copy the following code into it:
package memorymodel;
public class DoubleCheckedLockingDemo {
// volatile prevents seeing a partially constructed instance
private static volatile DoubleCheckedLockingDemo instance;
private final String config;
private DoubleCheckedLockingDemo() {
// Simulate expensive initialization
config = "initialized";
System.out.println(Thread.currentThread().getName() + " - Constructor completed");
}
public static DoubleCheckedLockingDemo getInstance() {
// First check: no locking needed (fast path for subsequent calls)
if (instance == null) {
synchronized (DoubleCheckedLockingDemo.class) {
// Second check: inside the lock to prevent duplicate creation
if (instance == null) {
instance = new DoubleCheckedLockingDemo();
}
}
}
return instance;
}
public String getConfig() {
return config;
}
public static void main(String[] args) throws InterruptedException {
int threadCount = 10;
Thread[] threads = new Thread[threadCount];
// Multiple threads try to get the singleton simultaneously
for (int i = 0; i < threadCount; i++) {
threads[i] = new Thread(() -> {
DoubleCheckedLockingDemo singleton = getInstance();
System.out.println(Thread.currentThread().getName()
+ " got instance with config: " + singleton.getConfig());
});
}
// Start all threads to create maximum contention
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("\nOnly ONE constructor call should appear above.");
System.out.println("volatile ensures no thread sees a partially constructed object.");
}
}
How does the pattern work?
- The first null check avoids locking on every call. After the singleton is created, all subsequent calls take the fast path without synchronization.
- The synchronized block ensures only one thread creates the instance.
- The second null check inside the lock handles the case where multiple threads passed the first check before one of them acquired the lock.
- The volatile keyword on the field prevents reordering of the reference assignment and the constructor execution.
- Run DoubleCheckedLockingDemo.
You should see exactly one "Constructor completed" message. All 10 threads should report the same config value. This confirms both that only one instance was created and that all threads see a fully constructed object.
Seeing multiple constructor calls?
If you see more than one "Constructor completed" line, check that the synchronized block locks on DoubleCheckedLockingDemo.class and that both null checks reference the same instance field. Also verify that the field is declared static volatile.
Help me debug my double-checked locking implementation
✔️ Awesome, I've got everything!
Great. Double check that all five files are saved in your memorymodel package.
ⓧ I'd like to double check the full code
Here are all five files for this step. Compare your code against these.
package memorymodel;
public class VisibilityBugDemo {
// No volatile keyword - the reader thread may cache this value forever
private static boolean running = true;
public static void main(String[] args) throws InterruptedException {
// Reader thread: spins until running becomes false
Thread reader = new Thread(() -> {
System.out.println("Reader started, waiting for flag to change...");
int count = 0;
while (running) {
count++;
}
System.out.println("Reader finished after " + count + " iterations");
});
reader.start();
// Give the reader time to start and cache the value
Thread.sleep(1000);
// Writer: sets the flag to false from the main thread
System.out.println("Main thread setting running = false");
running = false;
// Wait to see if the reader ever notices the change
reader.join(3000);
if (reader.isAlive()) {
System.out.println("BUG: Reader thread NEVER saw the update!");
System.out.println("The thread is stuck in an infinite loop.");
System.out.println("(Terminating program with System.exit)");
System.exit(0);
}
}
}
package memorymodel;
public class VolatileFixDemo {
// volatile guarantees visibility: reads go to main memory, writes flush immediately
private static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException {
// Reader thread: same spinning loop as the buggy version
Thread reader = new Thread(() -> {
System.out.println("Reader started, waiting for flag to change...");
int count = 0;
while (running) {
count++;
}
System.out.println("Reader finished after " + count + " iterations");
});
reader.start();
// Give the reader time to start
Thread.sleep(1000);
// Writer: sets the flag to false
System.out.println("Main thread setting running = false");
running = false;
// The reader WILL see the update because of volatile
reader.join(3000);
if (reader.isAlive()) {
System.out.println("Reader is still running (unexpected with volatile)");
} else {
System.out.println("SUCCESS: Reader saw the update and terminated.");
}
}
}
package memorymodel;
public class VolatileNotAtomicDemo {
// volatile does NOT make the ++ operator atomic
private static volatile int counter = 0;
private static final int ITERATIONS = 100_000;
public static void main(String[] args) throws InterruptedException {
// Two threads both incrementing the same volatile counter
Thread t1 = new Thread(() -> {
for (int i = 0; i < ITERATIONS; i++) {
counter++; // NOT atomic: read counter, add 1, write back
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < ITERATIONS; i++) {
counter++; // Same non-atomic read-modify-write
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// Expected 200,000 but actual will be less due to lost updates
System.out.println("Expected: " + (2 * ITERATIONS));
System.out.println("Actual: " + counter);
System.out.println("Lost updates: " + (2 * ITERATIONS - counter));
if (counter < 2 * ITERATIONS) {
System.out.println("DEMONSTRATED: volatile does not make ++ atomic.");
System.out.println("The read-modify-write is still three separate operations.");
}
}
}
package memorymodel;
public class HappensBeforeDemo {
// Non-volatile fields - visibility depends entirely on happens-before
private static int x = 0;
private static int y = 0;
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// Thread A: writes values then releases the lock
Thread writer = new Thread(() -> {
synchronized (lock) {
x = 42;
y = 99;
System.out.println("Writer: set x=" + x + ", y=" + y);
} // Unlock happens-before the reader's lock acquisition
});
// Thread B: acquires the same lock, then reads the values
Thread reader = new Thread(() -> {
// Small delay to let writer go first
try { Thread.sleep(100); } catch (InterruptedException e) { }
synchronized (lock) {
// Monitor lock rule guarantees x=42 and y=99 are visible here
System.out.println("Reader: sees x=" + x + ", y=" + y);
if (x == 42 && y == 99) {
System.out.println("SUCCESS: Happens-before guarantees visibility.");
System.out.println("All writes before unlock are visible after lock.");
}
}
});
writer.start();
reader.start();
writer.join();
reader.join();
}
}
package memorymodel;
public class DoubleCheckedLockingDemo {
// volatile prevents seeing a partially constructed instance
private static volatile DoubleCheckedLockingDemo instance;
private final String config;
private DoubleCheckedLockingDemo() {
// Simulate expensive initialization
config = "initialized";
System.out.println(Thread.currentThread().getName() + " - Constructor completed");
}
public static DoubleCheckedLockingDemo getInstance() {
// First check: no locking needed (fast path for subsequent calls)
if (instance == null) {
synchronized (DoubleCheckedLockingDemo.class) {
// Second check: inside the lock to prevent duplicate creation
if (instance == null) {
instance = new DoubleCheckedLockingDemo();
}
}
}
return instance;
}
public String getConfig() {
return config;
}
public static void main(String[] args) throws InterruptedException {
int threadCount = 10;
Thread[] threads = new Thread[threadCount];
// Multiple threads try to get the singleton simultaneously
for (int i = 0; i < threadCount; i++) {
threads[i] = new Thread(() -> {
DoubleCheckedLockingDemo singleton = getInstance();
System.out.println(Thread.currentThread().getName()
+ " got instance with config: " + singleton.getConfig());
});
}
// Start all threads to create maximum contention
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("\nOnly ONE constructor call should appear above.");
System.out.println("volatile ensures no thread sees a partially constructed object.");
}
}
You now understand why concurrent code can "work" in testing and break in production. The JMM is not a bug. It is an intentional trade-off that lets the JVM optimize aggressively while giving you explicit tools (volatile, synchronized, happens-before) to enforce visibility when you need it. Next up, you will explore explicit locks and Condition variables, which give you finer control than synchronized blocks alone.
Use Explicit Locks and Conditions
In the previous step, you studied the Java Memory Model and proved that visibility guarantees require explicit action from the programmer.
The synchronized keyword has served you well so far, but it has hard limitations. You cannot interrupt a thread waiting for a lock, time out on acquisition, or attach multiple wait conditions to a single lock.
In this step, you will study the java.util.concurrent.locks package. These explicit locks give you fine-grained control over lock acquisition, timeout, interruption, and condition signaling.
In this step, get ready to:
- Study the Lock interface, ReentrantLock, and Condition variables.
- Implement producer-consumer using ReentrantLock and practice lock acquisition strategies like tryLock and lockInterruptibly.
- Build a read-write cache and benchmark explicit locks against synchronized.
Study ReentrantLock and Condition variables
The Lock interface was introduced to overcome the rigid design of intrinsic locks. It gives you capabilities that synchronized simply cannot provide.
What are the limitations of synchronized?
The synchronized keyword is block-structured. A thread either acquires the lock or blocks indefinitely. There is no way to interrupt a thread waiting for a monitor lock, attempt acquisition without blocking, or time out after a duration.
Each intrinsic lock supports only one wait set. If you have two different conditions (like "buffer not full" and "buffer not empty"), you must use notifyAll() and wake all threads, even those waiting on the wrong condition. You also cannot implement non-block-structured locking patterns like hand-over-hand locking.
What does the Lock interface provide?
The Lock interface defines these key methods:
- lock() acquires the lock, blocking indefinitely if unavailable.
- unlock() releases the lock. Must always be in a finally block.
- tryLock() attempts to acquire the lock without blocking. Returns true if successful, false otherwise.
- tryLock(time, unit) attempts acquisition with a timeout.
- lockInterruptibly() acquires the lock but throws InterruptedException if interrupted while waiting.
- newCondition() creates a Condition variable bound to this lock. You can create multiple conditions per lock.
What is ReentrantLock?
ReentrantLock is the primary implementation of the Lock interface. It has the same mutual exclusion and memory visibility semantics as synchronized but with extended capabilities. Like intrinsic locks, it is reentrant. A thread that already holds the lock can acquire it again without deadlocking (the lock maintains an acquisition count).
ReentrantLock supports configurable fairness. A fair lock (new ReentrantLock(true)) grants acquisition in FIFO order, preventing starvation. An unfair lock (the default) allows barging, where a newly arriving thread can acquire the lock ahead of threads that have been waiting longer. Fair locks have lower throughput due to the overhead of maintaining the queue.
Why must unlock() always be in a finally block?
If an exception occurs between lock() and unlock(), the lock is never released. Every other thread waiting for that lock is permanently blocked. This is a guarantee of deadlock. The try-finally pattern ensures the lock is released regardless of how the protected code exits.
What is the Condition interface?
A Condition object is the explicit-lock equivalent of wait()/notify(). Its key methods are await() (releases the lock and waits), signal() (wakes one waiting thread), and signalAll() (wakes all waiting threads).
The critical advantage is that you can create multiple Condition objects from one lock. For a bounded buffer, you create a notFull condition (producers await on this when the buffer is full) and a notEmpty condition (consumers await on this when the buffer is empty). When a consumer removes an item, it signals only notFull. No consumer threads are unnecessarily woken.
Now put this theory into practice. You will rewrite the producer-consumer pattern from the communication package using ReentrantLock and two Condition variables.
- In IntelliJ, right-click the locks package and select New then Java Class.
- Name it ReentrantLockProducerConsumer and press Enter.
- Replace the generated content with the following code:
package locks;
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockProducerConsumer {
// Shared bounded buffer with a fixed capacity
private static final int CAPACITY = 5;
private static final Queue<Integer> buffer = new LinkedList<>();
// A single lock protects all access to the buffer
private static final ReentrantLock lock = new ReentrantLock();
// Two separate conditions: one for producers, one for consumers
private static final Condition notFull = lock.newCondition();
private static final Condition notEmpty = lock.newCondition();
public static void main(String[] args) throws InterruptedException {
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
lock.lock();
try {
// Wait while the buffer is full
while (buffer.size() == CAPACITY) {
System.out.println("Producer waiting - buffer full");
notFull.await();
}
buffer.add(i);
System.out.println("Produced: " + i + " | Buffer size: " + buffer.size());
// Signal consumers that data is available
notEmpty.signal();
} finally {
lock.unlock();
}
Thread.sleep(100);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Producer");
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
lock.lock();
try {
// Wait while the buffer is empty
while (buffer.isEmpty()) {
System.out.println("Consumer waiting - buffer empty");
notEmpty.await();
}
int value = buffer.poll();
System.out.println("Consumed: " + value + " | Buffer size: " + buffer.size());
// Signal producers that space is available
notFull.signal();
} finally {
lock.unlock();
}
Thread.sleep(150);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Consumer");
producer.start();
consumer.start();
producer.join();
consumer.join();
System.out.println("Done. Final buffer size: " + buffer.size());
}
}
What does this code do?
- A single ReentrantLock protects access to the shared buffer. Every access is wrapped in lock() / unlock() with try-finally.
- Two Condition objects are created from the same lock. notFull is where producers wait when the buffer is at capacity. notEmpty is where consumers wait when the buffer is empty.
- When a producer adds an item, it calls notEmpty.signal() to wake exactly one consumer. When a consumer removes an item, it calls notFull.signal() to wake exactly one producer. No unnecessary wakeups occur.
- Compare this to the wait()/notifyAll() version in your communication package. The two-condition approach is more precise and efficient.
- Run ReentrantLockProducerConsumer by clicking the green play button next to the main method.
You should see interleaved produce/consume messages. Notice the "Producer waiting" and "Consumer waiting" messages when the buffer hits its bounds.
Not seeing any waiting messages?
The sleep times control how often each thread produces or consumes. If the producer and consumer run at similar speeds, you may not hit the buffer bounds. Try reducing the Thread.sleep(100) in the producer to Thread.sleep(50) to make it produce faster than the consumer consumes.
Help me debug my ReentrantLock producer-consumer output
Practice lock acquisition strategies
Explicit locks unlock new strategies for handling the deadlock problem. Instead of blocking forever when a lock is unavailable, you can attempt acquisition and back off gracefully.
How does tryLock() prevent deadlock?
Deadlock requires the "hold and wait" condition. One thread holds Lock A and blocks waiting for Lock B, while another thread holds Lock B and blocks waiting for Lock A. With tryLock(), a thread attempts to acquire the second lock without blocking. If it fails, it releases the first lock and retries. This breaks the "hold and wait" condition entirely.
- Create a new class in the locks package called TryLockDeadlockAvoidance.
- Paste the following code:
package locks;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class TryLockDeadlockAvoidance {
private static final ReentrantLock lock1 = new ReentrantLock();
private static final ReentrantLock lock2 = new ReentrantLock();
public static void main(String[] args) {
// Thread 1 tries lock1 then lock2
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
acquireBothLocks(lock1, lock2, "Thread-1");
}
}, "Thread-1");
// Thread 2 tries lock2 then lock1 (reverse order - would deadlock with synchronized)
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
acquireBothLocks(lock2, lock1, "Thread-2");
}
}, "Thread-2");
thread1.start();
thread2.start();
}
// Attempts to acquire both locks using tryLock(). Backs off if either fails.
private static void acquireBothLocks(ReentrantLock first, ReentrantLock second, String name) {
while (true) {
boolean gotFirst = false;
boolean gotSecond = false;
try {
gotFirst = first.tryLock(50, TimeUnit.MILLISECONDS);
gotSecond = second.tryLock(50, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} finally {
if (gotFirst && gotSecond) {
// Both locks acquired - do the work
try {
System.out.println(name + " acquired both locks - doing work");
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
first.unlock();
second.unlock();
}
return;
} else {
// Could not get both - release whatever we hold and retry
if (gotFirst) first.unlock();
if (gotSecond) second.unlock();
System.out.println(name + " backing off - could not get both locks");
}
}
// Random back-off to reduce contention
try {
Thread.sleep((long) (Math.random() * 50));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
}
What does this code do?
- Two threads try to acquire the same two locks in reverse order. With synchronized, this would deadlock instantly (as you saw in your DeadlockDemo.java).
- tryLock(50, TimeUnit.MILLISECONDS) attempts acquisition with a timeout. If either lock cannot be acquired within 50ms, the thread releases whatever it holds and tries again after a random back-off.
- The random back-off reduces the chance that both threads collide again on the next attempt. Both threads complete successfully. No deadlock is possible.
- Run TryLockDeadlockAvoidance.
You should see both threads successfully acquiring both locks multiple times. You may also see "backing off" messages when contention occurs.
How does lockInterruptibly() work?
With synchronized, once a thread blocks waiting for a monitor, nothing can wake it except the lock becoming available. The thread's interrupt flag is set, but it will not receive an InterruptedException until after it acquires the lock.
lockInterruptibly() changes this. If a thread is interrupted while waiting to acquire the lock, it immediately throws InterruptedException and gives up on acquisition. This is essential for building cancellable, responsive systems.
- Create a new class in the locks package called LockInterruptiblyDemo.
- Paste the following code:
package locks;
import java.util.concurrent.locks.ReentrantLock;
public class LockInterruptiblyDemo {
private static final ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
// First thread acquires the lock and holds it for a long time
Thread holder = new Thread(() -> {
lock.lock();
try {
System.out.println("Holder: acquired the lock, holding for 5 seconds...");
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
System.out.println("Holder: released the lock");
}
}, "Holder");
// Second thread tries to acquire using lockInterruptibly()
Thread waiter = new Thread(() -> {
System.out.println("Waiter: attempting to acquire lock via lockInterruptibly()...");
try {
lock.lockInterruptibly();
try {
System.out.println("Waiter: acquired the lock");
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
// This is the key behavior - interrupted while waiting
System.out.println("Waiter: INTERRUPTED while waiting for lock!");
System.out.println("Waiter: gave up on lock acquisition gracefully");
}
}, "Waiter");
holder.start();
Thread.sleep(100); // Ensure holder acquires first
waiter.start();
Thread.sleep(1000); // Let waiter block for a second
// Interrupt the waiting thread - this would NOT work with synchronized
System.out.println("Main: interrupting the waiter thread");
waiter.interrupt();
holder.join();
waiter.join();
System.out.println("Main: done");
}
}
What does this code do?
- The "Holder" thread acquires the lock and sleeps for 5 seconds, simulating a long-running operation.
- The "Waiter" thread calls lockInterruptibly() and blocks because the lock is held.
- After 1 second, the main thread calls waiter.interrupt(). Because the waiter used lockInterruptibly(), it immediately throws InterruptedException and gives up cleanly.
- If you replaced lockInterruptibly() with lock() or synchronized, the waiter would block for the full 5 seconds, ignoring the interrupt until the lock is acquired.
- Run LockInterruptiblyDemo.
You should see the waiter get interrupted and exit gracefully before the holder finishes its 5-second hold.
Waiter isn't getting interrupted?
Make sure the Thread.sleep(100) after holder.start() is present. Without it, the waiter might acquire the lock before the holder does, meaning there is nothing to interrupt.
Help me understand why my lockInterruptibly demo isn't working
Implement read-write locks and compare performance
Not all concurrent access patterns are the same. Many real-world data structures are read far more often than they are written. A cache is a classic example.
What is a ReadWriteLock?
A ReadWriteLock maintains two locks: a read lock and a write lock. The read lock can be held by multiple threads simultaneously, as long as no thread holds the write lock. The write lock is exclusive. Only one thread can hold it, and no readers can proceed while it is held.
ReentrantReadWriteLock is the standard implementation. For read-heavy workloads (many readers, few writers), it dramatically improves throughput because readers never block each other. For write-heavy workloads, it adds overhead with no benefit because the write lock is still exclusive.
What is StampedLock?
StampedLock (added in Java 8) takes read-write locking further by supporting an optimistic read mode. An optimistic read does not actually acquire any lock. It takes a "stamp" and later validates that no write occurred during the read. If validation succeeds, the read was free. If it fails, you fall back to a full read lock. This provides even higher read throughput when writes are very rare.
- Create a new class in the locks package called ReadWriteCacheDemo.
- Paste the following code:
package locks;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteCacheDemo {
// A simple thread-safe cache backed by a HashMap
private final Map<String, String> cache = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
// Multiple threads can read simultaneously
public String get(String key) {
rwLock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " reading key: " + key);
return cache.get(key);
} finally {
rwLock.readLock().unlock();
}
}
// Only one thread can write at a time, and no readers can proceed
public void put(String key, String value) {
rwLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " WRITING key: " + key);
cache.put(key, value);
Thread.sleep(100); // Simulate slow write
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
rwLock.writeLock().unlock();
}
}
public static void main(String[] args) throws InterruptedException {
ReadWriteCacheDemo demo = new ReadWriteCacheDemo();
// Pre-populate the cache
demo.put("name", "Alice");
demo.put("role", "Engineer");
demo.put("level", "Senior");
// Launch 5 reader threads - they will run concurrently
for (int i = 0; i < 5; i++) {
new Thread(() -> {
for (int j = 0; j < 3; j++) {
demo.get("name");
demo.get("role");
try { Thread.sleep(50); } catch (InterruptedException e) { break; }
}
}, "Reader-" + i).start();
}
// Launch 1 writer thread - it blocks all readers while writing
Thread writer = new Thread(() -> {
demo.put("name", "Bob");
demo.put("role", "Architect");
}, "Writer-0");
writer.start();
writer.join();
Thread.sleep(1000);
System.out.println("Final cache state: name=" + demo.get("name") + ", role=" + demo.get("role"));
}
}
What does this code do?
- The cache wraps a plain HashMap with a ReentrantReadWriteLock. All readers acquire the read lock. All writers acquire the write lock.
- Five reader threads call get() concurrently. Because they all hold the read lock, they never block each other. You will see their print statements interleaved freely.
- When the writer thread calls put(), it acquires the exclusive write lock. All readers are blocked until the write completes. The "WRITING" messages will appear isolated from reader messages.
- Run ReadWriteCacheDemo and observe how read messages cluster together while write messages appear exclusively.
Now create the final program for this step. It benchmarks the three locking approaches against each other on a read-heavy workload to show when ReadWriteLock shines.
- Create a new class in the locks package called LockComparisonDemo.
- Paste the following code:
package locks;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.atomic.AtomicLong;
public class LockComparisonDemo {
private static final int NUM_READERS = 8;
private static final int NUM_WRITERS = 2;
private static final long TEST_DURATION_MS = 3000;
private static int sharedData = 0;
public static void main(String[] args) throws InterruptedException {
System.out.println("Benchmarking lock types with " + NUM_READERS + " readers, " + NUM_WRITERS + " writers");
System.out.println("Test duration: " + TEST_DURATION_MS + "ms per lock type\n");
long syncOps = benchmarkSynchronized();
long reentrantOps = benchmarkReentrantLock();
long rwOps = benchmarkReadWriteLock();
System.out.println("\n=== Results (total read+write operations) ===");
System.out.println("synchronized: " + syncOps);
System.out.println("ReentrantLock: " + reentrantOps);
System.out.println("ReadWriteLock: " + rwOps);
}
// Benchmark using synchronized keyword
private static long benchmarkSynchronized() throws InterruptedException {
final Object lock = new Object();
AtomicLong totalOps = new AtomicLong(0);
long deadline = System.currentTimeMillis() + TEST_DURATION_MS;
Thread[] threads = new Thread[NUM_READERS + NUM_WRITERS];
// Reader threads
for (int i = 0; i < NUM_READERS; i++) {
threads[i] = new Thread(() -> {
long ops = 0;
while (System.currentTimeMillis() < deadline) {
synchronized (lock) {
int ignored = sharedData; // Read
}
ops++;
}
totalOps.addAndGet(ops);
});
}
// Writer threads
for (int i = 0; i < NUM_WRITERS; i++) {
threads[NUM_READERS + i] = new Thread(() -> {
long ops = 0;
while (System.currentTimeMillis() < deadline) {
synchronized (lock) {
sharedData++; // Write
}
ops++;
}
totalOps.addAndGet(ops);
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
return totalOps.get();
}
// Benchmark using ReentrantLock
private static long benchmarkReentrantLock() throws InterruptedException {
ReentrantLock lock = new ReentrantLock();
AtomicLong totalOps = new AtomicLong(0);
long deadline = System.currentTimeMillis() + TEST_DURATION_MS;
Thread[] threads = new Thread[NUM_READERS + NUM_WRITERS];
for (int i = 0; i < NUM_READERS; i++) {
threads[i] = new Thread(() -> {
long ops = 0;
while (System.currentTimeMillis() < deadline) {
lock.lock();
try {
int ignored = sharedData;
} finally {
lock.unlock();
}
ops++;
}
totalOps.addAndGet(ops);
});
}
for (int i = 0; i < NUM_WRITERS; i++) {
threads[NUM_READERS + i] = new Thread(() -> {
long ops = 0;
while (System.currentTimeMillis() < deadline) {
lock.lock();
try {
sharedData++;
} finally {
lock.unlock();
}
ops++;
}
totalOps.addAndGet(ops);
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
return totalOps.get();
}
// Benchmark using ReentrantReadWriteLock
private static long benchmarkReadWriteLock() throws InterruptedException {
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
AtomicLong totalOps = new AtomicLong(0);
long deadline = System.currentTimeMillis() + TEST_DURATION_MS;
Thread[] threads = new Thread[NUM_READERS + NUM_WRITERS];
// Readers use the read lock - can proceed concurrently
for (int i = 0; i < NUM_READERS; i++) {
threads[i] = new Thread(() -> {
long ops = 0;
while (System.currentTimeMillis() < deadline) {
rwLock.readLock().lock();
try {
int ignored = sharedData;
} finally {
rwLock.readLock().unlock();
}
ops++;
}
totalOps.addAndGet(ops);
});
}
// Writers use the write lock - exclusive access
for (int i = 0; i < NUM_WRITERS; i++) {
threads[NUM_READERS + i] = new Thread(() -> {
long ops = 0;
while (System.currentTimeMillis() < deadline) {
rwLock.writeLock().lock();
try {
sharedData++;
} finally {
rwLock.writeLock().unlock();
}
ops++;
}
totalOps.addAndGet(ops);
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
return totalOps.get();
}
}
What does this code do?
- Each benchmark runs 8 reader threads and 2 writer threads for 3 seconds. The threads count how many lock-acquire/release cycles they complete.
- With synchronized and ReentrantLock, every access (reads included) is fully exclusive. Only one thread enters at a time.
- With ReadWriteLock, all 8 readers can proceed concurrently. Only writers are exclusive. This means total operations should be significantly higher for the read-heavy workload.
- Run LockComparisonDemo and wait about 9 seconds for all three benchmarks to complete.
You should see the ReadWriteLock results significantly higher than the other two, demonstrating its advantage for read-heavy access patterns.
Your mileage will vary
Exact numbers depend on your CPU, core count, and what else your machine is doing. The relative ordering is what matters. Run it multiple times to see consistent patterns.
ReadWriteLock results are similar to ReentrantLock?
If your machine has only 1-2 cores, the concurrency benefit of read-write locks is limited because the readers cannot physically run in parallel. The benchmark shines on machines with 4 or more cores. Also check that NUM_READERS is set to 8 and NUM_WRITERS is set to 2.
Help me interpret my lock benchmark results
✔️ Awesome, I've got everything!
Great. Double check you have all 5 files saved in the locks package.
ⓧ I'd like to double check the full code
Here is each file in the locks package for reference.
package locks;
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockProducerConsumer {
// Shared bounded buffer with a fixed capacity
private static final int CAPACITY = 5;
private static final Queue<Integer> buffer = new LinkedList<>();
// A single lock protects all access to the buffer
private static final ReentrantLock lock = new ReentrantLock();
// Two separate conditions: one for producers, one for consumers
private static final Condition notFull = lock.newCondition();
private static final Condition notEmpty = lock.newCondition();
public static void main(String[] args) throws InterruptedException {
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
lock.lock();
try {
// Wait while the buffer is full
while (buffer.size() == CAPACITY) {
System.out.println("Producer waiting - buffer full");
notFull.await();
}
buffer.add(i);
System.out.println("Produced: " + i + " | Buffer size: " + buffer.size());
// Signal consumers that data is available
notEmpty.signal();
} finally {
lock.unlock();
}
Thread.sleep(100);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Producer");
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
lock.lock();
try {
// Wait while the buffer is empty
while (buffer.isEmpty()) {
System.out.println("Consumer waiting - buffer empty");
notEmpty.await();
}
int value = buffer.poll();
System.out.println("Consumed: " + value + " | Buffer size: " + buffer.size());
// Signal producers that space is available
notFull.signal();
} finally {
lock.unlock();
}
Thread.sleep(150);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Consumer");
producer.start();
consumer.start();
producer.join();
consumer.join();
System.out.println("Done. Final buffer size: " + buffer.size());
}
}
package locks;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class TryLockDeadlockAvoidance {
private static final ReentrantLock lock1 = new ReentrantLock();
private static final ReentrantLock lock2 = new ReentrantLock();
public static void main(String[] args) {
// Thread 1 tries lock1 then lock2
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
acquireBothLocks(lock1, lock2, "Thread-1");
}
}, "Thread-1");
// Thread 2 tries lock2 then lock1 (reverse order - would deadlock with synchronized)
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
acquireBothLocks(lock2, lock1, "Thread-2");
}
}, "Thread-2");
thread1.start();
thread2.start();
}
// Attempts to acquire both locks using tryLock(). Backs off if either fails.
private static void acquireBothLocks(ReentrantLock first, ReentrantLock second, String name) {
while (true) {
boolean gotFirst = false;
boolean gotSecond = false;
try {
gotFirst = first.tryLock(50, TimeUnit.MILLISECONDS);
gotSecond = second.tryLock(50, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} finally {
if (gotFirst && gotSecond) {
// Both locks acquired - do the work
try {
System.out.println(name + " acquired both locks - doing work");
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
first.unlock();
second.unlock();
}
return;
} else {
// Could not get both - release whatever we hold and retry
if (gotFirst) first.unlock();
if (gotSecond) second.unlock();
System.out.println(name + " backing off - could not get both locks");
}
}
// Random back-off to reduce contention
try {
Thread.sleep((long) (Math.random() * 50));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
}
package locks;
import java.util.concurrent.locks.ReentrantLock;
public class LockInterruptiblyDemo {
private static final ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
// First thread acquires the lock and holds it for a long time
Thread holder = new Thread(() -> {
lock.lock();
try {
System.out.println("Holder: acquired the lock, holding for 5 seconds...");
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
System.out.println("Holder: released the lock");
}
}, "Holder");
// Second thread tries to acquire using lockInterruptibly()
Thread waiter = new Thread(() -> {
System.out.println("Waiter: attempting to acquire lock via lockInterruptibly()...");
try {
lock.lockInterruptibly();
try {
System.out.println("Waiter: acquired the lock");
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
// This is the key behavior - interrupted while waiting
System.out.println("Waiter: INTERRUPTED while waiting for lock!");
System.out.println("Waiter: gave up on lock acquisition gracefully");
}
}, "Waiter");
holder.start();
Thread.sleep(100); // Ensure holder acquires first
waiter.start();
Thread.sleep(1000); // Let waiter block for a second
// Interrupt the waiting thread - this would NOT work with synchronized
System.out.println("Main: interrupting the waiter thread");
waiter.interrupt();
holder.join();
waiter.join();
System.out.println("Main: done");
}
}
package locks;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteCacheDemo {
// A simple thread-safe cache backed by a HashMap
private final Map<String, String> cache = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
// Multiple threads can read simultaneously
public String get(String key) {
rwLock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " reading key: " + key);
return cache.get(key);
} finally {
rwLock.readLock().unlock();
}
}
// Only one thread can write at a time, and no readers can proceed
public void put(String key, String value) {
rwLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " WRITING key: " + key);
cache.put(key, value);
Thread.sleep(100); // Simulate slow write
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
rwLock.writeLock().unlock();
}
}
public static void main(String[] args) throws InterruptedException {
ReadWriteCacheDemo demo = new ReadWriteCacheDemo();
// Pre-populate the cache
demo.put("name", "Alice");
demo.put("role", "Engineer");
demo.put("level", "Senior");
// Launch 5 reader threads - they will run concurrently
for (int i = 0; i < 5; i++) {
new Thread(() -> {
for (int j = 0; j < 3; j++) {
demo.get("name");
demo.get("role");
try { Thread.sleep(50); } catch (InterruptedException e) { break; }
}
}, "Reader-" + i).start();
}
// Launch 1 writer thread - it blocks all readers while writing
Thread writer = new Thread(() -> {
demo.put("name", "Bob");
demo.put("role", "Architect");
}, "Writer-0");
writer.start();
writer.join();
Thread.sleep(1000);
System.out.println("Final cache state: name=" + demo.get("name") + ", role=" + demo.get("role"));
}
}
package locks;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.atomic.AtomicLong;
public class LockComparisonDemo {
private static final int NUM_READERS = 8;
private static final int NUM_WRITERS = 2;
private static final long TEST_DURATION_MS = 3000;
private static int sharedData = 0;
public static void main(String[] args) throws InterruptedException {
System.out.println("Benchmarking lock types with " + NUM_READERS + " readers, " + NUM_WRITERS + " writers");
System.out.println("Test duration: " + TEST_DURATION_MS + "ms per lock type\n");
long syncOps = benchmarkSynchronized();
long reentrantOps = benchmarkReentrantLock();
long rwOps = benchmarkReadWriteLock();
System.out.println("\n=== Results (total read+write operations) ===");
System.out.println("synchronized: " + syncOps);
System.out.println("ReentrantLock: " + reentrantOps);
System.out.println("ReadWriteLock: " + rwOps);
}
// Benchmark using synchronized keyword
private static long benchmarkSynchronized() throws InterruptedException {
final Object lock = new Object();
AtomicLong totalOps = new AtomicLong(0);
long deadline = System.currentTimeMillis() + TEST_DURATION_MS;
Thread[] threads = new Thread[NUM_READERS + NUM_WRITERS];
// Reader threads
for (int i = 0; i < NUM_READERS; i++) {
threads[i] = new Thread(() -> {
long ops = 0;
while (System.currentTimeMillis() < deadline) {
synchronized (lock) {
int ignored = sharedData; // Read
}
ops++;
}
totalOps.addAndGet(ops);
});
}
// Writer threads
for (int i = 0; i < NUM_WRITERS; i++) {
threads[NUM_READERS + i] = new Thread(() -> {
long ops = 0;
while (System.currentTimeMillis() < deadline) {
synchronized (lock) {
sharedData++; // Write
}
ops++;
}
totalOps.addAndGet(ops);
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
return totalOps.get();
}
// Benchmark using ReentrantLock
private static long benchmarkReentrantLock() throws InterruptedException {
ReentrantLock lock = new ReentrantLock();
AtomicLong totalOps = new AtomicLong(0);
long deadline = System.currentTimeMillis() + TEST_DURATION_MS;
Thread[] threads = new Thread[NUM_READERS + NUM_WRITERS];
for (int i = 0; i < NUM_READERS; i++) {
threads[i] = new Thread(() -> {
long ops = 0;
while (System.currentTimeMillis() < deadline) {
lock.lock();
try {
int ignored = sharedData;
} finally {
lock.unlock();
}
ops++;
}
totalOps.addAndGet(ops);
});
}
for (int i = 0; i < NUM_WRITERS; i++) {
threads[NUM_READERS + i] = new Thread(() -> {
long ops = 0;
while (System.currentTimeMillis() < deadline) {
lock.lock();
try {
sharedData++;
} finally {
lock.unlock();
}
ops++;
}
totalOps.addAndGet(ops);
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
return totalOps.get();
}
// Benchmark using ReentrantReadWriteLock
private static long benchmarkReadWriteLock() throws InterruptedException {
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
AtomicLong totalOps = new AtomicLong(0);
long deadline = System.currentTimeMillis() + TEST_DURATION_MS;
Thread[] threads = new Thread[NUM_READERS + NUM_WRITERS];
// Readers use the read lock - can proceed concurrently
for (int i = 0; i < NUM_READERS; i++) {
threads[i] = new Thread(() -> {
long ops = 0;
while (System.currentTimeMillis() < deadline) {
rwLock.readLock().lock();
try {
int ignored = sharedData;
} finally {
rwLock.readLock().unlock();
}
ops++;
}
totalOps.addAndGet(ops);
});
}
// Writers use the write lock - exclusive access
for (int i = 0; i < NUM_WRITERS; i++) {
threads[NUM_READERS + i] = new Thread(() -> {
long ops = 0;
while (System.currentTimeMillis() < deadline) {
rwLock.writeLock().lock();
try {
sharedData++;
} finally {
rwLock.writeLock().unlock();
}
ops++;
}
totalOps.addAndGet(ops);
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
return totalOps.get();
}
}
You now have five programs in your locks package demonstrating the full range of explicit lock capabilities. Next up, you will study the Executor framework and learn how thread pools eliminate the overhead and risk of creating raw threads.
Master Executors, Thread Pools, and Futures
In the previous step, you built explicit locks and conditions that give you fine-grained control over thread coordination.
Now you face a different problem. Creating a new thread every time you have work to do is expensive. Each thread consumes roughly 1MB of stack memory, requires an OS system call, and gets registered with the scheduler. If 10,000 requests arrive, spawning 10,000 threads will exhaust your system. Thread pools solve this by reusing a fixed set of threads across many tasks.
In this step, get ready to:
- Submit tasks to thread pools and observe thread reuse, lifecycle shutdown, and rejection behavior.
- Compute asynchronous results using Callable, Future, and CompletableFuture chains.
- Parallelize divide-and-conquer algorithms using ForkJoinPool and RecursiveTask.
Submit tasks to thread pools
Every time you create a thread with new Thread(), the JVM makes an OS system call, allocates a thread stack, and registers the thread with the scheduler. Once the thread finishes, it cannot be restarted. The Executor framework solves this by decoupling task submission from thread management.
What is the Executor framework?
The framework is a hierarchy of interfaces. Executor is the base interface with a single execute(Runnable) method. ExecutorService extends it with lifecycle management (shutdown(), awaitTermination()) and task submission that returns Future objects. ScheduledExecutorService adds support for delayed and periodic tasks.
You almost never implement these interfaces directly. Instead, you use the Executors factory class to create common pool types.
What are the Executors factory methods?
- newFixedThreadPool(n) creates exactly N threads with an unbounded LinkedBlockingQueue. Use this for CPU-bound tasks where you want a controlled number of threads.
- newCachedThreadPool() creates threads as needed and reuses idle threads. Threads idle for 60 seconds are removed. Uses a SynchronousQueue (no buffering). Best for many short-lived I/O tasks.
- newSingleThreadExecutor() runs one thread with an unbounded queue. Tasks execute sequentially, guaranteeing ordering.
- newScheduledThreadPool(n) supports delayed and periodic execution (cron-like behavior in Java).
- newWorkStealingPool() creates a ForkJoinPool-based pool with parallelism defaulting to available processors. Good for parallel computation with uneven subtask sizes.
How does ThreadPoolExecutor work internally?
When a task is submitted: if fewer than corePoolSize threads are running, a new thread is created. Otherwise the task is placed in the work queue. If the queue is full and fewer than maxPoolSize threads exist, a new thread is created. If the max is reached and the queue is full, the task is rejected.
The four rejection policies are: AbortPolicy (throws exception), CallerRunsPolicy (runs in the submitting thread), DiscardPolicy (silently drops the task), and DiscardOldestPolicy (drops the oldest queued task).
- In the executors package, create a new file called FixedThreadPoolDemo.java and paste the following code:
package executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class FixedThreadPoolDemo {
public static void main(String[] args) {
// Create a fixed pool with 4 threads
ExecutorService executor = Executors.newFixedThreadPool(4);
// Submit 20 tasks to observe thread reuse
for (int i = 1; i <= 20; i++) {
final int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId
+ " executed by " + Thread.currentThread().getName());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
// Shut down the pool after all tasks are submitted
executor.shutdown();
}
}
What does this code do?
- newFixedThreadPool(4) creates a pool with exactly 4 worker threads. No matter how many tasks you submit, only 4 threads exist.
- The loop submits 20 tasks. Since only 4 threads are available, the remaining 16 tasks wait in the queue until a thread becomes free.
- Each task prints its thread name. You will see only 4 unique names repeated across all 20 tasks. This proves thread reuse.
- shutdown() tells the pool to stop accepting new tasks but lets all submitted tasks complete before terminating.
- Run FixedThreadPoolDemo.java.
- You should see 20 lines of output, each showing a task ID and a thread name like pool-1-thread-1 through pool-1-thread-4. Notice the same 4 names repeat across all 20 tasks.
Tasks seem to run sequentially?
If you see tasks completing one at a time, verify the pool size is 4 (not 1). Also check that the sleep duration is not too long. With a 500ms sleep and 4 threads, batches of 4 tasks should print together before the next batch begins.
Help me debug why my thread pool tasks run one at a time
Now let's look at proper lifecycle management. A thread pool that is never shut down keeps its threads alive indefinitely, preventing the JVM from exiting.
What is the two-phase shutdown pattern?
shutdown() stops accepting new tasks but lets already-submitted tasks complete. shutdownNow() interrupts running tasks and returns the list of waiting tasks. The standard pattern is: call shutdown() first, then call awaitTermination() to block until all tasks finish (or a timeout expires).
- Create ShutdownDemo.java in the executors package and paste the following code:
package executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ShutdownDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3);
// Submit several tasks that each take 1 second
for (int i = 1; i <= 10; i++) {
final int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId
+ " running on " + Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
// Initiate orderly shutdown - no new tasks accepted
executor.shutdown();
System.out.println("Shutdown initiated. Waiting for tasks to complete...");
try {
// Wait up to 60 seconds for all tasks to finish
boolean completed = executor.awaitTermination(60, TimeUnit.SECONDS);
if (completed) {
System.out.println("All tasks completed successfully.");
} else {
System.out.println("Timeout elapsed before all tasks finished.");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
What does this code do?
- After submitting 10 tasks, shutdown() is called. This prevents new task submissions but does not cancel tasks already in the queue.
- awaitTermination(60, TimeUnit.SECONDS) blocks the main thread until either all tasks finish or 60 seconds pass. It returns true if the pool terminated, false if the timeout expired first.
- Run ShutdownDemo.java. You should see the "Shutdown initiated" message print immediately, followed by all 10 tasks completing, followed by "All tasks completed successfully."
What happens if you try to submit a task after calling shutdown()? The pool rejects it.
- Create RejectedExecutionDemo.java in the executors package and paste the following code:
package executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
public class RejectedExecutionDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor();
// Submit one task and immediately shut down
executor.submit(() -> System.out.println("First task executed."));
executor.shutdown();
// Attempt to submit after shutdown - this will be rejected
try {
executor.submit(() -> System.out.println("This will never run."));
} catch (RejectedExecutionException e) {
System.out.println("Caught RejectedExecutionException: " + e.getMessage());
}
}
}
What does this code do?
After shutdown() is called, any further call to submit() throws a RejectedExecutionException. The default rejection policy (AbortPolicy) is to throw this exception. This is the pool's way of saying "I am no longer accepting work."
- Run RejectedExecutionDemo.java. You should see "First task executed." followed by the caught exception message.
Not seeing the exception message?
If you do not see the RejectedExecutionException message, ensure shutdown() is called before the second submit(). The ordering matters because shutdown() flips the pool's state to "shutting down" which triggers the rejection.
Help me debug my RejectedExecutionDemo
Compute results asynchronously with Callable and Future
Runnable has a limitation. Its run() method returns void and cannot throw checked exceptions. When you need a task to compute and return a result, you use Callable<V> instead.
What is the difference between Callable and Runnable?
- Runnable.run() returns void and cannot throw checked exceptions. It represents a fire-and-forget task.
- Callable<V>.call() returns a value of type V and can throw checked exceptions. It represents a task that produces a result.
When you submit a Callable to an ExecutorService, you get back a Future<V> object. This is a handle to the result that will be available later.
What can you do with a Future?
- get() blocks indefinitely until the result is available.
- get(timeout, unit) blocks until the result is available or the timeout expires (throws TimeoutException).
- cancel(boolean mayInterrupt) attempts to cancel the task. If mayInterrupt is true, the running thread is interrupted.
- isDone() and isCancelled() let you check the task's status without blocking.
- Create CallableFutureDemo.java in the executors package and paste the following code:
package executors;
import java.util.concurrent.*;
public class CallableFutureDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor();
// Submit a Callable that returns a result after simulating work
Callable<Integer> task = () -> {
System.out.println("Computing result...");
Thread.sleep(2000);
return 42;
};
Future<Integer> future = executor.submit(task);
// Do other work while the task computes
System.out.println("Task submitted, doing other work...");
System.out.println("Is done? " + future.isDone());
try {
// Blocking get - waits until the result is available
Integer result = future.get();
System.out.println("Result: " + result);
// Demonstrate timeout variant
Future<Integer> future2 = executor.submit(() -> {
Thread.sleep(5000);
return 99;
});
// This throws TimeoutException after 1 second
Integer result2 = future2.get(1, TimeUnit.SECONDS);
System.out.println("Result2: " + result2);
} catch (TimeoutException e) {
System.out.println("Timed out waiting for result!");
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
executor.shutdown();
}
}
What does this code do?
- The first Callable simulates a 2-second computation and returns 42. While it computes, the main thread continues executing (isDone() returns false).
- future.get() blocks the main thread until the result is ready. This is the trade-off: you get a result back, but you block while waiting for it.
- The second future demonstrates the timeout variant. The task takes 5 seconds but we only wait 1 second, so TimeoutException is thrown.
- Run CallableFutureDemo.java. You should see "Is done? false", then after 2 seconds "Result: 42", then after 1 more second "Timed out waiting for result!".
The blocking nature of Future.get() is its biggest limitation. You cannot chain multiple async operations without blocking in between. CompletableFuture solves this with callback-style composition.
What is CompletableFuture?
CompletableFuture lets you build asynchronous pipelines without blocking. Each stage transforms or combines results from previous stages. The key methods fall into categories:
- Creation: supplyAsync() starts an async computation that returns a value. runAsync() starts one that returns nothing.
- Transformation: thenApply() transforms the result (like map). thenAccept() consumes it. thenRun() runs an action after completion.
- Composition: thenCompose() is flatMap: chains another async operation. thenCombine() merges results from two independent futures.
- Aggregation: allOf() waits for all futures to complete. anyOf() completes as soon as any one future finishes.
- Error handling: exceptionally() provides a fallback value on failure. handle() processes both success and failure. whenComplete() runs a callback regardless of outcome.
- Create CompletableFutureChainDemo.java in the executors package and paste the following code:
package executors;
import java.util.concurrent.CompletableFuture;
public class CompletableFutureChainDemo {
public static void main(String[] args) {
// Chain: supplyAsync -> thenApply -> thenCompose -> thenCombine -> exceptionally
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
System.out.println("Stage 1: supplying value on "
+ Thread.currentThread().getName());
return 50;
})
.thenApply(value -> {
// Transform the integer result into a string
System.out.println("Stage 2: thenApply - doubling value");
return "Result: " + (value * 2);
})
.thenCompose(str -> {
// Chain another async operation (flatMap)
System.out.println("Stage 3: thenCompose - creating new future");
return CompletableFuture.supplyAsync(() -> str + " [composed]");
})
.thenCombine(
// Combine with an independent future
CompletableFuture.supplyAsync(() -> " + combined"),
(result1, result2) -> {
System.out.println("Stage 4: thenCombine - merging results");
return result1 + result2;
}
)
.exceptionally(ex -> {
// Fallback if any stage throws an exception
System.out.println("Exception occurred: " + ex.getMessage());
return "Fallback value";
});
// join() blocks until the pipeline completes (like get() but unchecked)
String result = future.join();
System.out.println("Final result: " + result);
}
}
What does this code do?
- supplyAsync() starts the pipeline on the common ForkJoinPool (the default executor for async methods).
- thenApply() transforms the integer 50 into a string. This is synchronous relative to the previous stage.
- thenCompose() creates a brand new async future from the previous result. This is the async equivalent of flatMap.
- thenCombine() runs an independent future in parallel and merges both results when they are both ready.
- exceptionally() catches any exception thrown by any stage and returns a fallback value. In this demo no exception is thrown, so it is never invoked.
- Run CompletableFutureChainDemo.java. You should see stages 1 through 4 printing in order, followed by a final result that combines all the transformations.
Program exits before printing anything?
If the program ends immediately with no output, check that you are calling future.join() at the end. Without join(), the main thread finishes before the async pipeline completes, and since the ForkJoinPool uses daemon threads, the JVM exits.
Help me understand why my CompletableFuture chain produces no output
Parallelize with ForkJoinPool and work-stealing
Thread pools like newFixedThreadPool are designed for independent tasks. But what about divide-and-conquer algorithms where a task recursively splits into subtasks? The ForkJoinPool is built specifically for this pattern.
How does ForkJoinPool and work-stealing work?
Each worker thread in a ForkJoinPool has its own double-ended queue (deque). When a task forks subtasks, those subtasks go onto the current thread's deque. If a worker thread runs out of work, it "steals" tasks from the tail of another thread's deque.
You implement divide-and-conquer using RecursiveTask<V> (returns a value) or RecursiveAction (no return value). Override the compute() method, split the problem, call fork() on subtasks to schedule them asynchronously, then call join() to wait for their results.
- Create ForkJoinMergeSortDemo.java in the executors package and paste the following code:
package executors;
import java.util.Arrays;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
public class ForkJoinMergeSortDemo {
// RecursiveTask that splits, forks, joins, and merges
static class MergeSortTask extends RecursiveTask<int[]> {
private final int[] array;
MergeSortTask(int[] array) {
this.array = array;
}
@Override
protected int[] compute() {
// Base case: arrays of length 1 or less are already sorted
if (array.length <= 1) {
return array;
}
// Split the array in half
int mid = array.length / 2;
int[] left = Arrays.copyOfRange(array, 0, mid);
int[] right = Arrays.copyOfRange(array, mid, array.length);
// Fork both halves as subtasks
MergeSortTask leftTask = new MergeSortTask(left);
MergeSortTask rightTask = new MergeSortTask(right);
leftTask.fork();
rightTask.fork();
// Join (wait for) the results of both subtasks
int[] sortedLeft = leftTask.join();
int[] sortedRight = rightTask.join();
// Merge the two sorted halves
return merge(sortedLeft, sortedRight);
}
private int[] merge(int[] left, int[] right) {
int[] result = new int[left.length + right.length];
int i = 0, j = 0, k = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) {
result[k++] = left[i++];
} else {
result[k++] = right[j++];
}
}
while (i < left.length) result[k++] = left[i++];
while (j < right.length) result[k++] = right[j++];
return result;
}
}
public static void main(String[] args) {
int[] data = {38, 27, 43, 3, 9, 82, 10, 1, 57, 24, 16, 91, 45, 6, 73, 12};
System.out.println("Unsorted: " + Arrays.toString(data));
// Submit to ForkJoinPool.commonPool()
int[] sorted = ForkJoinPool.commonPool().invoke(new MergeSortTask(data));
System.out.println("Sorted: " + Arrays.toString(sorted));
System.out.println("Parallelism level: "
+ ForkJoinPool.commonPool().getParallelism());
}
}
What does this code do?
- MergeSortTask extends RecursiveTask<int[]> because it returns a sorted array. The compute() method implements the divide-and-conquer logic.
- fork() submits the subtask to the current thread's deque for potential work-stealing by other threads. It does not block.
- join() blocks until the subtask completes and returns its result. While waiting, the thread may steal other tasks rather than sitting idle.
- ForkJoinPool.commonPool().invoke() submits the root task and blocks until the entire tree of subtasks completes. The parallelism level defaults to the number of available processors minus one.
- Run ForkJoinMergeSortDemo.java. You should see the unsorted array printed, then the sorted array in ascending order, followed by the parallelism level matching your CPU core count minus one.
Array not sorted correctly?
If the output array is not fully sorted, check the merge() method. The most common bug is forgetting the trailing while loops that flush remaining elements from either half. Also verify that Arrays.copyOfRange() boundaries are correct (0 to mid, mid to length).
Help me debug my parallel merge sort implementation
Why use ForkJoinPool instead of a fixed thread pool?
A fixed thread pool with a shared queue creates a bottleneck. Every task submission and retrieval contends on a single queue. ForkJoinPool gives each thread its own deque, dramatically reducing contention for recursive algorithms.
Work-stealing also handles uneven task sizes gracefully. If one branch of the recursion is deeper than another, idle threads steal work from busy threads instead of sitting dormant.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all 6 files in your executors package: FixedThreadPoolDemo.java, CallableFutureDemo.java, CompletableFutureChainDemo.java, ForkJoinMergeSortDemo.java, ShutdownDemo.java, and RejectedExecutionDemo.java.
ⓧ I'd like to double check the full code
Here are all 6 files for this step. Compare them with what you have.
package executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class FixedThreadPoolDemo {
public static void main(String[] args) {
// Create a fixed pool with 4 threads
ExecutorService executor = Executors.newFixedThreadPool(4);
// Submit 20 tasks to observe thread reuse
for (int i = 1; i <= 20; i++) {
final int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId
+ " executed by " + Thread.currentThread().getName());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
// Shut down the pool after all tasks are submitted
executor.shutdown();
}
}
package executors;
import java.util.concurrent.*;
public class CallableFutureDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor();
// Submit a Callable that returns a result after simulating work
Callable<Integer> task = () -> {
System.out.println("Computing result...");
Thread.sleep(2000);
return 42;
};
Future<Integer> future = executor.submit(task);
// Do other work while the task computes
System.out.println("Task submitted, doing other work...");
System.out.println("Is done? " + future.isDone());
try {
// Blocking get - waits until the result is available
Integer result = future.get();
System.out.println("Result: " + result);
// Demonstrate timeout variant
Future<Integer> future2 = executor.submit(() -> {
Thread.sleep(5000);
return 99;
});
// This throws TimeoutException after 1 second
Integer result2 = future2.get(1, TimeUnit.SECONDS);
System.out.println("Result2: " + result2);
} catch (TimeoutException e) {
System.out.println("Timed out waiting for result!");
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
executor.shutdown();
}
}
package executors;
import java.util.concurrent.CompletableFuture;
public class CompletableFutureChainDemo {
public static void main(String[] args) {
// Chain: supplyAsync -> thenApply -> thenCompose -> thenCombine -> exceptionally
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
System.out.println("Stage 1: supplying value on "
+ Thread.currentThread().getName());
return 50;
})
.thenApply(value -> {
// Transform the integer result into a string
System.out.println("Stage 2: thenApply - doubling value");
return "Result: " + (value * 2);
})
.thenCompose(str -> {
// Chain another async operation (flatMap)
System.out.println("Stage 3: thenCompose - creating new future");
return CompletableFuture.supplyAsync(() -> str + " [composed]");
})
.thenCombine(
// Combine with an independent future
CompletableFuture.supplyAsync(() -> " + combined"),
(result1, result2) -> {
System.out.println("Stage 4: thenCombine - merging results");
return result1 + result2;
}
)
.exceptionally(ex -> {
// Fallback if any stage throws an exception
System.out.println("Exception occurred: " + ex.getMessage());
return "Fallback value";
});
// join() blocks until the pipeline completes (like get() but unchecked)
String result = future.join();
System.out.println("Final result: " + result);
}
}
package executors;
import java.util.Arrays;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
public class ForkJoinMergeSortDemo {
// RecursiveTask that splits, forks, joins, and merges
static class MergeSortTask extends RecursiveTask<int[]> {
private final int[] array;
MergeSortTask(int[] array) {
this.array = array;
}
@Override
protected int[] compute() {
// Base case: arrays of length 1 or less are already sorted
if (array.length <= 1) {
return array;
}
// Split the array in half
int mid = array.length / 2;
int[] left = Arrays.copyOfRange(array, 0, mid);
int[] right = Arrays.copyOfRange(array, mid, array.length);
// Fork both halves as subtasks
MergeSortTask leftTask = new MergeSortTask(left);
MergeSortTask rightTask = new MergeSortTask(right);
leftTask.fork();
rightTask.fork();
// Join (wait for) the results of both subtasks
int[] sortedLeft = leftTask.join();
int[] sortedRight = rightTask.join();
// Merge the two sorted halves
return merge(sortedLeft, sortedRight);
}
private int[] merge(int[] left, int[] right) {
int[] result = new int[left.length + right.length];
int i = 0, j = 0, k = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) {
result[k++] = left[i++];
} else {
result[k++] = right[j++];
}
}
while (i < left.length) result[k++] = left[i++];
while (j < right.length) result[k++] = right[j++];
return result;
}
}
public static void main(String[] args) {
int[] data = {38, 27, 43, 3, 9, 82, 10, 1, 57, 24, 16, 91, 45, 6, 73, 12};
System.out.println("Unsorted: " + Arrays.toString(data));
// Submit to ForkJoinPool.commonPool()
int[] sorted = ForkJoinPool.commonPool().invoke(new MergeSortTask(data));
System.out.println("Sorted: " + Arrays.toString(sorted));
System.out.println("Parallelism level: "
+ ForkJoinPool.commonPool().getParallelism());
}
}
package executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ShutdownDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3);
// Submit several tasks that each take 1 second
for (int i = 1; i <= 10; i++) {
final int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId
+ " running on " + Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
// Initiate orderly shutdown - no new tasks accepted
executor.shutdown();
System.out.println("Shutdown initiated. Waiting for tasks to complete...");
try {
// Wait up to 60 seconds for all tasks to finish
boolean completed = executor.awaitTermination(60, TimeUnit.SECONDS);
if (completed) {
System.out.println("All tasks completed successfully.");
} else {
System.out.println("Timeout elapsed before all tasks finished.");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
package executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
public class RejectedExecutionDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor();
// Submit one task and immediately shut down
executor.submit(() -> System.out.println("First task executed."));
executor.shutdown();
// Attempt to submit after shutdown - this will be rejected
try {
executor.submit(() -> System.out.println("This will never run."));
} catch (RejectedExecutionException e) {
System.out.println("Caught RejectedExecutionException: " + e.getMessage());
}
}
}
You now have a complete picture of how Java manages concurrent work at scale. Thread pools reuse threads, Futures let you retrieve results, CompletableFuture lets you compose async pipelines, and ForkJoinPool parallelizes recursive algorithms. Next up, you will explore atomic variables and lock-free programming with CAS operations.
Apply Atomic Variables and Concurrent Collections
In the previous step, you explored thread pools, Futures, and CompletableFuture chains for managing asynchronous work. Those tools handle task scheduling, but every shared variable still needed a lock to stay consistent.
This step introduces a fundamentally different approach: lock-free programming with atomic variables and purpose-built concurrent collections. Instead of blocking other threads with a lock, these tools use a hardware-level instruction called Compare-And-Swap to update shared state without ever holding a lock.
In this step, get ready to:
- Replace a synchronized counter with atomic variables and measure the performance difference.
- Build a lock-free stack using CAS retry loops.
- Rewrite producer-consumer with concurrent collections and benchmark AtomicLong vs LongAdder.
Study the Compare-And-Swap operation and atomic variables
Every synchronization technique you have used so far (synchronized blocks, ReentrantLock, ReadWriteLock) works by blocking. When one thread holds a lock, other threads must wait. Under high contention, this means threads spend more time waiting than working.
Atomic variables offer an alternative. They use a single CPU instruction to update a value without ever acquiring a lock.
What is Compare-And-Swap (CAS)?
CAS is a hardware instruction (CMPXCHG on x86 processors) that takes three arguments: a memory location, the expected old value, and the new value. If the current value at that memory location matches the expected value, the CPU atomically swaps in the new value and returns true. Otherwise it does nothing and returns false.
This single instruction is the foundation of all lock-free algorithms in Java. Instead of acquiring a lock, a thread reads the current value, computes the new value, then attempts a CAS. If another thread modified the value in between, CAS fails and the thread retries. Under low contention, this is faster than locking because there is no context switch.
What is the ABA problem?
If a value changes from A to B and back to A, CAS thinks nothing happened because the current value still matches the expected value. This can corrupt lock-free data structures where the meaning of "A" has changed even though the bits are the same.
Java solves this with AtomicStampedReference, which pairs each reference with an integer stamp. CAS then checks both the reference and the stamp, detecting changes even when the reference returns to its original value.
What classes does the java.util.concurrent.atomic package provide?
The java.util.concurrent.atomic package provides AtomicInteger, AtomicLong, AtomicBoolean, and AtomicReference. Key methods include get(), set(), compareAndSet(), getAndIncrement(), incrementAndGet(), getAndUpdate(), and accumulateAndGet().
Use atomics for simple single-variable updates (counters, flags, references). Use locks when you need to update multiple related variables as one atomic unit.
- Create a new Java class in your atomic package called AtomicCounterDemo.java.
- This program runs two versions of a counter incremented by 8 threads: one using synchronized, one using AtomicInteger. Copy the following code:
package atomic;
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounterDemo {
// Synchronized version: threads must acquire a lock to increment
static int syncCounter = 0;
static final Object lock = new Object();
// Atomic version: uses CAS internally, no lock needed
static AtomicInteger atomicCounter = new AtomicInteger(0);
static final int THREADS = 8;
static final int INCREMENTS_PER_THREAD = 1_000_000;
public static void main(String[] args) throws InterruptedException {
// Run synchronized version and measure time
long syncTime = runSynchronized();
System.out.println("Synchronized counter: " + syncCounter);
System.out.println("Synchronized time: " + syncTime + " ms");
System.out.println();
// Run atomic version and measure time
long atomicTime = runAtomic();
System.out.println("Atomic counter: " + atomicCounter.get());
System.out.println("Atomic time: " + atomicTime + " ms");
}
static long runSynchronized() throws InterruptedException {
Thread[] threads = new Thread[THREADS];
long start = System.nanoTime();
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
// Every increment acquires and releases the lock
synchronized (lock) {
syncCounter++;
}
}
});
threads[i].start();
}
for (Thread t : threads) t.join();
long elapsed = (System.nanoTime() - start) / 1_000_000;
return elapsed;
}
static long runAtomic() throws InterruptedException {
Thread[] threads = new Thread[THREADS];
long start = System.nanoTime();
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
// CAS-based increment: no lock, no blocking
atomicCounter.incrementAndGet();
}
});
threads[i].start();
}
for (Thread t : threads) t.join();
long elapsed = (System.nanoTime() - start) / 1_000_000;
return elapsed;
}
}
What does this code do?
Both versions increment a counter 8 million times (8 threads x 1,000,000 each). The synchronized version forces each thread to acquire and release a lock for every single increment. The atomic version uses incrementAndGet() which internally performs a CAS loop.
Both counters produce the correct result (8,000,000). The difference is how long each approach takes. Under this level of contention, the atomic version is typically faster because CAS avoids the overhead of context switching between blocked threads.
- Run AtomicCounterDemo.java by right-clicking the file and selecting Run.
- You should see both counters print 8000000, with the atomic version completing faster.
Results vary by machine
The exact speedup depends on your CPU and how many cores are available. Run it a few times. The atomic version is consistently faster because it avoids the lock acquisition overhead, but the margin varies.
Now let's go further and build a complete data structure without any locks at all.
- Create a new Java class in your atomic package called LockFreeStackDemo.java.
- This demonstrates a stack backed entirely by AtomicReference and CAS retry loops. Copy the following code:
package atomic;
import java.util.concurrent.atomic.AtomicReference;
public class LockFreeStackDemo {
// Each node holds a value and a pointer to the next node
static class Node<T> {
final T value;
Node<T> next;
Node(T value) {
this.value = value;
}
}
// The top of the stack is an atomic reference to a Node
static class LockFreeStack<T> {
private final AtomicReference<Node<T>> top = new AtomicReference<>(null);
// Push: create new node, point it to current top, CAS to swap top
public void push(T value) {
Node<T> newNode = new Node<>(value);
while (true) {
Node<T> currentTop = top.get();
newNode.next = currentTop;
// If top is still currentTop, atomically replace it with newNode
if (top.compareAndSet(currentTop, newNode)) {
return; // Success
}
// CAS failed: another thread modified top, retry
}
}
// Pop: read current top, CAS to replace it with top.next
public T pop() {
while (true) {
Node<T> currentTop = top.get();
if (currentTop == null) {
return null; // Stack is empty
}
Node<T> newTop = currentTop.next;
// If top is still currentTop, atomically replace it with newTop
if (top.compareAndSet(currentTop, newTop)) {
return currentTop.value; // Success
}
// CAS failed: another thread modified top, retry
}
}
}
public static void main(String[] args) throws InterruptedException {
LockFreeStack<Integer> stack = new LockFreeStack<>();
int THREADS = 4;
int ITEMS_PER_THREAD = 10_000;
// 4 threads push items concurrently
Thread[] pushers = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
final int threadId = i;
pushers[i] = new Thread(() -> {
for (int j = 0; j < ITEMS_PER_THREAD; j++) {
stack.push(threadId * ITEMS_PER_THREAD + j);
}
});
pushers[i].start();
}
for (Thread t : pushers) t.join();
// Pop all items and count them
int count = 0;
while (stack.pop() != null) {
count++;
}
System.out.println("Pushed: " + (THREADS * ITEMS_PER_THREAD));
System.out.println("Popped: " + count);
System.out.println("All items accounted for: " + (count == THREADS * ITEMS_PER_THREAD));
}
}
How does the CAS retry loop work?
Both push() and pop() follow the same pattern: read the current state, prepare the new state, then attempt a CAS. If another thread changed the stack between the read and the CAS, the operation fails and loops back to try again.
No thread ever blocks. Under low contention most CAS attempts succeed on the first try. Under high contention threads may retry a few times, but they never sleep or context-switch. This makes lock-free structures particularly fast for short critical sections.
- Run LockFreeStackDemo.java.
- You should see "All items accounted for: true" confirming that 40,000 items were pushed and popped without any lock and without losing a single item.
When should I use atomics vs locks?
Atomics shine for simple single-variable updates: counters, flags, single reference swaps. Locks are necessary when you need to update multiple related variables as one atomic unit. If your critical section involves reading two fields, computing something, and writing to three fields, a lock is simpler and less error-prone than trying to coordinate multiple CAS operations.
What are LongAdder and LongAccumulator?
Under high contention, even AtomicLong can become a bottleneck because every thread CAS-retries on the same memory location. LongAdder solves this by maintaining a table of cells. Each thread updates a different cell, spreading the contention across multiple memory locations.
Calling sum() aggregates all cells to produce the final count. The trade-off: you get higher write throughput but sum() provides only an eventually consistent read (not an atomic point-in-time snapshot). Use LongAdder when you need a high-throughput counter but only read the total at the end.
- Create a new Java class in your atomic package called LongAdderBenchmark.java.
- This program benchmarks AtomicLong against LongAdder with 16 threads under sustained contention. Copy the following code:
package atomic;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
public class LongAdderBenchmark {
static final int THREADS = 16;
static final long DURATION_SECONDS = 5;
public static void main(String[] args) throws InterruptedException {
System.out.println("Benchmarking with " + THREADS + " threads for " + DURATION_SECONDS + "s each...");
System.out.println();
// Benchmark AtomicLong
long atomicOps = benchmarkAtomicLong();
System.out.println("AtomicLong total ops: " + atomicOps);
System.out.println("AtomicLong throughput: " + (atomicOps / DURATION_SECONDS) + " ops/sec");
System.out.println();
// Benchmark LongAdder
long adderOps = benchmarkLongAdder();
System.out.println("LongAdder total ops: " + adderOps);
System.out.println("LongAdder throughput: " + (adderOps / DURATION_SECONDS) + " ops/sec");
System.out.println();
System.out.println("LongAdder speedup: " + String.format("%.2f", (double) adderOps / atomicOps) + "x");
}
static long benchmarkAtomicLong() throws InterruptedException {
AtomicLong counter = new AtomicLong(0);
// Flag to signal threads to stop
volatile boolean[] running = {true};
Thread[] threads = new Thread[THREADS];
long[] counts = new long[THREADS];
long startTime = System.nanoTime();
long endTime = startTime + DURATION_SECONDS * 1_000_000_000L;
for (int i = 0; i < THREADS; i++) {
final int idx = i;
threads[i] = new Thread(() -> {
long localCount = 0;
// Each thread increments until time runs out
while (System.nanoTime() < endTime) {
counter.incrementAndGet();
localCount++;
}
counts[idx] = localCount;
});
threads[i].start();
}
for (Thread t : threads) t.join();
long totalOps = 0;
for (long c : counts) totalOps += c;
return totalOps;
}
static long benchmarkLongAdder() throws InterruptedException {
LongAdder counter = new LongAdder();
Thread[] threads = new Thread[THREADS];
long[] counts = new long[THREADS];
long startTime = System.nanoTime();
long endTime = startTime + DURATION_SECONDS * 1_000_000_000L;
for (int i = 0; i < THREADS; i++) {
final int idx = i;
threads[i] = new Thread(() -> {
long localCount = 0;
// Each thread increments its own internal cell
while (System.nanoTime() < endTime) {
counter.increment();
localCount++;
}
counts[idx] = localCount;
});
threads[i].start();
}
for (Thread t : threads) t.join();
long totalOps = 0;
for (long c : counts) totalOps += c;
return totalOps;
}
}
What does this benchmark measure?
Both tests run 16 threads hammering a counter for 5 seconds using System.nanoTime() for precise timing. The total number of successful increments becomes the throughput metric.
With AtomicLong, all 16 threads CAS-retry on the same memory location, creating a bottleneck. With LongAdder, threads update different cells, so contention is spread out. You should see LongAdder achieve significantly higher throughput (often 2-5x or more depending on your CPU).
- Run LongAdderBenchmark.java.
- The program takes about 10 seconds (5 seconds per benchmark). You should see LongAdder reporting a higher ops/sec number and a speedup multiplier greater than 1x.
Seeing similar numbers for both?
If your machine has very few cores (2-4), the contention may not be high enough to differentiate the two approaches significantly. The difference becomes dramatic on 8+ core machines where 16 threads truly run in parallel. Try increasing THREADS to 32 or 64 to amplify the effect.
Why isn't my LongAdder benchmark showing a speedup?
Apply concurrent collections to simplify thread-safe code
Atomic variables handle individual values. But real programs need thread-safe data structures: queues, maps, and lists. The java.util.concurrent package provides collections that are designed from the ground up for concurrent access.
What concurrent collections does Java provide?
- ConcurrentHashMap: uses node-based locking (Java 8+). Only the bin being modified is locked, so reads and writes to different keys proceed in parallel. Supports atomic compound operations like compute(), merge(), and computeIfAbsent().
- CopyOnWriteArrayList: every write creates a new array copy. Iterators see a snapshot from when they were created and never throw ConcurrentModificationException. Best for read-heavy, small lists.
- BlockingQueue family: ArrayBlockingQueue (bounded, array-backed), LinkedBlockingQueue (optionally bounded, linked-node), PriorityBlockingQueue (unbounded, priority-ordered). put() blocks when full, take() blocks when empty.
- ConcurrentLinkedQueue: non-blocking, lock-free queue using CAS internally. Does not block on any operation.
What happens-before guarantees do concurrent collections provide?
Placing an element into a concurrent collection happens-before a subsequent retrieval of that element by another thread. This means any writes the producing thread made before the put are visible to the consuming thread after the take. You get memory visibility for free without needing volatile or explicit synchronization on surrounding code.
Remember the producer-consumer pattern from the communication step? You used wait() and notifyAll() with careful synchronized blocks. Watch how BlockingQueue reduces that to almost nothing.
- Create a new Java class in your atomic package called BlockingQueueProducerConsumer.java.
- This rewrites the producer-consumer pattern using ArrayBlockingQueue. Copy the following code:
package atomic;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class BlockingQueueProducerConsumer {
// Bounded queue with capacity 10: put() blocks when full, take() blocks when empty
static final BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
public static void main(String[] args) {
// Producer thread: generates items and puts them in the queue
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 20; i++) {
System.out.println("Producing: " + i);
queue.put(i); // Blocks if queue is full
Thread.sleep(100); // Simulate production time
}
queue.put(-1); // Poison pill signals end
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Producer");
// Consumer thread: takes items from the queue and processes them
Thread consumer = new Thread(() -> {
try {
while (true) {
int item = queue.take(); // Blocks if queue is empty
if (item == -1) break; // Poison pill received
System.out.println("Consuming: " + item);
Thread.sleep(150); // Simulate slower consumption
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Consumer");
producer.start();
consumer.start();
}
}
Why is this so much simpler than wait/notify?
The entire synchronization logic is hidden inside the queue. put() automatically blocks the producer when the queue is full. take() automatically blocks the consumer when the queue is empty. No synchronized blocks, no while-loop condition checks, no notify calls.
The "-1" value is a poison pill pattern. It signals the consumer to stop. In production code you might use a more sophisticated shutdown mechanism, but the poison pill is clean and widely used.
- Run BlockingQueueProducerConsumer.java.
- You should see interleaved "Producing" and "Consuming" messages. Notice that the consumer falls behind (150ms vs 100ms) so the queue fills up and the producer blocks until space becomes available.
- Create a new Java class in your atomic package called ConcurrentHashMapDemo.java.
- This demonstrates the atomic compound operations that make ConcurrentHashMap powerful. Copy the following code:
package atomic;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
public class ConcurrentHashMapDemo {
public static void main(String[] args) throws InterruptedException {
// Word frequency counter using merge()
ConcurrentHashMap<String, Integer> wordCounts = new ConcurrentHashMap<>();
String[] words = {"java", "thread", "java", "lock", "thread", "java", "atomic", "lock"};
// Multiple threads count words concurrently
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
final int start = i * 2;
threads[i] = new Thread(() -> {
for (int j = start; j < start + 2 && j < words.length; j++) {
// merge() atomically: if key absent, put value; if present, apply function
wordCounts.merge(words[j], 1, Integer::sum);
}
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("Word counts: " + wordCounts);
// computeIfAbsent: lazily initialize a list per key
ConcurrentHashMap<String, List<String>> groupedItems = new ConcurrentHashMap<>();
// Simulating multiple threads adding items to category lists
Thread t1 = new Thread(() -> {
// Atomically creates the list if it doesn't exist, then adds to it
groupedItems.computeIfAbsent("fruits", k -> new CopyOnWriteArrayList<>()).add("apple");
groupedItems.computeIfAbsent("fruits", k -> new CopyOnWriteArrayList<>()).add("banana");
});
Thread t2 = new Thread(() -> {
groupedItems.computeIfAbsent("fruits", k -> new CopyOnWriteArrayList<>()).add("cherry");
groupedItems.computeIfAbsent("veggies", k -> new CopyOnWriteArrayList<>()).add("carrot");
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Grouped items: " + groupedItems);
}
}
What makes merge() and computeIfAbsent() special?
With a regular HashMap, the pattern "check if key exists, then put or update" requires external synchronization because the check and the update are two separate operations. Another thread can sneak in between them.
ConcurrentHashMap's merge() and computeIfAbsent() perform the entire check-and-act as a single atomic operation. No external lock needed. The map handles the concurrency internally using node-level locking.
- Run ConcurrentHashMapDemo.java.
- You should see word counts (java=3, thread=2, lock=2, atomic=1) and grouped items with fruits containing all three entries.
- Create a new Java class in your atomic package called CopyOnWriteListDemo.java.
- This proves that CopyOnWriteArrayList iterators see a snapshot even when another thread modifies the list. Copy the following code:
package atomic;
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;
public class CopyOnWriteListDemo {
public static void main(String[] args) throws InterruptedException {
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("Alpha");
list.add("Beta");
list.add("Gamma");
// Get an iterator BEFORE any modifications
Iterator<String> snapshotIterator = list.iterator();
// Another thread modifies the list while we hold the iterator
Thread modifier = new Thread(() -> {
list.add("Delta");
list.add("Epsilon");
list.remove("Beta");
System.out.println("Modifier done. Current list: " + list);
});
modifier.start();
modifier.join(); // Wait for modifications to complete
// Iterate using the snapshot iterator obtained BEFORE modifications
System.out.println("\nSnapshot iterator sees:");
while (snapshotIterator.hasNext()) {
System.out.println(" " + snapshotIterator.next());
}
// A new iterator sees the current state
System.out.println("\nNew iterator sees:");
for (String item : list) {
System.out.println(" " + item);
}
System.out.println("\nNo ConcurrentModificationException thrown!");
}
}
What does this demonstrate?
The snapshot iterator still sees [Alpha, Beta, Gamma] even though Beta was removed and Delta/Epsilon were added. This is because CopyOnWriteArrayList creates a new underlying array on every write. Existing iterators hold a reference to the old array.
This guarantees that iteration never throws ConcurrentModificationException, but the trade-off is expensive writes. Use CopyOnWriteArrayList only when reads vastly outnumber writes and the list is small.
- Run CopyOnWriteListDemo.java.
- You should see the snapshot iterator printing Alpha, Beta, Gamma. The new iterator prints the modified list without Beta but with Delta and Epsilon.
Seeing unexpected iterator output?
Make sure you call modifier.join() before iterating. Without the join, the modifier thread might not have finished its modifications yet, making the output from the new iterator unpredictable.
Why is my CopyOnWriteArrayList iterator not showing the expected snapshot?
How do I choose the right concurrent collection?
- Need a thread-safe map with high read/write concurrency? Use ConcurrentHashMap.
- Need a list where reads vastly outnumber writes? Use CopyOnWriteArrayList.
- Need producers and consumers to block on a bounded buffer? Use ArrayBlockingQueue.
- Need a non-blocking concurrent queue (never blocks, just returns null)? Use ConcurrentLinkedQueue.
- Need priority ordering with blocking semantics? Use PriorityBlockingQueue.
Review all six atomic programs
Your atomic package now contains six programs covering the full range of lock-free and concurrent collection techniques.
✔️ Awesome, I've got everything!
Great. Double check that your atomic package contains all six files: AtomicCounterDemo.java, LockFreeStackDemo.java, BlockingQueueProducerConsumer.java, ConcurrentHashMapDemo.java, CopyOnWriteListDemo.java, and LongAdderBenchmark.java.
ⓧ I'd like to double check the full code
Here is each file in your atomic package for comparison.
package atomic;
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounterDemo {
// Synchronized version: threads must acquire a lock to increment
static int syncCounter = 0;
static final Object lock = new Object();
// Atomic version: uses CAS internally, no lock needed
static AtomicInteger atomicCounter = new AtomicInteger(0);
static final int THREADS = 8;
static final int INCREMENTS_PER_THREAD = 1_000_000;
public static void main(String[] args) throws InterruptedException {
// Run synchronized version and measure time
long syncTime = runSynchronized();
System.out.println("Synchronized counter: " + syncCounter);
System.out.println("Synchronized time: " + syncTime + " ms");
System.out.println();
// Run atomic version and measure time
long atomicTime = runAtomic();
System.out.println("Atomic counter: " + atomicCounter.get());
System.out.println("Atomic time: " + atomicTime + " ms");
}
static long runSynchronized() throws InterruptedException {
Thread[] threads = new Thread[THREADS];
long start = System.nanoTime();
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
// Every increment acquires and releases the lock
synchronized (lock) {
syncCounter++;
}
}
});
threads[i].start();
}
for (Thread t : threads) t.join();
long elapsed = (System.nanoTime() - start) / 1_000_000;
return elapsed;
}
static long runAtomic() throws InterruptedException {
Thread[] threads = new Thread[THREADS];
long start = System.nanoTime();
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
// CAS-based increment: no lock, no blocking
atomicCounter.incrementAndGet();
}
});
threads[i].start();
}
for (Thread t : threads) t.join();
long elapsed = (System.nanoTime() - start) / 1_000_000;
return elapsed;
}
}
package atomic;
import java.util.concurrent.atomic.AtomicReference;
public class LockFreeStackDemo {
// Each node holds a value and a pointer to the next node
static class Node<T> {
final T value;
Node<T> next;
Node(T value) {
this.value = value;
}
}
// The top of the stack is an atomic reference to a Node
static class LockFreeStack<T> {
private final AtomicReference<Node<T>> top = new AtomicReference<>(null);
// Push: create new node, point it to current top, CAS to swap top
public void push(T value) {
Node<T> newNode = new Node<>(value);
while (true) {
Node<T> currentTop = top.get();
newNode.next = currentTop;
// If top is still currentTop, atomically replace it with newNode
if (top.compareAndSet(currentTop, newNode)) {
return; // Success
}
// CAS failed: another thread modified top, retry
}
}
// Pop: read current top, CAS to replace it with top.next
public T pop() {
while (true) {
Node<T> currentTop = top.get();
if (currentTop == null) {
return null; // Stack is empty
}
Node<T> newTop = currentTop.next;
// If top is still currentTop, atomically replace it with newTop
if (top.compareAndSet(currentTop, newTop)) {
return currentTop.value; // Success
}
// CAS failed: another thread modified top, retry
}
}
}
public static void main(String[] args) throws InterruptedException {
LockFreeStack<Integer> stack = new LockFreeStack<>();
int THREADS = 4;
int ITEMS_PER_THREAD = 10_000;
// 4 threads push items concurrently
Thread[] pushers = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
final int threadId = i;
pushers[i] = new Thread(() -> {
for (int j = 0; j < ITEMS_PER_THREAD; j++) {
stack.push(threadId * ITEMS_PER_THREAD + j);
}
});
pushers[i].start();
}
for (Thread t : pushers) t.join();
// Pop all items and count them
int count = 0;
while (stack.pop() != null) {
count++;
}
System.out.println("Pushed: " + (THREADS * ITEMS_PER_THREAD));
System.out.println("Popped: " + count);
System.out.println("All items accounted for: " + (count == THREADS * ITEMS_PER_THREAD));
}
}
package atomic;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class BlockingQueueProducerConsumer {
// Bounded queue with capacity 10: put() blocks when full, take() blocks when empty
static final BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
public static void main(String[] args) {
// Producer thread: generates items and puts them in the queue
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 20; i++) {
System.out.println("Producing: " + i);
queue.put(i); // Blocks if queue is full
Thread.sleep(100); // Simulate production time
}
queue.put(-1); // Poison pill signals end
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Producer");
// Consumer thread: takes items from the queue and processes them
Thread consumer = new Thread(() -> {
try {
while (true) {
int item = queue.take(); // Blocks if queue is empty
if (item == -1) break; // Poison pill received
System.out.println("Consuming: " + item);
Thread.sleep(150); // Simulate slower consumption
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Consumer");
producer.start();
consumer.start();
}
}
package atomic;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
public class ConcurrentHashMapDemo {
public static void main(String[] args) throws InterruptedException {
// Word frequency counter using merge()
ConcurrentHashMap<String, Integer> wordCounts = new ConcurrentHashMap<>();
String[] words = {"java", "thread", "java", "lock", "thread", "java", "atomic", "lock"};
// Multiple threads count words concurrently
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
final int start = i * 2;
threads[i] = new Thread(() -> {
for (int j = start; j < start + 2 && j < words.length; j++) {
// merge() atomically: if key absent, put value; if present, apply function
wordCounts.merge(words[j], 1, Integer::sum);
}
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("Word counts: " + wordCounts);
// computeIfAbsent: lazily initialize a list per key
ConcurrentHashMap<String, List<String>> groupedItems = new ConcurrentHashMap<>();
// Simulating multiple threads adding items to category lists
Thread t1 = new Thread(() -> {
// Atomically creates the list if it doesn't exist, then adds to it
groupedItems.computeIfAbsent("fruits", k -> new CopyOnWriteArrayList<>()).add("apple");
groupedItems.computeIfAbsent("fruits", k -> new CopyOnWriteArrayList<>()).add("banana");
});
Thread t2 = new Thread(() -> {
groupedItems.computeIfAbsent("fruits", k -> new CopyOnWriteArrayList<>()).add("cherry");
groupedItems.computeIfAbsent("veggies", k -> new CopyOnWriteArrayList<>()).add("carrot");
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Grouped items: " + groupedItems);
}
}
package atomic;
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;
public class CopyOnWriteListDemo {
public static void main(String[] args) throws InterruptedException {
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("Alpha");
list.add("Beta");
list.add("Gamma");
// Get an iterator BEFORE any modifications
Iterator<String> snapshotIterator = list.iterator();
// Another thread modifies the list while we hold the iterator
Thread modifier = new Thread(() -> {
list.add("Delta");
list.add("Epsilon");
list.remove("Beta");
System.out.println("Modifier done. Current list: " + list);
});
modifier.start();
modifier.join(); // Wait for modifications to complete
// Iterate using the snapshot iterator obtained BEFORE modifications
System.out.println("\nSnapshot iterator sees:");
while (snapshotIterator.hasNext()) {
System.out.println(" " + snapshotIterator.next());
}
// A new iterator sees the current state
System.out.println("\nNew iterator sees:");
for (String item : list) {
System.out.println(" " + item);
}
System.out.println("\nNo ConcurrentModificationException thrown!");
}
}
package atomic;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
public class LongAdderBenchmark {
static final int THREADS = 16;
static final long DURATION_SECONDS = 5;
public static void main(String[] args) throws InterruptedException {
System.out.println("Benchmarking with " + THREADS + " threads for " + DURATION_SECONDS + "s each...");
System.out.println();
// Benchmark AtomicLong
long atomicOps = benchmarkAtomicLong();
System.out.println("AtomicLong total ops: " + atomicOps);
System.out.println("AtomicLong throughput: " + (atomicOps / DURATION_SECONDS) + " ops/sec");
System.out.println();
// Benchmark LongAdder
long adderOps = benchmarkLongAdder();
System.out.println("LongAdder total ops: " + adderOps);
System.out.println("LongAdder throughput: " + (adderOps / DURATION_SECONDS) + " ops/sec");
System.out.println();
System.out.println("LongAdder speedup: " + String.format("%.2f", (double) adderOps / atomicOps) + "x");
}
static long benchmarkAtomicLong() throws InterruptedException {
AtomicLong counter = new AtomicLong(0);
// Flag to signal threads to stop
volatile boolean[] running = {true};
Thread[] threads = new Thread[THREADS];
long[] counts = new long[THREADS];
long startTime = System.nanoTime();
long endTime = startTime + DURATION_SECONDS * 1_000_000_000L;
for (int i = 0; i < THREADS; i++) {
final int idx = i;
threads[i] = new Thread(() -> {
long localCount = 0;
// Each thread increments until time runs out
while (System.nanoTime() < endTime) {
counter.incrementAndGet();
localCount++;
}
counts[idx] = localCount;
});
threads[i].start();
}
for (Thread t : threads) t.join();
long totalOps = 0;
for (long c : counts) totalOps += c;
return totalOps;
}
static long benchmarkLongAdder() throws InterruptedException {
LongAdder counter = new LongAdder();
Thread[] threads = new Thread[THREADS];
long[] counts = new long[THREADS];
long startTime = System.nanoTime();
long endTime = startTime + DURATION_SECONDS * 1_000_000_000L;
for (int i = 0; i < THREADS; i++) {
final int idx = i;
threads[i] = new Thread(() -> {
long localCount = 0;
// Each thread increments its own internal cell
while (System.nanoTime() < endTime) {
counter.increment();
localCount++;
}
counts[idx] = localCount;
});
threads[i].start();
}
for (Thread t : threads) t.join();
long totalOps = 0;
for (long c : counts) totalOps += c;
return totalOps;
}
}
You now have a complete set of programs covering lock-free programming and concurrent collections. Next up, you will study synchronizers like CountDownLatch and CyclicBarrier that coordinate groups of threads at specific points in their execution.
Apply Synchronizers
Your project now covers locks, executors, and atomic variables. But there is a category of coordination tools in java.util.concurrent that solves a different class of problems: what happens when multiple threads need to meet at a rendezvous point before any can proceed?
Synchronizers are purpose-built coordination tools. Each one encapsulates a specific multi-thread coordination pattern so you do not have to reimplement it with raw locks and conditions. In this step, you will study four synchronizers and write programs that make their behavior visible.
In this step, get ready to:
- Study the theory behind CountDownLatch, CyclicBarrier, Semaphore, Phaser, and Exchanger.
- Write four demo programs that exercise each synchronizer's core behavior.
- Learn a decision guide for choosing the right synchronizer for a given problem.
Create the synchronizers package and build CountDownLatchRaceDemo
A CountDownLatch is a one-time barrier. One or more threads call await() and block until a count reaches zero. Other threads call countDown() to decrement. Once the count hits zero, all waiting threads are released simultaneously and the latch cannot be reset.
What is a CountDownLatch?
Think of it as a gate that opens once a certain number of events have occurred. The classic use case is a main thread waiting for N worker threads to finish initialization before proceeding.
Key properties: the count is set once at construction and can never be reset. If you need a reusable barrier, use CyclicBarrier instead. Calling countDown() when the count is already zero has no effect.
- In IntelliJ, right-click your src folder and select New > Package.
- Name the package synchronizers and press Enter.
- Right-click the synchronizers package, select New > Java Class, and name it CountDownLatchRaceDemo.
- Replace the generated contents with the following code:
package synchronizers;
import java.util.concurrent.CountDownLatch;
public class CountDownLatchRaceDemo {
public static void main(String[] args) throws InterruptedException {
int numRunners = 5;
// Latch initialized to 1 - acts as a starting gate
CountDownLatch startingGate = new CountDownLatch(1);
// Latch to wait for all runners to finish
CountDownLatch finishLine = new CountDownLatch(numRunners);
for (int i = 1; i <= numRunners; i++) {
final int runnerId = i;
new Thread(() -> {
try {
System.out.println("Runner " + runnerId + " is ready at the starting line.");
// All runners wait here until the gate opens
startingGate.await();
System.out.println("Runner " + runnerId + " is running!");
// Simulate varying race times
Thread.sleep((long) (Math.random() * 2000));
System.out.println("Runner " + runnerId + " finished!");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
finishLine.countDown();
}
}, "Runner-" + i).start();
}
// Give runners time to reach the starting line
Thread.sleep(500);
System.out.println("\nSTARTER: Ready... Set... GO!");
// Release all runners simultaneously
startingGate.countDown();
// Wait for all runners to finish
finishLine.await();
System.out.println("\nAll runners have finished the race!");
}
}
What does this code do?
This demo uses two latches. The startingGate latch is initialized to 1. All 5 runner threads call startingGate.await() and block until the main thread calls countDown(). This guarantees all runners start at the exact same moment.
The finishLine latch is initialized to 5 (number of runners). Each runner calls finishLine.countDown() when done. The main thread calls finishLine.await() and blocks until all 5 runners have finished.
- Save CountDownLatchRaceDemo.java.
- Right-click the file and select Run 'CountDownLatchRaceDemo.main()'. You should see all 5 runners announce they are ready, then the "GO!" message, then all runners start and finish in a random order.
Runners start before the GO message?
Check that your runner threads call startingGate.await() before printing "is running". If the latch count is initialized to 0 instead of 1, the gate is already open.
Help me debug my CountDownLatch race demo
Build CyclicBarrierPhaseDemo and SemaphoreConnectionPoolDemo
A CyclicBarrier is a reusable barrier. All participating threads call await() and block until every party has arrived. Once all arrive, an optional barrier action runs, then all threads are released simultaneously. The barrier then resets automatically for the next cycle.
How does CyclicBarrier differ from CountDownLatch?
CountDownLatch is one-shot. Once its count reaches zero, it stays open forever. CyclicBarrier automatically resets after each release. This makes it perfect for iterative algorithms where all threads must finish one phase before any thread starts the next.
The optional barrier action (a Runnable passed to the constructor) executes after the last thread arrives but before any thread is released. This is useful for aggregating per-phase results.
- Right-click the synchronizers package, select New > Java Class, and name it CyclicBarrierPhaseDemo.
- Replace the generated contents with the following code:
package synchronizers;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierPhaseDemo {
public static void main(String[] args) {
int numWorkers = 3;
int numPhases = 3;
// Barrier action runs when all parties arrive
CyclicBarrier barrier = new CyclicBarrier(numWorkers, () -> {
System.out.println("--- All workers completed the phase. Moving to next phase. ---\n");
});
for (int i = 1; i <= numWorkers; i++) {
final int workerId = i;
new Thread(() -> {
try {
for (int phase = 1; phase <= numPhases; phase++) {
// Simulate work for this phase
long workTime = (long) (Math.random() * 1000);
Thread.sleep(workTime);
System.out.println("Worker " + workerId + " completed phase " + phase
+ " (took " + workTime + "ms)");
// Wait for all workers to finish this phase
barrier.await();
}
} catch (InterruptedException | BrokenBarrierException e) {
Thread.currentThread().interrupt();
}
}, "Worker-" + i).start();
}
}
}
What does this code do?
Three workers execute 3 phases of work. After each phase, a worker calls barrier.await(). It blocks until all 3 workers have called await() for that phase. Then the barrier action prints a phase-completion message, all workers are released, and the barrier automatically resets for the next phase.
Notice the BrokenBarrierException. If one thread is interrupted while waiting at the barrier, the barrier becomes "broken" and all other waiting threads receive this exception. This prevents threads from hanging forever if one party fails.
- Save CyclicBarrierPhaseDemo.java and run it.
- You should see workers completing phases at different speeds, but the "Moving to next phase" message only appears after all three workers finish each phase. No worker starts phase 2 before all have finished phase 1.
Next, a Semaphore controls access to a limited number of resources. Calling acquire() decrements the permit count and blocks if zero permits are available. Calling release() increments it.
What is a Semaphore?
A semaphore with N permits allows at most N threads to access a resource simultaneously. Use cases include throttling, connection pool limits, and rate limiting. Think of it as a bouncer allowing only N people into a club at once.
Semaphores come in fair and unfair variants. A fair semaphore (constructed with new Semaphore(permits, true)) guarantees FIFO ordering of acquisition. An unfair semaphore (the default) allows barging, which provides higher throughput at the cost of potential starvation.
- Create a new Java class in the synchronizers package called SemaphoreConnectionPoolDemo.
- Replace the generated contents with the following code:
package synchronizers;
import java.util.concurrent.Semaphore;
public class SemaphoreConnectionPoolDemo {
// Only 3 connections available in the pool
private static final Semaphore connectionPool = new Semaphore(3, true);
public static void main(String[] args) {
// 10 threads compete for 3 connections
for (int i = 1; i <= 10; i++) {
final int clientId = i;
new Thread(() -> {
try {
System.out.println("Client " + clientId + " requesting connection...");
// Blocks if no permits available
connectionPool.acquire();
System.out.println("Client " + clientId + " ACQUIRED connection. "
+ "Available: " + connectionPool.availablePermits());
// Simulate using the connection
Thread.sleep((long) (Math.random() * 2000));
System.out.println("Client " + clientId + " releasing connection.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
connectionPool.release();
}
}, "Client-" + i).start();
}
}
}
What does this code do?
The semaphore is initialized with 3 permits and fairness set to true. Ten client threads each try to acquire a permit. At most 3 clients hold a connection at any time. When a client finishes and calls release(), the next client in the FIFO queue is unblocked.
The release() call is in a finally block to guarantee the permit is returned even if an exception occurs during the simulated work.
- Save SemaphoreConnectionPoolDemo.java and run it.
- You should see at most 3 "ACQUIRED" messages before any "releasing" messages. The "Available" count should never exceed 3 or drop below 0.
All 10 clients acquiring at once?
Verify that your semaphore is initialized with 3 permits, not 10. Also ensure that acquire() is called before the "ACQUIRED" print statement.
Help me debug my Semaphore connection pool demo
Build ExchangerDemo and study the synchronizer decision guide
An Exchanger<V> is a synchronization point where exactly two threads swap objects with each other. Each thread calls exchange(object) and blocks until the other thread arrives. Both then receive the other's object.
When would I use an Exchanger?
Exchangers are useful in pipeline designs. One thread fills a buffer while the other drains it. When both are ready, they swap. The filler gets an empty buffer and the drainer gets a full one. Another use case is genetic algorithm crossover where two parent threads swap chromosome data.
- Create a new Java class in the synchronizers package called ExchangerDemo.
- Replace the generated contents with the following code:
package synchronizers;
import java.util.concurrent.Exchanger;
public class ExchangerDemo {
public static void main(String[] args) {
Exchanger<String> exchanger = new Exchanger<>();
// Thread A produces data and swaps with Thread B
new Thread(() -> {
try {
String myData = "Data from Thread A";
System.out.println("Thread A has: " + myData);
// Blocks until the other thread arrives
String received = exchanger.exchange(myData);
System.out.println("Thread A received: " + received);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Thread-A").start();
// Thread B produces data and swaps with Thread A
new Thread(() -> {
try {
String myData = "Data from Thread B";
System.out.println("Thread B has: " + myData);
// Simulate some preparation time
Thread.sleep(500);
// Blocks until the other thread arrives
String received = exchanger.exchange(myData);
System.out.println("Thread B received: " + received);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Thread-B").start();
}
}
What does this code do?
Thread A creates its data and immediately calls exchange(). It blocks because Thread B has not arrived yet (Thread B sleeps for 500ms first). Once Thread B calls exchange(), both threads simultaneously hand their objects to each other. Thread A receives "Data from Thread B" and vice versa.
- Save ExchangerDemo.java and run it.
- You should see Thread A announce its data, then after a brief pause (Thread B's 500ms sleep), both threads print what they received from the other.
What about Phaser?
Phaser is the most flexible synchronizer. It supports dynamic registration and deregistration of parties. Each party registers, arrives at a phase, and waits for others. Parties can deregister between phases.
Phaser is more powerful than CyclicBarrier but also more complex. Use CyclicBarrier when you have a fixed number of threads doing identical work in phases. Use Phaser when the number of participants can change between phases or when you need fine-grained phase control.
Which synchronizer should I use?
Use this decision guide:
- Need threads to wait for a one-time event? CountDownLatch.
- Need threads to repeatedly synchronize at a barrier? CyclicBarrier.
- Need to limit concurrent access to N resources? Semaphore.
- Need dynamic party registration with phased synchronization? Phaser.
- Need two threads to swap data? Exchanger.
✔️ Awesome, I've got everything!
Great. Double check that all four files are saved in your synchronizers package.
ⓧ I'd like to double check the full code
Here is the complete code for each file in the synchronizers package.
package synchronizers;
import java.util.concurrent.CountDownLatch;
public class CountDownLatchRaceDemo {
public static void main(String[] args) throws InterruptedException {
int numRunners = 5;
// Latch initialized to 1 - acts as a starting gate
CountDownLatch startingGate = new CountDownLatch(1);
// Latch to wait for all runners to finish
CountDownLatch finishLine = new CountDownLatch(numRunners);
for (int i = 1; i <= numRunners; i++) {
final int runnerId = i;
new Thread(() -> {
try {
System.out.println("Runner " + runnerId + " is ready at the starting line.");
// All runners wait here until the gate opens
startingGate.await();
System.out.println("Runner " + runnerId + " is running!");
// Simulate varying race times
Thread.sleep((long) (Math.random() * 2000));
System.out.println("Runner " + runnerId + " finished!");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
finishLine.countDown();
}
}, "Runner-" + i).start();
}
// Give runners time to reach the starting line
Thread.sleep(500);
System.out.println("\nSTARTER: Ready... Set... GO!");
// Release all runners simultaneously
startingGate.countDown();
// Wait for all runners to finish
finishLine.await();
System.out.println("\nAll runners have finished the race!");
}
}
package synchronizers;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierPhaseDemo {
public static void main(String[] args) {
int numWorkers = 3;
int numPhases = 3;
// Barrier action runs when all parties arrive
CyclicBarrier barrier = new CyclicBarrier(numWorkers, () -> {
System.out.println("--- All workers completed the phase. Moving to next phase. ---\n");
});
for (int i = 1; i <= numWorkers; i++) {
final int workerId = i;
new Thread(() -> {
try {
for (int phase = 1; phase <= numPhases; phase++) {
// Simulate work for this phase
long workTime = (long) (Math.random() * 1000);
Thread.sleep(workTime);
System.out.println("Worker " + workerId + " completed phase " + phase
+ " (took " + workTime + "ms)");
// Wait for all workers to finish this phase
barrier.await();
}
} catch (InterruptedException | BrokenBarrierException e) {
Thread.currentThread().interrupt();
}
}, "Worker-" + i).start();
}
}
}
package synchronizers;
import java.util.concurrent.Semaphore;
public class SemaphoreConnectionPoolDemo {
// Only 3 connections available in the pool
private static final Semaphore connectionPool = new Semaphore(3, true);
public static void main(String[] args) {
// 10 threads compete for 3 connections
for (int i = 1; i <= 10; i++) {
final int clientId = i;
new Thread(() -> {
try {
System.out.println("Client " + clientId + " requesting connection...");
// Blocks if no permits available
connectionPool.acquire();
System.out.println("Client " + clientId + " ACQUIRED connection. "
+ "Available: " + connectionPool.availablePermits());
// Simulate using the connection
Thread.sleep((long) (Math.random() * 2000));
System.out.println("Client " + clientId + " releasing connection.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
connectionPool.release();
}
}, "Client-" + i).start();
}
}
}
package synchronizers;
import java.util.concurrent.Exchanger;
public class ExchangerDemo {
public static void main(String[] args) {
Exchanger<String> exchanger = new Exchanger<>();
// Thread A produces data and swaps with Thread B
new Thread(() -> {
try {
String myData = "Data from Thread A";
System.out.println("Thread A has: " + myData);
// Blocks until the other thread arrives
String received = exchanger.exchange(myData);
System.out.println("Thread A received: " + received);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Thread-A").start();
// Thread B produces data and swaps with Thread A
new Thread(() -> {
try {
String myData = "Data from Thread B";
System.out.println("Thread B has: " + myData);
// Simulate some preparation time
Thread.sleep(500);
// Blocks until the other thread arrives
String received = exchanger.exchange(myData);
System.out.println("Thread B received: " + received);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Thread-B").start();
}
}
Your synchronizers package is complete. You can now coordinate threads at rendezvous points, limit concurrent resource access, and swap data between thread pairs. Next up, you will face the final exam that tests everything you have learned across all 11 steps.
Take the Final Exam
You have built an entire concurrency lab from the ground up. You understand processes, threads, monitors, the Java Memory Model, locks, executors, atomics, and synchronizers.
Now it is time to prove that understanding. This final exam tests deep comprehension, not surface recall. You will explain core theory in your own words, diagnose concurrency bugs in code, and design a thread-safe component from scratch.
In this step, get ready to:
- Answer 10 detailed theory questions spanning all concurrency topics.
- Identify and fix concurrency bugs in 5 code snippets.
- Design and implement a thread-safe rate limiter from scratch.
Part A: answer the theory questions
Each question requires a detailed written answer. Aim for 3-5 sentences minimum per question. Write your answers in a new file or in a text editor of your choice.
- Create a new file called FinalExamAnswers.md in your project root to write your answers.
Here are the 10 questions. Do not look at the answers until you have written your own response for every question.
- Question 1: Explain what "happens-before" means in the Java Memory Model. List all the happens-before rules defined in the JMM specification.
- Question 2: Explain why double-checked locking for a singleton is broken without the volatile keyword. What specific problem can occur, and how does volatile fix it?
- Question 3: Describe the four Coffman conditions for deadlock. For each condition, explain one strategy to prevent it.
- Question 4: Explain when you would choose ReentrantReadWriteLock over synchronized. When would the overhead of a read-write lock NOT be worth it?
- Question 5: Explain the difference between the BLOCKED and WAITING thread states. What causes a thread to enter each state, and what causes it to leave?
- Question 6: State Amdahl's Law (the formula) and explain what it implies for multithreaded program design. If 10% of a program is sequential, what is the maximum theoretical speedup with 8 cores?
- Question 7: Explain the difference between concurrency and parallelism. Give a concrete example of a program that is concurrent but not parallel, and one that is both concurrent and parallel.
- Question 8: Explain why CAS-based algorithms can outperform lock-based algorithms under low contention. Under what conditions do CAS-based algorithms perform poorly?
- Question 9: Explain why wait() must be called inside both a synchronized block and a while loop. What goes wrong if either requirement is violated?
- Question 10: Describe the task submission flow inside ThreadPoolExecutor. Explain the roles of core pool size, the work queue, and maximum pool size. When is a task rejected?
Ready to check your understanding against model answers?
✔️ I'll try it first
Take your time. Write your answers in full sentences, as if explaining to another developer. Come back when you have answered all 10.
ⓧ Let me check my work
Here are model answers for each question. Compare yours against these for completeness and accuracy.
Question 1: Happens-before
The happens-before relationship is the JMM's guarantee that if action A happens-before action B, then the memory effects of A are visible to B. It guarantees visibility, not necessarily temporal ordering.
The rules are:
- Program order rule: Each action in a thread happens-before every subsequent action in that thread.
- Monitor lock rule: An unlock on a monitor happens-before every subsequent lock on that same monitor.
- Volatile variable rule: A write to a volatile field happens-before every subsequent read of that field.
- Thread start rule: A call to Thread.start() happens-before any action in the started thread.
- Thread join rule: All actions in a thread happen-before the return of Thread.join() on that thread.
- Transitivity: If A happens-before B and B happens-before C, then A happens-before C.
Question 2: Double-checked locking
Without volatile, the JVM can reorder instructions so that the reference to the singleton is published before the constructor finishes executing. Another thread can see a non-null reference to an incompletely constructed object.
The volatile keyword acts as a memory fence that prevents this reordering. It ensures the write to the reference is visible only after the constructor completes. The volatile write happens-before any subsequent volatile read, guaranteeing the reading thread sees the fully constructed object.
Question 3: Coffman conditions
- Mutual exclusion: At least one resource is non-sharable. Prevention: use lock-free algorithms or make resources sharable (e.g., read-only data).
- Hold and wait: A thread holds one resource while waiting for another. Prevention: acquire all locks atomically or release held locks before requesting new ones.
- No preemption: Resources cannot be forcibly taken. Prevention: use tryLock with timeouts so threads back off voluntarily.
- Circular wait: A cycle of threads each waiting for a resource held by the next. Prevention: impose a global lock ordering so cycles are impossible.
Question 4: ReentrantReadWriteLock vs synchronized
Choose ReentrantReadWriteLock when the workload is read-heavy with infrequent writes. Multiple reader threads can hold the read lock simultaneously, giving higher throughput than a single exclusive lock.
It is NOT worth it when writes are frequent. The overhead of managing read-write lock state (tracking reader counts, upgrading/downgrading) exceeds the benefit when writers are constantly blocking readers. A simple synchronized block or ReentrantLock is simpler and faster in write-heavy scenarios.
Question 5: BLOCKED vs WAITING
BLOCKED: a thread is trying to enter a synchronized block but another thread holds the monitor lock. The thread leaves BLOCKED when the lock becomes available.
WAITING: a thread has voluntarily parked itself by calling Object.wait(), Thread.join(), or LockSupport.park() with no timeout. It leaves WAITING when another thread calls notify()/notifyAll(), the joined thread terminates, or it is interrupted.
Question 6: Amdahl's Law
Amdahl's Law: Speedup = 1 / (S + (1-S)/N), where S is the sequential fraction and N is the number of cores. It implies that the sequential portion of a program places a hard ceiling on speedup, no matter how many cores you add.
With S=0.10 and N=8: Speedup = 1 / (0.10 + 0.90/8) = 1 / (0.10 + 0.1125) = 1 / 0.2125 = approximately 4.7x. Even with infinite cores, the maximum speedup would be 1/0.10 = 10x. The practical takeaway: reducing the synchronized (sequential) portion of your code matters more than adding cores.
Question 7: Concurrency vs parallelism
Concurrency means managing multiple tasks that can make progress within overlapping time periods. Parallelism means literally executing multiple tasks at the same instant on different CPU cores. Parallelism is a subset of concurrency.
Concurrent but not parallel: a web server on a single-core machine handling 100 requests by time-slicing between them. The requests interleave but never truly execute simultaneously. Both concurrent and parallel: the same web server on a 4-core machine, with 4 requests processing on 4 cores at the same instant.
Question 8: CAS vs locks
Under low contention, CAS avoids the overhead of context switching. A lock-based approach requires the OS to suspend and resume threads (microseconds each). CAS uses a single atomic CPU instruction that succeeds on the first try when no other thread is competing, making it significantly faster.
CAS performs poorly under high contention because many threads will fail their CAS operations and must retry (spin). This wastes CPU cycles on failed attempts. Lock-based approaches park contending threads, freeing the CPU for other work. CAS also suffers from the ABA problem in more complex data structures.
Question 9: wait() requirements
The synchronized block is required because wait() releases the monitor lock. If you do not hold the lock, there is nothing to release, and the JVM throws IllegalMonitorStateException. The lock is needed to atomically check the condition and enter the wait set.
The while loop is required because of spurious wakeups. The JVM spec allows a thread to wake from wait() without being notified. Without the loop, the thread proceeds even though the condition it was waiting for is still false. The loop re-checks the condition on every wakeup.
Question 10: ThreadPoolExecutor flow
When a task is submitted: if fewer than corePoolSize threads are running, a new thread is created to handle the task. If the core pool is full, the task is placed in the work queue. If the queue is full and fewer than maximumPoolSize threads exist, a new thread is created. If maximumPoolSize is reached AND the queue is full, the task is rejected.
The four rejection policies are AbortPolicy (throws RejectedExecutionException), CallerRunsPolicy (submitting thread runs the task), DiscardPolicy (silently drops the task), and DiscardOldestPolicy (drops the oldest queued task and retries).
Part B: find the concurrency bugs
Each of the following 5 code snippets contains a concurrency bug. For each one, identify the bug, explain what can go wrong at runtime, and provide a corrected version.
Write your answers below each snippet in your FinalExamAnswers.md file.
Bug 1: Race condition
public class BuggyCounter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
BuggyCounter counter = new BuggyCounter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) counter.increment();
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) counter.increment();
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Count: " + counter.getCount());
}
}
Bug 2: Visibility violation
public class BuggyFlag {
private boolean running = true;
public void stop() {
running = false;
}
public void run() {
System.out.println("Starting...");
while (running) {
// busy work
}
System.out.println("Stopped.");
}
public static void main(String[] args) throws InterruptedException {
BuggyFlag flag = new BuggyFlag();
Thread worker = new Thread(flag::run);
worker.start();
Thread.sleep(1000);
flag.stop();
worker.join();
System.out.println("Done.");
}
}
Bug 3: Deadlock potential
public class BuggyTransfer {
private final Object lockA = new Object();
private final Object lockB = new Object();
private int balanceA = 1000;
private int balanceB = 1000;
public void transferAtoB(int amount) {
synchronized (lockA) {
synchronized (lockB) {
balanceA -= amount;
balanceB += amount;
}
}
}
public void transferBtoA(int amount) {
synchronized (lockB) {
synchronized (lockA) {
balanceB -= amount;
balanceA += amount;
}
}
}
}
Bug 4: Incorrect wait/notify
public class BuggyQueue {
private final java.util.LinkedList<Integer> queue = new java.util.LinkedList<>();
private final int capacity = 5;
public synchronized void produce(int item) throws InterruptedException {
if (queue.size() == capacity) {
wait();
}
queue.add(item);
notify();
}
public synchronized int consume() throws InterruptedException {
if (queue.isEmpty()) {
wait();
}
int item = queue.removeFirst();
notify();
return item;
}
}
Bug 5: Non-atomic operations on volatile
public class BuggyVolatileCounter {
private volatile int count = 0;
public void increment() {
count++;
}
public static void main(String[] args) throws InterruptedException {
BuggyVolatileCounter counter = new BuggyVolatileCounter();
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10_000; j++) counter.increment();
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("Expected: 100000, Got: " + counter.count);
}
}
Once you have identified all 5 bugs and written your fixes, check them below.
✔️ I'll try it first
For each snippet, identify: what is the bug, what can go wrong at runtime, and how would you fix it. Write your answers before checking.
ⓧ Let me check my work
Bug 1: Race condition
Bug: The count++ operation is not atomic. It is a read-modify-write consisting of three steps: read count, add 1, write result. Two threads can interleave these steps and lose updates.
Runtime symptom: Final count is less than 200,000. The exact value varies between runs.
Fix: Make increment() synchronized, or replace int count with AtomicInteger and call incrementAndGet().
Bug 2: Visibility violation
Bug: The running field is not volatile. The worker thread may cache the value of running in a CPU register or L1 cache and never see the main thread's write.
Runtime symptom: The worker thread loops forever. The program never prints "Stopped." or "Done." and hangs indefinitely.
Fix: Declare private volatile boolean running = true. This forces all reads to go to main memory.
Bug 3: Deadlock potential
Bug: Lock ordering is inconsistent. transferAtoB acquires lockA then lockB. transferBtoA acquires lockB then lockA. If both methods run concurrently, each thread holds one lock and waits for the other. This is a circular wait.
Runtime symptom: The program freezes permanently. Both threads are in BLOCKED state and neither can proceed.
Fix: Always acquire locks in the same order. Change transferBtoA to also acquire lockA first, then lockB. This eliminates the circular wait condition.
Bug 4: Incorrect wait/notify
Bug: Two problems. First, if is used instead of while for the wait condition. Spurious wakeups or notifications from a different condition can cause the thread to proceed when the condition is still not met. Second, notify() wakes only one thread. With multiple producers and consumers, the wrong thread may be woken (e.g., a producer notifies another producer).
Fix: Replace both if checks with while loops. Replace notify() with notifyAll() to ensure the correct waiting thread is woken.
Bug 5: Non-atomic operations on volatile
Bug: volatile guarantees visibility but NOT atomicity. The count++ operation is still three separate steps (read, increment, write). Two threads can read the same value, both increment it to the same result, and one update is lost.
Runtime symptom: The final count is less than 100,000. The volatile keyword misleads the developer into thinking the operation is safe.
Fix: Replace volatile int with AtomicInteger and use incrementAndGet(). The atomic class uses CAS internally to guarantee atomicity without locks.
Part C: design a thread-safe rate limiter
This final challenge tests your ability to design a concurrent component from scratch. You must make design decisions, choose synchronization primitives, and justify every choice.
The design challenge
Design and implement a thread-safe rate limiter that allows at most N operations per second. Multiple threads will call an acquire() method concurrently. The method should block if the rate limit has been reached, and return once a permit becomes available.
Requirements:
- Support configurable rate (N operations per second).
- Thread-safe under high contention (20+ concurrent callers).
- Block callers when the limit is exceeded (do not reject or throw).
- Fair: first-come, first-served ordering when threads are waiting.
- Include a main() method that demonstrates 20 threads using the rate limiter at a rate of 5 per second.
- Create a new file called RateLimiter.java in your project. You can place it in any package.
- Write your full implementation, then write a brief paragraph justifying your design decisions (which lock type, why fairness, how you track the time window).
Give this your best attempt before checking the model solution.
✔️ I'll try it first
Think about which synchronization primitive fits best. Consider how you will track how many operations have occurred in the current second. Think about what happens when the window rolls over.
Hints if you get stuck
- A sliding window approach uses timestamps of recent operations.
- A fixed window approach resets a counter every second.
- ReentrantLock with a Condition allows fair, blocking waits.
- Semaphore is another valid approach (think about how to refill permits).
ⓧ Let me check my work
Here is one valid implementation using a sliding window with ReentrantLock and a Condition. Your solution may differ and still be correct.
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class RateLimiter {
// Maximum operations allowed per second
private final int maxPermitsPerSecond;
// Timestamps of recent operations (sliding window)
private final Queue<Long> timestamps;
// Fair lock ensures first-come, first-served ordering
private final ReentrantLock lock;
// Condition for threads to wait on when limit is reached
private final Condition permitAvailable;
public RateLimiter(int maxPermitsPerSecond) {
this.maxPermitsPerSecond = maxPermitsPerSecond;
this.timestamps = new LinkedList<>();
// fair = true ensures FIFO ordering of waiting threads
this.lock = new ReentrantLock(true);
this.permitAvailable = lock.newCondition();
}
public void acquire() throws InterruptedException {
lock.lockInterruptibly();
try {
while (true) {
long now = System.nanoTime();
long windowStart = now - 1_000_000_000L; // 1 second window
// Remove timestamps older than 1 second
while (!timestamps.isEmpty() && timestamps.peek() <= windowStart) {
timestamps.poll();
}
// Check if we have room for another operation
if (timestamps.size() < maxPermitsPerSecond) {
timestamps.add(now);
// Signal other waiting threads in case multiple permits freed up
permitAvailable.signal();
return;
}
// Calculate how long until the oldest permit expires
long oldestTimestamp = timestamps.peek();
long waitNanos = oldestTimestamp + 1_000_000_000L - now;
if (waitNanos > 0) {
// Wait until a permit should become available
permitAvailable.awaitNanos(waitNanos);
}
}
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
RateLimiter limiter = new RateLimiter(5); // 5 ops per second
// Launch 20 threads all trying to acquire
for (int i = 0; i < 20; i++) {
final int threadId = i;
new Thread(() -> {
try {
limiter.acquire();
long time = System.currentTimeMillis() % 10000;
System.out.printf("Thread %2d acquired at t=%dms%n", threadId, time);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
}
}
Design justification
- ReentrantLock with fair=true: Guarantees FIFO ordering of waiting threads, satisfying the fairness requirement. An unfair lock could starve some callers under contention.
- Sliding window approach: Tracks actual timestamps rather than resetting a counter every second. This prevents bursts at window boundaries (where a fixed window would allow 2N operations in two seconds: N at the end of one window and N at the start of the next).
- Condition with awaitNanos: Threads sleep for exactly as long as needed for a permit to expire, rather than polling. This is CPU-efficient.
- lockInterruptibly: Allows threads to be interrupted while waiting for the lock, supporting cooperative cancellation.
How to grade yourself
Part A: Each answer should demonstrate understanding of the mechanism, not just recall the API. If you can explain WHY a rule exists (not just what it says), you understand it.
Part B: Full marks for identifying the exact failure mode, not just naming the category. "It is a race condition" is not enough. "Two threads can read the same count value, both increment to the same result, and one write is lost" is full understanding.
Part C: A correct solution uses appropriate synchronization, blocks without spinning, handles fairness, and has no race conditions or deadlock potential. Bonus points for handling interruption and explaining trade-offs.
You have proven your understanding of Java concurrency from theory to implementation. If you want an additional challenge, the Secret Mission pushes you to implement the same problem three different ways and benchmark the results.
Secret mission
Triple-Implementation Bounded Buffer Benchmark
You have learned three different synchronization approaches throughout this project. Now put them head-to-head. Build the same bounded buffer three ways, benchmark them under real contention, and discover which approach wins and why.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine, so there are no ongoing costs. Decide whether to keep your project files for future reference, or delete them to free up disk space.
Resources you used:
- IntelliJ IDEA project java-threading-masterclass on your Desktop.
- Java source files across 9 packages (multithreading, fundamentals, synchronization, communication, problems, memorymodel, locks, executors, atomic).
- JDK 21 installation (shared system resource, used by other Java projects).
Keep everything running
No action needed. Choose this if you plan to revisit the code examples, study for interviews, or extend your threading experiments.
- Your project stays on your Desktop in the java-threading-masterclass folder.
- You can reopen it anytime in IntelliJ IDEA.
Pause - I'll come back to this later
Since this is a local project with no running processes, there is nothing to pause. Your files sit on disk and consume no CPU or memory when IntelliJ is closed.
- Close IntelliJ IDEA if it is still open.
- Your project files remain on your Desktop until you decide to delete them.
Delete - I don't want to use this again
Remove all project files from your machine to free up disk space.
- Close IntelliJ IDEA if it is still open.
- Delete the java-threading-masterclass folder from your Desktop.
- Optionally remove the project from IntelliJ's recent projects list by right-clicking it on the Welcome screen and selecting Remove from Recent Projects.
- Keep JDK 21 installed. It is a shared system resource that other Java projects depend on.
That's a Wrap!
That's a Wrap!
Congratulations! You have completed a comprehensive deep-dive into Java threading and concurrency.
You now understand not just how to use threads, but why they behave the way they do, what the JVM guarantees, and what it does not.
You learned how to:
- Write 25+ runnable Java programs demonstrating every core threading concept, from thread lifecycle and synchronization to lock-free CAS operations and the Java Memory Model.
- Master the Executor framework, thread pools, and asynchronous composition with CompletableFuture.
- Identify and prevent deadlocks, race conditions, and visibility bugs by understanding the happens-before guarantees and thread safety at a level deeper than most developers ever reach.
- Secret Mission: Built a triple-implementation bounded buffer using synchronized, ReentrantLock, and BlockingQueue, then benchmarked them against each other to measure real-world throughput and latency.
Ready to quiz yourself?