Java Advanced: Generics, Streams and Concurrency
Concurrency: Threads, Executors and Futures
In this lesson you will
- Start and join threads
- Explain a race condition and fix it with synchronized or atomic classes
- Use ExecutorService, Future, CompletableFuture and virtual threads
Introduction
Your computer can do several things at once, and modern programs should too: a web server handles thousands of users, a game keeps drawing while it loads, an app downloads files without freezing the screen. In Java, each independent line of execution is a thread. Concurrency is powerful, but it is also where the trickiest bugs live, so we will go carefully.
The examples are written so that their output is predictable. Real concurrent programs often interleave output differently from run to run.
Starting a thread
Java
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
System.out.println("worker: running in " + Thread.currentThread().getName());
}, "worker-1");
worker.start();
worker.join();
System.out.println("main: worker is finished");
}
}worker: running in worker-1
main: worker is finished- A
Threadtakes aRunnable, often written as a lambda. start()begins running it in parallel with the rest of the program. (Callingrun()instead would just run it in the current thread.)join()makes the current thread wait for the other thread to finish.
Quick quiz
What does thread.join() do?
The danger: race conditions
When threads share data and at least one changes it, things can go wrong. The statement count++ looks like one step but is really three: read, add, write. If two threads interleave those steps, updates are lost.
Java
public class Main {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
count++;
}
};
Thread a = new Thread(task);
Thread b = new Thread(task);
a.start();
b.start();
a.join();
b.join();
System.out.println("expected 200000, got a number that is often smaller");
}
}expected 200000, got a number that is often smaller(We print a fixed message here because the real total is unpredictable, which is the point.) This is a race condition: the result depends on timing.
Fix 1: synchronized
A synchronized block lets only one thread at a time run the protected code on the same lock object.
Java
public class Main {
private static int count = 0;
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
synchronized (lock) {
count++;
}
}
};
Thread a = new Thread(task);
Thread b = new Thread(task);
a.start();
b.start();
a.join();
b.join();
System.out.println(count);
}
}200000Fix 2: atomic classes
For simple counters, AtomicInteger does the read-add-write as one indivisible step, without locks, and is faster.
Java
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
public static void main(String[] args) throws InterruptedException {
AtomicInteger count = new AtomicInteger();
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
count.incrementAndGet();
}
};
Thread a = new Thread(task);
Thread b = new Thread(task);
a.start();
b.start();
a.join();
b.join();
System.out.println(count.get());
}
}200000Quick quiz
Why can count++ lose updates when two threads run it at the same time?
Executor services: let Java manage the threads
Creating threads by hand does not scale. An ExecutorService keeps a pool of worker threads and gives them tasks. A task that returns a result is a Callable, and you get a Future to collect the result later.
Java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class Main {
static long sumTo(int n) {
long total = 0;
for (int i = 1; i <= n; i++) {
total += i;
}
return total;
}
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(3);
List<Future<Long>> futures = new ArrayList<>();
for (int n : new int[] {10, 100, 1000}) {
Callable<Long> job = () -> sumTo(n);
futures.add(pool.submit(job));
}
for (Future<Long> f : futures) {
System.out.println(f.get()); // waits for the result
}
pool.shutdown();
}
}55
5050
500500Always call shutdown(), or the program may never exit because the pool threads keep waiting. future.get() blocks until that task is done, and we read the futures in order, so the output is stable.
CompletableFuture: chaining async steps
CompletableFuture lets you describe a pipeline of asynchronous steps without blocking, similar to streams.
Java
import java.util.concurrent.CompletableFuture;
public class Main {
static String fetchName() {
return "asha";
}
static int fetchScore() {
return 90;
}
public static void main(String[] args) {
CompletableFuture<String> name = CompletableFuture
.supplyAsync(Main::fetchName)
.thenApply(String::toUpperCase);
CompletableFuture<Integer> score = CompletableFuture.supplyAsync(Main::fetchScore);
CompletableFuture<String> report = name.thenCombine(score, (n, s) -> n + " scored " + s);
System.out.println(report.join());
}
}ASHA scored 90supplyAsyncruns a supplier on another thread.thenApplytransforms the result when it arrives.thenCombinewaits for two futures and merges their results.join()waits for the final answer.
You can also handle failures with exceptionally or handle, and wait for many with CompletableFuture.allOf(...).
Virtual threads (Java 21)
Normal ("platform") threads are expensive: each one maps to an operating system thread, so you can only have a few thousand. Virtual threads, finalised in Java 21, are very cheap and managed by the JVM. You can have millions, which makes the simple style of "one thread per task" work for servers that wait a lot on networks and databases.
Java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class Main {
public static void main(String[] args) throws Exception {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Integer>> results = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
int n = i;
results.add(executor.submit(() -> {
Thread.sleep(50); // pretend to wait for a network
return n * n;
}));
}
for (Future<Integer> f : results) {
System.out.println(f.get());
}
}
}
}1
4
9
16
25Here five tasks each sleep for 50 milliseconds, but because they run concurrently the whole program takes about 50 ms, not 250 ms. Notice that ExecutorService can be used in a try-with-resources block in Java 21, which waits for the tasks and shuts it down for you.
Rules of thumb
- Prefer high-level tools (
ExecutorService,CompletableFuture, concurrent collections such asConcurrentHashMap) over raw threads and locks. - Share as little mutable data as possible. Immutable objects (such as records) are automatically thread-safe.
- Protect shared mutable data with
synchronized, locks or atomic classes. - Always shut down executors, and handle
InterruptedExceptionproperly. - Test concurrent code carefully. A program that passes 1,000 times can still have a race.
Try it yourself
On your computer, use an ExecutorService with 4 threads to compute the sum of the numbers from 1 to 4,000,000 by splitting the range into 4 parts, one per task, and adding the four partial results from the Futures. The answer is 8000002000000. Then rewrite it using a virtual-thread executor.
Summary
- A thread runs code concurrently.
start()launches it andjoin()waits for it. - Shared mutable data causes race conditions. Fix them with
synchronizedor atomic classes such asAtomicInteger. ExecutorServicemanages a pool of threads, andFutureandCompletableFuturecarry results back.- Java 21 virtual threads are cheap, so you can run huge numbers of blocking tasks.
Lesson complete
+20 XP
Nice work. Keep going while it is fresh.