Java course menu
Java Threads and Concurrency: Thread, ExecutorService, synchronized, Atomic and Virtual Threads
An introduction to concurrency in Java: creating threads with Runnable and lambdas, join, race conditions, synchronized and locks, AtomicInteger, ExecutorService and Future, CompletableFuture, ConcurrentHashMap, and Java 21 virtual threads.
On this page
A thread is an independent path of execution inside a program. With several threads, a program can do several things at once: serve many users, download while staying responsive, or split heavy calculations across CPU cores. Java has had built-in thread support from the beginning, and modern Java adds much higher-level tools.
Concurrency is powerful but introduces a new class of bugs. This lesson covers the essentials and the most important safety rules.
Creating and joining threads#
A thread runs a Runnable, which is a functional interface, so a lambda works. start() launches the thread; join() waits for it to finish:
public class Main {
static long sumTo(int n) {
long sum = 0;
for (int i = 1; i <= n; i++) sum += i;
return sum;
}
public static void main(String[] args) throws InterruptedException {
long[] results = new long[2];
Thread t1 = new Thread(() -> results[0] = sumTo(1_000_000));
Thread t2 = new Thread(() -> results[1] = sumTo(2_000_000));
t1.start();
t2.start();
t1.join(); // wait for both to finish
t2.join();
System.out.println(results[0] + " " + results[1]);
}
}500000500000 2000001000000Each thread writes to a different array slot, so there is no conflict. Call start(), not run(): run() would simply execute the code on the current thread.
Race conditions#
When several threads update the same data without coordination, updates can be lost. count++ is really three steps (read, add, write), and threads can interleave them:
int[] counter = {0};
Runnable work = () -> {
for (int i = 0; i < 100_000; i++) counter[0]++; // not atomic!
};
// two threads running 'work' often end with a total below 200000,
// and a different total on every runBugs like this appear only sometimes, which makes them very hard to find. Shared mutable data needs protection.
synchronized#
A synchronized method or block lets only one thread at a time run it for a given object (the lock):
public class Main {
private int count = 0;
synchronized void increment() {
count++;
}
public static void main(String[] args) throws InterruptedException {
Main counter = new Main();
Runnable work = () -> {
for (int i = 0; i < 100_000; i++) counter.increment();
};
Thread t1 = new Thread(work);
Thread t2 = new Thread(work);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}200000Now the result is correct every time. Keep synchronized sections short: while one thread holds the lock, others wait. The java.util.concurrent.locks package offers more flexible locks such as ReentrantLock.
Atomic variables#
For a single shared number, the classes in java.util.concurrent.atomic are simpler and faster than locks:
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
public static void main(String[] args) throws InterruptedException {
AtomicInteger hits = new AtomicInteger();
Thread[] threads = new Thread[4];
for (int t = 0; t < threads.length; t++) {
threads[t] = new Thread(() -> {
for (int i = 0; i < 50_000; i++) hits.incrementAndGet();
});
threads[t].start();
}
for (Thread t : threads) t.join();
System.out.println(hits.get());
}
}200000ExecutorService: thread pools#
Creating a new platform thread for every task is expensive. An ExecutorService manages a pool of threads and runs tasks you submit. A task that returns a value is a Callable, and submit returns a Future you can ask for the result:
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 boolean isPrime(int n) {
if (n < 2) return false;
for (int d = 2; (long) d * d <= n; d++) {
if (n % d == 0) return false;
}
return true;
}
static int countPrimes(int from, int to) {
int count = 0;
for (int n = from; n < to; n++) {
if (isPrime(n)) count++;
}
return count;
}
public static void main(String[] args) throws Exception {
int limit = 200_000;
int parts = 4;
try (ExecutorService pool = Executors.newFixedThreadPool(parts)) {
List<Future<Integer>> futures = new ArrayList<>();
for (int p = 0; p < parts; p++) {
int from = p * limit / parts;
int to = (p + 1) * limit / parts;
futures.add(pool.submit(() -> countPrimes(from, to)));
}
int total = 0;
for (Future<Integer> f : futures) {
total += f.get(); // waits for that task
}
System.out.println(total);
}
}
}17984Since Java 19, ExecutorService is AutoCloseable, so try-with-resources shuts the pool down and waits for its tasks. Each task works on its own range and returns its own result, so no locking is needed.
CompletableFuture#
CompletableFuture chains asynchronous steps without blocking until the end:
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) {
CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> 499);
CompletableFuture<Integer> quantity = CompletableFuture.supplyAsync(() -> 3);
int total = price.thenCombine(quantity, (p, q) -> p * q)
.thenApply(t -> t + 50) // add delivery
.join();
System.out.println(total);
}
}1547Thread-safe collections#
Ordinary collections such as HashMap and ArrayList are not safe to modify from several threads. Use the concurrent versions:
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
public class Main {
public static void main(String[] args) throws InterruptedException {
Map<String, Integer> counts = new ConcurrentHashMap<>();
String[] words = {"red", "blue", "red", "green", "blue", "red"};
Thread[] threads = new Thread[3];
for (int t = 0; t < threads.length; t++) {
threads[t] = new Thread(() -> {
for (String w : words) counts.merge(w, 1, Integer::sum); // atomic per key
});
threads[t].start();
}
for (Thread t : threads) t.join();
System.out.println(new TreeMap<>(counts));
}
}{blue=6, green=3, red=9}Virtual threads (Java 21)#
Virtual threads are lightweight threads managed by the JVM. You can create millions of them, which makes the simple "one thread per task" style practical even for huge numbers of tasks that mostly wait (for the network, a database or files):
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 <= 10_000; i++) {
int id = i;
results.add(executor.submit(() -> {
Thread.sleep(10); // simulate waiting for I/O
return id % 7;
}));
}
long sum = 0;
for (Future<Integer> f : results) sum += f.get();
System.out.println(sum);
}
}
}29998Ten thousand tasks that each sleep for 10 ms finish quickly, because sleeping virtual threads do not occupy operating-system threads. For CPU-heavy work, a fixed pool sized to the number of cores is still the right tool.
Guidelines#
- Prefer not sharing mutable data: give each task its own data and combine results at the end.
- When you must share, protect every access (synchronized, locks, atomics, concurrent collections).
- Use executors (or virtual threads) rather than managing raw
Threadobjects yourself. - Prefer immutable objects (records,
List.of) for data passed between threads.
Common mistakes#
Practice#
Practice exercise
Using an ExecutorService with 3 threads, compute the sums of squares of 1–1000, 1001–2000 and 2001–3000 as three separate tasks, then print the total.
Show solution
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 SumSquares {
static long sumSquares(int from, int to) {
long sum = 0;
for (long i = from; i <= to; i++) sum += i * i;
return sum;
}
public static void main(String[] args) throws Exception {
try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
List<Future<Long>> parts = new ArrayList<>();
for (int start = 1; start <= 2001; start += 1000) {
int from = start;
parts.add(pool.submit(() -> sumSquares(from, from + 999)));
}
long total = 0;
for (Future<Long> f : parts) total += f.get();
System.out.println(total);
}
}
}9004500500The sum of squares of 1 to n is n(n+1)(2n+1)/6, which for n = 3000 is 9,004,500,500.
Key takeaway#
Finished this lesson?