Skip to content
Laviyx Learn

Try

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.

Advanced15 min readUpdated
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:

Java
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]);
    }
}
Output
500000500000 2000001000000

Each 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:

Java
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 run

Bugs 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):

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

Now 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:

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

ExecutorService: 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:

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

Since 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:

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

Thread-safe collections#

Ordinary collections such as HashMap and ArrayList are not safe to modify from several threads. Use the concurrent versions:

NeedThread-safe choice
Shared mapConcurrentHashMap
Producer/consumer queueArrayBlockingQueue, LinkedBlockingQueue
Mostly-read listCopyOnWriteArrayList
Java
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));
    }
}
Output
{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):

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

Ten 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#

  1. Prefer not sharing mutable data: give each task its own data and combine results at the end.
  2. When you must share, protect every access (synchronized, locks, atomics, concurrent collections).
  3. Use executors (or virtual threads) rather than managing raw Thread objects yourself.
  4. 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
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 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);
        }
    }
}
Output
9004500500

The 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?