Concurrency
Master multi-threading with hands-on exercises from race conditions to CompletableFuture
What You Will Learn
Concurrency means doing multiple things at the same time. Your computer has multiple CPU cores that can run code simultaneously. In this practical work, you'll learn:
- Threads - Units of execution that run concurrently
- Race Conditions - Bugs that happen when threads interfere
- Synchronization - How to prevent race conditions
- Thread Pools - Efficiently managing many threads
- CompletableFuture - Modern async programming in Java
Understanding Concurrency
Think of a restaurant kitchen:
- Single-threaded: One chef does everything - takes order, cooks, serves, cleans. Slow!
- Multi-threaded: Multiple chefs work simultaneously. Much faster, but they need to coordinate so they don't bump into each other!
Why use concurrency?
- Performance: Use all CPU cores (a 4-core CPU can do 4 things at once)
- Responsiveness: UI stays responsive while processing in background
- Throughput: Handle many requests simultaneously (web servers)
Prerequisites
- Java 17+ installed
- Basic Java knowledge (classes, methods)
- Maven installed
Part 1: Project Setup
Step 1.1: Create Project
Create folder concurrency-lab with this structure:
concurrency-lab/
├── pom.xml
└── src/
└── main/
└── java/
└── com/
└── example/
└── concurrency/
Step 1.2: Create pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>concurrency-lab</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
</plugin>
</plugins>
</build>
</project>
Part 2: Your First Thread
Step 2.1: Create a Simple Thread
Create src/main/java/com/example/concurrency/BasicThreadDemo.java:
package com.example.concurrency;
public class BasicThreadDemo {
public static void main(String[] args) {
System.out.println("Main thread: " + Thread.currentThread().getName());
// (#1:Create a thread by passing a Runnable)
Thread thread1 = new Thread(() -> {
for (int i = 1; i <= 5; i++) {
System.out.println("Thread 1 counting: " + i);
sleep(500); // Wait 500ms
}
});
Thread thread2 = new Thread(() -> {
for (int i = 1; i <= 5; i++) {
System.out.println("Thread 2 counting: " + i);
sleep(500);
}
});
// (#2:Start both threads - they run concurrently!)
thread1.start();
thread2.start();
System.out.println("Main thread continues immediately!");
// (#3:Wait for both threads to finish)
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Both threads finished!");
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Step 2.2: Run and Observe
cd concurrency-lab
mvn compile exec:java -Dexec.mainClass="com.example.concurrency.BasicThreadDemo"
Notice how the output is interleaved - both threads run at the same time!
Main thread: main
Main thread continues immediately!
Thread 1 counting: 1
Thread 2 counting: 1
Thread 1 counting: 2
Thread 2 counting: 2
...
.start()doesn't wait - main thread continues immediately.join()waits for a thread to complete- Output order is unpredictable - threads run in parallel
Part 3: Race Conditions (The Bug)
When two threads access the same data at the same time, bad things happen. Imagine two people trying to edit the same document simultaneously - changes get lost!
Step 3.1: Create a Counter with a Bug
Create src/main/java/com/example/concurrency/RaceConditionDemo.java:
package com.example.concurrency;
public class RaceConditionDemo {
// (#1:Shared counter - multiple threads will access this)
private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
// (#2:Create 1000 threads, each incrementing counter 1000 times)
Thread[] threads = new Thread[1000];
for (int i = 0; i < 1000; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
counter++; // (#3:This line has a BUG!)
}
});
}
// Start all threads
for (Thread t : threads) {
t.start();
}
// Wait for all threads to finish
for (Thread t : threads) {
t.join();
}
// (#4:Expected: 1,000,000 - but we get less!)
System.out.println("Expected: 1000000");
System.out.println("Actual: " + counter);
}
}
Step 3.2: See the Bug
mvn compile exec:java -Dexec.mainClass="com.example.concurrency.RaceConditionDemo"
Run it multiple times. You'll get different results, always less than 1,000,000:
Expected: 1000000
Actual: 876543 (varies each run!)
Step 3.3: Why Does This Happen?
The operation counter++ looks atomic but it's actually THREE operations:
1. Read current value of counter (e.g., 100)
2. Add 1 to it (100 + 1 = 101)
3. Write the new value back (counter = 101)
When two threads do this simultaneously:
Thread A Thread B
────────────────────────────────────────
Read counter (100)
Read counter (100) ← Same value!
Add 1 (101)
Add 1 (101) ← Same result!
Write (counter = 101)
Write (counter = 101) ← Lost increment!
Result: counter is 101, but should be 102!
Part 4: Fixing Race Conditions
Step 4.1: Solution 1 - synchronized
Create src/main/java/com/example/concurrency/SynchronizedDemo.java:
package com.example.concurrency;
public class SynchronizedDemo {
private static int counter = 0;
private static final Object lock = new Object(); // (#1:Lock object)
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[1000];
for (int i = 0; i < 1000; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
// (#2:Only one thread can enter this block at a time)
synchronized (lock) {
counter++;
}
}
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
System.out.println("Expected: 1000000");
System.out.println("Actual: " + counter); // (#3:Now correct!)
}
}
mvn compile exec:java -Dexec.mainClass="com.example.concurrency.SynchronizedDemo"
Now you always get 1,000,000!
Think of a bathroom with a lock. Only one person can use it at a time. When Thread A enters the synchronized block, it "locks the door". Thread B has to wait until Thread A exits.
Step 4.2: Solution 2 - AtomicInteger (Better!)
Create src/main/java/com/example/concurrency/AtomicDemo.java:
package com.example.concurrency;
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicDemo {
// (#1:AtomicInteger is thread-safe by design)
private static AtomicInteger counter = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[1000];
for (int i = 0; i < 1000; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
counter.incrementAndGet(); // (#2:Atomic operation - no lock needed!)
}
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
System.out.println("Expected: 1000000");
System.out.println("Actual: " + counter.get());
}
}
- synchronized: Blocks other threads (slower)
- AtomicInteger: Uses CPU-level atomic operations (faster, no blocking)
Use Atomic classes for simple counters and flags!
Part 5: Thread Pools with ExecutorService
Creating threads is expensive. Imagine hiring a new employee for every task and firing them when done. Thread pools are like having permanent employees who handle multiple tasks.
Step 5.1: Create a Thread Pool
Create src/main/java/com/example/concurrency/ThreadPoolDemo.java:
package com.example.concurrency;
import java.util.concurrent.*;
public class ThreadPoolDemo {
public static void main(String[] args) throws InterruptedException {
// (#1:Create a pool with 4 threads)
ExecutorService executor = Executors.newFixedThreadPool(4);
System.out.println("Submitting 10 tasks to 4 threads...\n");
// (#2:Submit 10 tasks - they'll be distributed to 4 threads)
for (int i = 1; i <= 10; i++) {
final int taskId = i;
executor.submit(() -> {
String thread = Thread.currentThread().getName();
System.out.println("Task " + taskId + " started by " + thread);
sleep(1000); // Simulate work
System.out.println("Task " + taskId + " completed by " + thread);
});
}
// (#3:Shutdown and wait for all tasks)
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
System.out.println("\nAll tasks completed!");
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Step 5.2: Run and Observe
mvn compile exec:java -Dexec.mainClass="com.example.concurrency.ThreadPoolDemo"
Notice how:
- Only 4 tasks run simultaneously (we have 4 threads)
- When one finishes, the same thread picks up the next task
- Thread names are reused: pool-1-thread-1, pool-1-thread-2, etc.
Step 5.3: Different Pool Types
| Pool Type | Use Case |
|---|---|
newFixedThreadPool(n) | Fixed number of threads, predictable resource usage |
newCachedThreadPool() | Creates threads as needed, good for short tasks |
newSingleThreadExecutor() | One thread, tasks execute in order |
newScheduledThreadPool(n) | For delayed/periodic tasks |
Part 6: CompletableFuture (Modern Async)
It's Java's way of saying "do this task in the background, and when it's done, do that next thing". Like ordering food online and getting a notification when it's ready.
Step 6.1: Basic CompletableFuture
Create src/main/java/com/example/concurrency/CompletableFutureDemo.java:
package com.example.concurrency;
import java.util.concurrent.*;
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
System.out.println("Starting on " + Thread.currentThread().getName());
// (#1:Run task asynchronously)
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
System.out.println("Task running on " + Thread.currentThread().getName());
sleep(2000); // Simulate slow operation
return "Hello from async!";
});
System.out.println("Main thread continues immediately...");
// (#2:Do other work while waiting)
for (int i = 1; i <= 3; i++) {
System.out.println("Main thread working... " + i);
sleep(500);
}
// (#3:Get the result (blocks if not ready yet))
String result = future.get();
System.out.println("Result: " + result);
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Step 6.2: Chaining Operations
Create src/main/java/com/example/concurrency/FutureChainingDemo.java:
package com.example.concurrency;
import java.util.concurrent.*;
public class FutureChainingDemo {
public static void main(String[] args) throws Exception {
CompletableFuture<String> pipeline = CompletableFuture
// (#1:Step 1: Fetch user data)
.supplyAsync(() -> {
System.out.println("Step 1: Fetching user...");
sleep(1000);
return "John Doe";
})
// (#2:Step 2: Transform the result)
.thenApply(name -> {
System.out.println("Step 2: Transforming...");
return name.toUpperCase();
})
// (#3:Step 3: Another async operation)
.thenCompose(upperName -> CompletableFuture.supplyAsync(() -> {
System.out.println("Step 3: Enriching...");
sleep(1000);
return upperName + " (VIP)";
}))
// (#4:Step 4: Final transformation)
.thenApply(result -> {
System.out.println("Step 4: Finalizing...");
return "Welcome, " + result + "!";
});
System.out.println("Pipeline created, waiting for result...\n");
// (#5:Get final result)
String result = pipeline.get();
System.out.println("\nFinal result: " + result);
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
mvn compile exec:java -Dexec.mainClass="com.example.concurrency.FutureChainingDemo"
Output:
Pipeline created, waiting for result...
Step 1: Fetching user...
Step 2: Transforming...
Step 3: Enriching...
Step 4: Finalizing...
Final result: Welcome, JOHN DOE (VIP)!
Step 6.3: Combining Multiple Futures
Create src/main/java/com/example/concurrency/ParallelFuturesDemo.java:
package com.example.concurrency;
import java.util.concurrent.*;
public class ParallelFuturesDemo {
public static void main(String[] args) throws Exception {
long start = System.currentTimeMillis();
// (#1:Three independent tasks run in PARALLEL)
CompletableFuture<String> user = CompletableFuture.supplyAsync(() -> {
System.out.println("Fetching user...");
sleep(2000);
return "John";
});
CompletableFuture<Integer> orders = CompletableFuture.supplyAsync(() -> {
System.out.println("Counting orders...");
sleep(2000);
return 42;
});
CompletableFuture<Double> balance = CompletableFuture.supplyAsync(() -> {
System.out.println("Checking balance...");
sleep(2000);
return 1234.56;
});
// (#2:Wait for ALL to complete)
CompletableFuture.allOf(user, orders, balance).join();
// (#3:Combine results)
String result = String.format(
"User: %s, Orders: %d, Balance: $%.2f",
user.get(), orders.get(), balance.get()
);
long elapsed = System.currentTimeMillis() - start;
System.out.println("\n" + result);
System.out.println("Time: " + elapsed + "ms"); // (#4:~2000ms, not 6000ms!)
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
mvn compile exec:java -Dexec.mainClass="com.example.concurrency.ParallelFuturesDemo"
Total time is ~2 seconds (tasks run in parallel), not 6 seconds (sequential)!
Part 7: Thread-Safe Collections
Step 7.1: Unsafe vs Safe Collections
Create src/main/java/com/example/concurrency/CollectionSafetyDemo.java:
package com.example.concurrency;
import java.util.*;
import java.util.concurrent.*;
public class CollectionSafetyDemo {
public static void main(String[] args) throws InterruptedException {
// (#1:UNSAFE - ArrayList is NOT thread-safe)
List<Integer> unsafeList = new ArrayList<>();
// (#2:SAFE - CopyOnWriteArrayList IS thread-safe)
List<Integer> safeList = new CopyOnWriteArrayList<>();
// (#3:Another SAFE option - synchronized wrapper)
List<Integer> syncList = Collections.synchronizedList(new ArrayList<>());
ExecutorService executor = Executors.newFixedThreadPool(10);
// Add 1000 elements from multiple threads
for (int i = 0; i < 1000; i++) {
final int value = i;
executor.submit(() -> {
unsafeList.add(value); // May lose elements or throw exception!
safeList.add(value); // Safe
syncList.add(value); // Safe
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("Unsafe list size: " + unsafeList.size() + " (expected 1000)");
System.out.println("Safe list size: " + safeList.size() + " (expected 1000)");
System.out.println("Sync list size: " + syncList.size() + " (expected 1000)");
}
}
| Unsafe | Safe Alternative |
|---|---|
| ArrayList | CopyOnWriteArrayList |
| HashMap | ConcurrentHashMap |
| HashSet | ConcurrentHashMap.newKeySet() |
| LinkedList | ConcurrentLinkedQueue |
Part 8: Practical Exercise - Web Scraper
Step 8.1: Build a Parallel URL Fetcher
Create src/main/java/com/example/concurrency/ParallelFetcher.java:
package com.example.concurrency;
import java.util.*;
import java.util.concurrent.*;
public class ParallelFetcher {
public static void main(String[] args) throws Exception {
List<String> urls = List.of(
"https://api.example.com/users",
"https://api.example.com/products",
"https://api.example.com/orders",
"https://api.example.com/inventory",
"https://api.example.com/reviews"
);
// (#1:Sequential approach - slow!)
System.out.println("=== Sequential Fetching ===");
long start = System.currentTimeMillis();
for (String url : urls) {
String result = fetchUrl(url);
System.out.println("Fetched: " + result);
}
long sequential = System.currentTimeMillis() - start;
System.out.println("Sequential time: " + sequential + "ms\n");
// (#2:Parallel approach - fast!)
System.out.println("=== Parallel Fetching ===");
start = System.currentTimeMillis();
List<CompletableFuture<String>> futures = urls.stream()
.map(url -> CompletableFuture.supplyAsync(() -> fetchUrl(url)))
.toList();
// (#3:Wait for all and collect results)
List<String> results = futures.stream()
.map(CompletableFuture::join)
.toList();
results.forEach(r -> System.out.println("Fetched: " + r));
long parallel = System.currentTimeMillis() - start;
System.out.println("Parallel time: " + parallel + "ms");
System.out.println("\nSpeedup: " + (sequential / parallel) + "x faster!");
}
// (#4:Simulates fetching a URL - takes 1 second)
private static String fetchUrl(String url) {
try {
Thread.sleep(1000); // Simulate network latency
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return url.substring(url.lastIndexOf('/') + 1);
}
}
mvn compile exec:java -Dexec.mainClass="com.example.concurrency.ParallelFetcher"
Expected output:
=== Sequential Fetching ===
Fetched: users
Fetched: products
Fetched: orders
Fetched: inventory
Fetched: reviews
Sequential time: 5012ms
=== Parallel Fetching ===
Fetched: users
Fetched: products
Fetched: orders
Fetched: inventory
Fetched: reviews
Parallel time: 1008ms
Speedup: 5x faster!
Deliverables Checklist
- BasicThreadDemo runs and shows interleaved output
- RaceConditionDemo shows missing counts (the bug)
- SynchronizedDemo shows correct count (1,000,000)
- AtomicDemo shows correct count (1,000,000)
- ThreadPoolDemo shows tasks distributed across 4 threads
- CompletableFutureDemo shows async execution
- FutureChainingDemo shows step-by-step pipeline
- ParallelFuturesDemo shows ~2s instead of ~6s
- ParallelFetcher shows 5x speedup
Key Takeaways
- Simple counter? → Use
AtomicInteger - Shared data? → Use
synchronizedor concurrent collections - Many small tasks? → Use
ExecutorServicethread pool - Async with callbacks? → Use
CompletableFuture - Multiple independent tasks? → Use
CompletableFuture.allOf()
Bonus Challenges
Create a program where one thread produces numbers and another consumes them using a BlockingQueue.
Implement a simple rate limiter that allows only N requests per second using Semaphore.
Rewrite ParallelFetcher using Virtual Threads: Thread.startVirtualThread()