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

JAVA
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());
    }
}
Output
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

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 {
    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);
        }
    }
}
Output
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

JAVA
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));
    }
}
Output
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

JAVA
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));
    }
}
Output
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.
  • synchronized methods or blocks, or ReentrantLock, 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

JAVA
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());
    }
}
Output
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

JAVA
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);
    }
}
Output
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

JAVA
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;
    }
}
Output
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.

Try it yourself · Main.java
Edit the code, copy it into Main.java and run java Main.java (Java 25+). How to set up Java
Output
Basket total: 95.5