Skip to content
Educora
Advanced24 min15 / 18

Concurrency in Java

Do several jobs at once with threads, `ExecutorService`, `CompletableFuture`, `synchronized` and atomic types, and meet the virtual threads of Java 21.

Check yourself
In this lesson you will learn
  • Create a thread, launch it with start() and wait for it with join()
  • Recognise a race condition and fix it with synchronized or AtomicInteger
  • Run tasks with ExecutorService, CompletableFuture and 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.

Definition
Thread

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().

Java
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;
    }
}
Expected output
Part 1: 125000250000
Part 2: 375000250000
Total:  500000500000
Printed by: main
Two threads share the work, and main waits for them with join()
Wrong: `run()` is a plain method call
Thread t = new Thread(() -> System.out.println(Thread.currentThread().getName()));
t.run();     // prints main: no new thread was created!
Right: `start()` opens a new thread
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.

Race condition
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 200000
Atomic counter
static 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 200000

There 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.

Java
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());
    }
}
Expected output
synchronized: 200000
AtomicInteger: 200000
Both approaches always give 200,000

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.

Java
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";
    }
}
Expected output
Report for 10A is ready
Report for 10B is ready
Report for 11A is ready
Parallel was faster: true
Each report takes 300 ms, yet all three are ready in about 300 ms

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.

Java
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;
    }
}
Expected output
Final score: 97
With fallback: -1
The exam score and the bonus are computed in parallel; ninety is not a number, so the fallback -1 kicks in

Virtual 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.

Java
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());
    }
}
Expected output
Finished tasks: 10000
Took less than 5 s: true
Is virtual: true
A separate virtual thread for every task: 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.

SituationTool
Lots of waiting: HTTP requests, databases, filesnewVirtualThreadPerTaskExecutor()
Heavy computation (CPU)newFixedThreadPool(cores)
Chaining and combining resultsCompletableFuture
A shared counter or mapAtomicInteger, ConcurrentHashMap

Key points

  • start() opens a new thread and join() waits for it; run() does not create a thread.
  • Shared mutable data causes race conditions; the fixes are synchronized, atomic types and ConcurrentHashMap.
  • ExecutorService reuses threads; submit and invokeAll return Future objects.
  • A CompletableFuture chain: supplyAsync starts the work, thenApply / thenCombine transform and combine results, exceptionally handles errors and join takes 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.

1 / 10
What happens if you call t.run() instead of t.start()?