- Create a thread, launch it with
start()and wait for it withjoin() - Recognise a race condition and fix it with
synchronizedorAtomicInteger - Run tasks with
ExecutorService,CompletableFutureand virtual threads
In a shop with one checkout the queue grows, but when five checkouts open, customers are served quickly. A computer's processor has several cores too, and a server has to answer thousands of requests at the same time. The ability to do several jobs at once is called concurrency. Java is one of the strongest languages here: from simple threads to the virtual threads of Java 21.
An independent line of execution inside a program. Every Java program starts with one thread called main; you can create new threads and split the work between them. Threads share the same memory — the same objects — which is both convenient and dangerous.
Creating threads: start and join
We pass the work to the new Thread(...) constructor as a lambda. start() launches the new thread and returns immediately — main carries on with its own work. join() waits until that thread has finished. Below, the sum of the numbers from 1 to 1,000,000 is split into two halves, and each half is computed by its own thread; we read the results only after join().
public class Main {
public static void main(String[] args) throws InterruptedException {
long[] sums = new long[2];
Thread first = new Thread(() -> sums[0] = sum(1, 500_000));
Thread second = new Thread(() -> sums[1] = sum(500_001, 1_000_000));
first.start();
second.start();
first.join();
second.join();
System.out.println("Part 1: " + sums[0]);
System.out.println("Part 2: " + sums[1]);
System.out.println("Total: " + (sums[0] + sums[1]));
System.out.println("Printed by: " + Thread.currentThread().getName());
}
static long sum(long from, long to) {
long total = 0;
for (long i = from; i <= to; i++) total += i;
return total;
}
}Part 1: 125000250000 Part 2: 375000250000 Total: 500000500000 Printed by: main
main waits for them with join()Thread t = new Thread(() -> System.out.println(Thread.currentThread().getName()));
t.run(); // prints main: no new thread was created!Thread t = new Thread(() -> System.out.println(Thread.currentThread().getName()));
t.start(); // prints Thread-0: the code runs in a new thread
t.join();Shared data: race conditions
count++ looks like one operation, but it is really three steps: read the value, add 1, write it back. If two threads read at the same moment, both write the same value and one increment is lost. This bug is called a race condition: the result depends on the random order of the threads. In a test, two threads looping 100,000 times each gave, for example, 121,962 instead of 200,000 — and a different number on every run.
static int count = 0;
// each of two threads runs:
for (int i = 0; i < 100_000; i++) {
count++; // read + add + write: not atomic
}
// result: often less than 200000static final AtomicInteger count = new AtomicInteger();
// each of two threads runs:
for (int i = 0; i < 100_000; i++) {
count.incrementAndGet(); // one indivisible operation
}
// result: always 200000There are two main fixes. The synchronized keyword locks a method: only one thread can be inside it at a time, and the others wait their turn. Classes such as AtomicInteger and AtomicLong from java.util.concurrent.atomic do the increment without a lock, as one indivisible processor operation, and are usually faster. For collections there are ready-made safe versions: ConcurrentHashMap, CopyOnWriteArrayList.
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
static int syncCount = 0;
static final AtomicInteger atomicCount = new AtomicInteger();
static synchronized void incrementSync() {
syncCount++;
}
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
incrementSync();
atomicCount.incrementAndGet();
}
};
Thread a = new Thread(task);
Thread b = new Thread(task);
a.start();
b.start();
a.join();
b.join();
System.out.println("synchronized: " + syncCount);
System.out.println("AtomicInteger: " + atomicCount.get());
}
}synchronized: 200000 AtomicInteger: 200000
ExecutorService: a thread pool
Creating a new thread for every task is expensive. An ExecutorService keeps a pool of ready threads and hands tasks out to them. submit hands over one task and returns a Future — a “receipt” for a future result; invokeAll hands over a list of tasks and waits for all of them. Future.get() waits until the result is ready. Since Java 19 an ExecutorService can be closed with try-with-resources: when the block ends, all tasks are awaited and the pool is shut down.
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 {
public static void main(String[] args) throws Exception {
List<String> classes = List.of("10A", "10B", "11A");
long start = System.currentTimeMillis();
try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
List<Callable<String>> jobs = classes.stream()
.map(c -> (Callable<String>) () -> buildReport(c))
.toList();
for (Future<String> f : pool.invokeAll(jobs)) {
System.out.println(f.get());
}
}
long ms = System.currentTimeMillis() - start;
System.out.println("Parallel was faster: " + (ms < 900));
}
static String buildReport(String name) throws InterruptedException {
Thread.sleep(300); // pretend to read the database
return "Report for " + name + " is ready";
}
}Report for 10A is ready Report for 10B is ready Report for 11A is ready Parallel was faster: true
CompletableFuture: asynchronous chains
Future.get() blocks the thread. CompletableFuture lets you describe in advance what to do when the result arrives: supplyAsync starts the work in the background, thenApply transforms the result, thenCombine joins two independent results, and exceptionally provides a fallback value if something fails. At the end, join() takes the final result. This is very similar to a Promise in JavaScript.
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) {
CompletableFuture<Integer> exam = CompletableFuture.supplyAsync(() -> slow(92, 300));
CompletableFuture<Integer> bonus = CompletableFuture.supplyAsync(() -> slow(5, 200));
CompletableFuture<String> report = exam
.thenCombine(bonus, Integer::sum)
.thenApply(total -> Math.min(total, 100))
.thenApply(total -> "Final score: " + total);
System.out.println(report.join());
CompletableFuture<Integer> broken = CompletableFuture
.supplyAsync(() -> Integer.parseInt("ninety"))
.exceptionally(ex -> -1);
System.out.println("With fallback: " + broken.join());
}
static int slow(int value, long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return value;
}
}Final score: 97 With fallback: -1
ninety is not a number, so the fallback -1 kicks inVirtual threads (Java 21)
An ordinary (platform) thread is tied to an operating-system thread and usually reserves about 1 MB of memory for its stack, so creating thousands of them is hard. The virtual threads that arrived in Java 21 are lightweight threads managed by the JVM: while waiting (for the network, a file, a database) they free up the underlying thread, and you can create millions of them. Below, each of 10,000 tasks “waits” for 1 second. With an ordinary pool of 100 threads this would take about 100 seconds; with virtual threads it takes a second or two.
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
public static void main(String[] args) {
AtomicInteger done = new AtomicInteger();
Instant start = Instant.now();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // a slow network call
done.incrementAndGet();
return null;
});
}
} // close() waits until every task has finished
Duration took = Duration.between(start, Instant.now());
System.out.println("Finished tasks: " + done.get());
System.out.println("Took less than 5 s: " + (took.toSeconds() < 5));
Thread vt = Thread.ofVirtual().start(() -> {});
System.out.println("Is virtual: " + vt.isVirtual());
}
}Finished tasks: 10000 Took less than 5 s: true Is virtual: true
newVirtualThreadPerTaskExecutor()Code written with virtual threads looks like ordinary sequential code: Thread.sleep, reading a file and querying a database are written as usual, with no callbacks or chains. You can also start a single thread with Thread.ofVirtual().start(...) or Thread.startVirtualThread(...). Web servers benefit too: for example, since Spring Boot 3.2 the setting spring.threads.virtual.enabled=true handles every HTTP request on a virtual thread.
| Situation | Tool |
|---|---|
| Lots of waiting: HTTP requests, databases, files | newVirtualThreadPerTaskExecutor() |
| Heavy computation (CPU) | newFixedThreadPool(cores) |
| Chaining and combining results | CompletableFuture |
| A shared counter or map | AtomicInteger, ConcurrentHashMap |
Key points
start()opens a new thread andjoin()waits for it;run()does not create a thread.- Shared mutable data causes race conditions; the fixes are
synchronized, atomic types andConcurrentHashMap. ExecutorServicereuses threads;submitandinvokeAllreturnFutureobjects.- A
CompletableFuturechain:supplyAsyncstarts the work,thenApply/thenCombinetransform and combine results,exceptionallyhandles errors andjointakes the final value. - Java 21 virtual threads are for work full of waiting; for computation choose a pool with as many threads as cores.
Check yourself
10 questions. Every correct answer earns XP.
t.run() instead of t.start()?