Threads & Virtual Threads

Java · Lesson 27 of 32 8 min read Updated

So far every program has done one thing at a time. A real server does not: while one customer's payment waits for the bank, hundreds of other customers are browsing, searching and booking. A thread is an independent path of execution inside a program. Several threads can run at the same time, which is called concurrency.

Concurrency is powerful and also one of the trickiest parts of programming, because threads can interfere with each other. In this lesson you will start threads, see a real race condition and fix it, use thread pools, and then meet virtual threads, the Java 21 feature that lets one program handle thousands of simultaneous tasks easily.

Starting threads

Every Java program starts with one thread, called main. To start another, create a Thread with a Runnable (a functional interface with a run() method, so a lambda works) and call start(). join() makes the current thread wait until another thread finishes.

Java
public class Basics {
    static void download(String file, int seconds) {
        String who = Thread.currentThread().getName();
        System.out.println(who + " started " + file);
        try {
            Thread.sleep(seconds * 100L);          // pretend to download
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println(who + " finished " + file);
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> download("syllabus.pdf", 3), "worker-1");
        Thread t2 = new Thread(() -> download("timetable.pdf", 1), "worker-2");

        t1.start();
        t2.start();
        t1.join();                                  // wait for both to finish
        t2.join();

        System.out.println("Both downloads done");
    }
}

One possible output (the order of the first two lines can change from run to run):

Text
worker-2 started timetable.pdf
worker-1 started syllabus.pdf
worker-2 finished timetable.pdf
worker-1 finished syllabus.pdf
Both downloads done

Both downloads run at the same time, so the shorter one finishes first even though it started second. You cannot predict exactly how threads interleave; the operating system decides. That is why the output order may differ on your computer.

Call start(), not run(). Calling run() directly just runs the code in the current thread, with no concurrency at all. Thread.sleep can throw the checked InterruptedException, which signals that another thread asked this one to stop; restoring the flag with interrupt() is the standard response.

Race conditions and how to fix them

When two threads change the same variable, updates can be lost. count++ looks like one step but is really three: read the value, add one, write it back. If two threads read the same old value at the same moment, one increment disappears. This bug is called a race condition.

Java
import java.util.concurrent.atomic.AtomicInteger;

public class Race {
    static int unsafeCount = 0;
    static final AtomicInteger safeCount = new AtomicInteger();
    static int lockedCount = 0;
    static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Runnable sellTickets = () -> {
            for (int i = 0; i < 100_000; i++) {
                unsafeCount++;                      // read, add, write: not atomic!
                safeCount.incrementAndGet();        // atomic
                synchronized (lock) {               // one thread at a time
                    lockedCount++;
                }
            }
        };

        Thread counterA = new Thread(sellTickets);
        Thread counterB = new Thread(sellTickets);
        counterA.start();
        counterB.start();
        counterA.join();
        counterB.join();

        System.out.println("Unsafe:       " + unsafeCount);
        System.out.println("Atomic:       " + safeCount.get());
        System.out.println("Synchronized: " + lockedCount);
    }
}

Three runs on the same laptop printed 198767, 199900 and 193668 for the unsafe counter. For example:

Text
Unsafe:       193668
Atomic:       200000
Synchronized: 200000

Two ticket counters each sold 100,000 tickets, so the answer must be 200,000. The plain int lost thousands of sales, and a different number each time, which makes such bugs very hard to find. Two fixes work:

  • Atomic classes such as AtomicInteger and AtomicLong (in java.util.concurrent.atomic) perform the read-add-write as one indivisible step.
  • A synchronized block lets only one thread at a time run the code inside, using an object as a lock. Methods can be marked synchronized too.

The best fix is often to avoid sharing changeable data at all: give each task its own data and combine the results at the end. For shared maps, use ConcurrentHashMap instead of HashMap.

Thread pools with ExecutorService

Creating a platform thread by hand for every task is expensive and hard to manage. An ExecutorService keeps a pool of threads and runs the tasks you submit. Submitting a Callable (a task that returns a value) gives you a Future, a placeholder for the result; future.get() waits for it.

Java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Pool {
    static int countPrimes(int from, int to) {
        int count = 0;
        for (int n = Math.max(from, 2); n <= to; n++) {
            boolean prime = true;
            for (int d = 2; d * d <= n; d++) {
                if (n % d == 0) { prime = false; break; }
            }
            if (prime) count++;
        }
        return count;
    }

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        List<Future<Integer>> parts = new ArrayList<>();
        try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
            for (int i = 0; i < 4; i++) {
                int from = i * 250_000 + 1;
                int to = (i + 1) * 250_000;
                parts.add(pool.submit(() -> countPrimes(from, to)));
            }
            int total = 0;
            for (Future<Integer> f : parts) {
                total += f.get();                    // waits for that task's result
            }
            System.out.println("Primes up to 1,000,000: " + total);
        }
    }
}
Output
Primes up to 1,000,000: 78498

The work is split into four ranges that run in parallel on four threads, each with its own counter, so nothing is shared and no locking is needed. Since Java 19, ExecutorService can be used in try-with-resources: close() waits for submitted tasks and shuts the pool down. In older code you will see explicit shutdown() and awaitTermination(...) calls instead.

Virtual threads

A normal Java thread, now called a platform thread, is a wrapper around an operating-system thread. Each one reserves a lot of memory, so a server can only have a few thousand. That is a problem for web applications, where most of a request's time is spent waiting: for the database, for a payment gateway, for another web service.

Virtual threads, final since Java 21 (JEP 444), are lightweight threads managed by the JVM. When a virtual thread waits, for example during Thread.sleep or a network call, the JVM parks it and uses the underlying operating-system thread for other work. You can create millions of them.

Java
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class Virtual {
    public static void main(String[] args) {
        AtomicInteger done = new AtomicInteger();
        long start = System.currentTimeMillis();

        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                executor.submit(() -> {
                    Thread.sleep(Duration.ofSeconds(1));   // pretend to call a slow web service
                    done.incrementAndGet();
                    return null;
                });
            }
        }                                                  // close() waits for all tasks

        long ms = System.currentTimeMillis() - start;
        System.out.println("Requests finished: " + done.get());
        System.out.println("Took less than 5 seconds? " + (ms < 5000));

        Thread vt = Thread.ofVirtual().name("bill-printer").start(
                () -> System.out.println("Hello from " + Thread.currentThread().getName()
                        + ", virtual? " + Thread.currentThread().isVirtual()));
        try {
            vt.join();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
Output
Requests finished: 10000
Took less than 5 seconds? true
Hello from bill-printer, virtual? true

Ten thousand tasks that each wait one second finish in about one second in total, because every task gets its own virtual thread. With a fixed pool of 100 platform threads, the same work would take about 100 seconds. The code is ordinary, blocking, easy-to-read Java; no callbacks or special async style is needed.

Guidelines for virtual threads:

  • Use them for tasks that spend most of their time waiting (I/O): web requests, database queries, file and network access.
  • They do not make CPU-heavy work faster; for number crunching, use a pool sized to your CPU cores, as in the primes example.
  • Do not pool them. Create a new virtual thread per task; newVirtualThreadPerTaskExecutor() does exactly that.
  • Race conditions still apply: shared data needs the same care as before.

Common mistakes

  • Calling run() instead of start(). No new thread is created.
  • Sharing a plain variable between threads. Use atomic classes, synchronized, concurrent collections, or better, no shared changeable state.
  • Ignoring InterruptedException. Restore the flag with Thread.currentThread().interrupt() or pass the exception on.
  • Forgetting to shut down executors. Use try-with-resources so the program does not hang at the end.
  • Using virtual threads for CPU-bound work. They help with waiting, not with calculation.

Key points

  • A thread is an independent path of execution; start() launches it and join() waits for it.
  • Thread interleaving is unpredictable; unsynchronised shared data causes race conditions.
  • AtomicInteger, synchronized and ConcurrentHashMap make shared updates safe.
  • ExecutorService manages a pool of threads; submit returns a Future with the result.
  • Virtual threads (Java 21+) are cheap; use Executors.newVirtualThreadPerTaskExecutor() for many waiting tasks.
  • Since Java 24, synchronized no longer pins virtual threads in most cases.

Exercise

A college website must fetch results for 5 students from a slow exam server. Simulate each fetch with a method String fetchResult(String roll) that sleeps 500 ms and returns roll + ": PASS". Use a virtual-thread executor to fetch all five at the same time, collect the Futures in a list, print each result in the original order, and print whether the total time was under 1.5 seconds.

Show solution
Java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Results {
    static String fetchResult(String roll) throws InterruptedException {
        Thread.sleep(500);                       // slow exam server
        return roll + ": PASS";
    }

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        List<String> rolls = List.of("R101", "R102", "R103", "R104", "R105");
        long start = System.currentTimeMillis();

        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futures = new ArrayList<>();
            for (String roll : rolls) {
                futures.add(executor.submit(() -> fetchResult(roll)));
            }
            for (Future<String> f : futures) {
                System.out.println(f.get());
            }
        }

        long ms = System.currentTimeMillis() - start;
        System.out.println("Under 1.5 seconds? " + (ms < 1500));
    }
}
Output
R101: PASS
R102: PASS
R103: PASS
R104: PASS
R105: PASS
Under 1.5 seconds? true

All five fetches wait at the same time, so the total is about half a second instead of two and a half. Reading the futures in list order keeps the output in roll-number order, even if the tasks finish in a different order.

Quick Quiz

1. What is the difference between thread.start() and thread.run()?

2. Two threads each run count++ 1,000 times on a shared int. What can the final value be?

3. For which work do virtual threads help most?

4. What does future.get() do?

Learn Java with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call