Threads & Concurrency
Concurrency lets a program do several things at once: serve many web requests, download files in parallel, keep a user interface responsive. Java has had threads since day one, and modern Java adds executors, CompletableFuture and — since Java 21 — lightweight virtual threads. Concurrency is powerful but subtle, so this lesson also covers the classic pitfalls.
Starting a thread
public class Main { public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> System.out.println("Hello from " + Thread.currentThread().getName()), "worker-1"); worker.start(); worker.join(); System.out.println("Hello from " + Thread.currentThread().getName()); } }
Hello from worker-1 Hello from main
A thread runs a Runnable — here a lambda — alongside the main thread. start() launches it; join() waits for it to finish. Without join, the two lines could print in either order.
Executors and futures
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 { static long slowSquare(int n) throws InterruptedException { Thread.sleep(100); return (long) n * n; } public static void main(String[] args) throws Exception { try (ExecutorService pool = Executors.newFixedThreadPool(4)) { List<Future<Long>> results = new ArrayList<>(); for (int i = 1; i <= 8; i++) { int n = i; results.add(pool.submit(() -> slowSquare(n))); } long sum = 0; for (Future<Long> f : results) sum += f.get(); System.out.println("Sum of squares: " + sum); } } }
Sum of squares: 204
Rather than managing threads yourself, submit tasks to an ExecutorService, which reuses a pool of threads. submit returns a Future; get() waits for its result. Eight 100 ms tasks on four threads take about 200 ms instead of 800 ms. Since Java 19, executors are AutoCloseable: closing waits for submitted tasks and shuts the pool down.
Virtual threads
import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; public class Main { public static void main(String[] args) { AtomicInteger done = new AtomicInteger(); long start = System.nanoTime(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 10_000; i++) { executor.submit(() -> { Thread.sleep(200); done.incrementAndGet(); return null; }); } } long ms = (System.nanoTime() - start) / 1_000_000; System.out.println(done.get() + " tasks finished in under 5 seconds: " + (ms < 5000)); } }
10000 tasks finished in under 5 seconds: true
Virtual threads (Java 21+) are managed by the JVM rather than the operating system, so you can have hundreds of thousands of them cheaply. When one waits — for a network response, a database or sleep — it steps aside and frees the underlying thread. They make simple "one thread per task" code scale for I/O-heavy servers. (For CPU-heavy work, a pool sized to your processor count is still the right tool.)
Race conditions
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; public class Main { static int unsafeCount = 0; public static void main(String[] args) { AtomicInteger safeCount = new AtomicInteger(); try (ExecutorService pool = Executors.newFixedThreadPool(8)) { for (int i = 0; i < 8; i++) { pool.submit(() -> { for (int j = 0; j < 100_000; j++) { unsafeCount++; safeCount.incrementAndGet(); } }); } } System.out.println("Atomic counter: " + safeCount.get()); System.out.println("Plain counter correct? " + (unsafeCount == 800_000)); } }
Atomic counter: 800000 Plain counter correct? false
count++ is really three steps — read, add, write — and threads interleave them, losing updates. The plain counter usually ends up below 800,000, and by a different amount each run. Fixes:
- Atomic classes (
AtomicInteger,LongAdder) for counters. synchronizedmethods or blocks, orReentrantLock, so only one thread at a time runs a critical section.- Thread-safe collections:
ConcurrentHashMap,CopyOnWriteArrayList,BlockingQueue. - Immutability and confinement: share only immutable data, or don't share at all.
synchronized
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; class Account { private int balance = 1000; synchronized boolean withdraw(int amount) { if (balance >= amount) { balance -= amount; return true; } return false; } synchronized int balance() { return balance; } } public class Main { public static void main(String[] args) { Account acc = new Account(); try (ExecutorService pool = Executors.newFixedThreadPool(10)) { for (int i = 0; i < 50; i++) pool.submit(() -> acc.withdraw(30)); } System.out.println("Balance: " + acc.balance()); } }
Balance: 10
Fifty withdrawals of 30 race for 1,000. With synchronized, the check and the update happen as one indivisible step, so exactly 33 succeed and the balance never goes negative.
CompletableFuture
import java.util.concurrent.CompletableFuture; public class Main { public static void main(String[] args) { CompletableFuture<String> user = CompletableFuture.supplyAsync(() -> "Ana"); CompletableFuture<Integer> orders = CompletableFuture.supplyAsync(() -> 3); String summary = user.thenCombine(orders, (name, count) -> name + " has " + count + " orders") .thenApply(String::toUpperCase) .join(); System.out.println(summary); } }
ANA HAS 3 ORDERS
CompletableFuture chains asynchronous steps — run two lookups in parallel, combine them, transform the result — without blocking a thread at each step.
Parallel streams
import java.util.stream.LongStream; public class Main { public static void main(String[] args) { long count = LongStream.rangeClosed(2, 200_000).parallel().filter(Main::isPrime).count(); System.out.println(count + " primes"); } static boolean isPrime(long n) { for (long d = 2; d * d <= n; d++) if (n % d == 0) return false; return true; } }
17984 primes
.parallel() splits a stream's work across processor cores. It helps for large, CPU-heavy, independent computations — and hurts for small streams or operations with side effects. Measure before relying on it.
Try it yourself
Fetch three "prices" concurrently with virtual threads and add them up.
Basket total: 95.5