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.
synchronizedfor 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. CompletableFuturecomposes 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.