Threads, Executors and Virtual Threads

Article · 15:00

Two threads reading and writing the same field with nothing arranged between them is a data race, and the result is not merely a wrong number - the JVM and the CPU are free to reorder. Executors over raw threads, and what virtual threads actually change.

Threads, Executors and Virtual Threads

Two threads reading and writing the same field, with nothing arranged between them, is a data race - and the result is not merely a wrong number. Without synchronisation the JVM and the CPU are free to reorder and to cache, so one thread may never see the other's write at all.

public class Race {
    static int counter = 0;                    // shared, unsynchronised

    public static void main(String[] args) throws InterruptedException {
        Thread a = new Thread(Race::bump);
        Thread b = new Thread(Race::bump);

        a.start();
        b.start();
        a.join();                              // wait for it to finish
        b.join();

        // Expected 200000. Usually less: counter++ is read, add, write - and
        // the two threads interleave inside it.
        System.out.println("counter = " + counter + " (expected 200000)");
    }

    static void bump() {
        for (int i = 0; i < 100_000; i++) counter++;
    }
}

The three ways to make that correct

import java.util.concurrent.atomic.AtomicInteger;

public class Fixed {
    private static int guarded = 0;
    private static final Object LOCK = new Object();
    private static final AtomicInteger atomic = new AtomicInteger();

    public static void main(String[] args) throws InterruptedException {
        var threads = new Thread[4];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int n = 0; n < 50_000; n++) {
                    synchronized (LOCK) {      // one thread at a time, and a
                        guarded++;             // memory barrier at each end
                    }
                    atomic.incrementAndGet();  // one hardware instruction
                }
            });
            threads[i].start();
        }
        for (Thread thread : threads) thread.join();

        System.out.println("synchronized: " + guarded);
        System.out.println("atomic:       " + atomic.get());
        System.out.println("and the third way: share nothing that changes");
    }
}

synchronized is correct and general. An Atomic* is faster for a single variable. Immutability is better than both, because there is nothing to protect - which is why the record and the final field in Level 2 matter here.

Do not manage threads by hand

An ExecutorService owns the threads and you submit work to it. A Future is the handle on a result that is not ready yet.

import java.util.*;
import java.util.concurrent.*;

public class Executors1 {
    public static void main(String[] args) throws Exception {
        try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
            List<Callable<String>> jobs = new ArrayList<>();
            for (int i = 1; i <= 4; i++) {
                int id = i;
                jobs.add(() -> {
                    Thread.sleep(100);                 // pretend this is I/O
                    return "job " + id + " done";
                });
            }

            long start = System.currentTimeMillis();
            List<Future<String>> futures = pool.invokeAll(jobs);   // all at once
            for (Future<String> future : futures) {
                System.out.println(future.get());                  // in order
            }
            long elapsed = System.currentTimeMillis() - start;
            System.out.println("four 100ms jobs took about 100ms: " + (elapsed < 350));
        }
        // try-with-resources on an ExecutorService (Java 19+) shuts it down and
        // waits. Before that, the forgotten shutdown() was why programs hung.
    }
}

CompletableFuture, for composing work rather than waiting on it

import java.util.concurrent.*;

public class Futures {
    public static void main(String[] args) throws Exception {
        CompletableFuture<String> name = CompletableFuture.supplyAsync(() -> {
            sleep(50);
            return "Ada";
        });

        CompletableFuture<Integer> score = CompletableFuture.supplyAsync(() -> {
            sleep(50);
            return 95;
        });

        // Both are already running; this composes their results.
        String summary = name.thenCombine(score, (n, s) -> n + " scored " + s)
                .thenApply(String::toUpperCase)
                .get(1, TimeUnit.SECONDS);
        System.out.println(summary);

        // A failure travels down the chain until something handles it.
        String recovered = CompletableFuture.<String>supplyAsync(() -> {
                    throw new IllegalStateException("upstream is down");
                })
                .exceptionally(e -> "fell back after: " + e.getCause().getMessage())
                .get();
        System.out.println(recovered);
    }

    static void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();      // never swallow this
            throw new IllegalStateException(e);
        }
    }
}

Note the interrupt() in that handler. Catching InterruptedException and doing nothing throws away a cancellation request the rest of the program is relying on.

Virtual threads: Java 21's answer to blocking I/O

A platform thread is an operating-system thread: about a megabyte of stack, and a few thousand is a lot. A virtual thread is scheduled by the JVM onto a small pool of carriers, and blocking one on I/O parks it instead of tying up an OS thread. So the simple blocking code you would like to write becomes the scalable version.

import java.time.Duration;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

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

        // Ten thousand threads, each blocking for 100ms. As platform threads
        // this would need ten gigabytes of stack; here it is unremarkable.
        try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                pool.submit(() -> {
                    Thread.sleep(Duration.ofMillis(100));
                    done.incrementAndGet();
                    return null;
                });
            }
        }

        System.out.println(done.get() + " tasks finished in "
                + (System.currentTimeMillis() - start) + "ms");
        System.out.println("Thread.ofVirtual() makes one directly, when you need just one");
    }
}

Virtual threads change the cost of blocking, not the rules of sharing: a data race is still a data race, and synchronized still means what it meant. What they remove is the reason to write callback-shaped code for I/O.

What to take away

  • An unsynchronised shared field is a data race: wrong values, and stale reads.
  • synchronized for general mutual exclusion; Atomic* for one variable.
  • Sharing nothing mutable beats both - immutability is a concurrency strategy.
  • Submit work to an ExecutorService; close it with try-with-resources.
  • CompletableFuture composes results instead of blocking on each in turn.
  • Restore the interrupt flag when you catch InterruptedException.
  • Virtual threads make blocking I/O cheap; they do not make sharing safe.