Java concurrency on Java 21: virtual threads, structured concurrency, CompletableFuture, executors, locks, atomic operations, thread safety. Apply when writing or reviewing concurrent Java code.
Lightweight JVM-managed threads (JEP 444) — millions fit in a process, and blocking calls park the virtual thread without blocking the OS thread.
// Run a task on a virtual thread
Thread.ofVirtual().start(() -> doWork());
// An executor that creates one virtual thread per task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
final int id = i;
executor.submit(() -> handle(id));
}
} // executor close blocks until all tasks finish
Rules of thumb:
ForkJoinPool,
Executors.newFixedThreadPool) for CPU-bound work.synchronized blocks still pin virtual threads to carriers — use
instead in hot paths.ReentrantLocktry (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<User> user = scope.fork(() -> fetchUser(id));
Future<List<Order>> orders = scope.fork(() -> fetchOrders(id));
scope.join().throwIfFailed();
return new Dashboard(user.resultNow(), orders.resultNow());
}
ShutdownOnFailure cancels siblings when any task fails.ShutdownOnSuccess returns the first successful result and cancels
the rest.CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> fetchRemote(), executor)
.thenApply(String::toUpperCase)
.exceptionally(ex -> {
log.error("remote fetch failed", ex);
return "fallback";
});
String result = future.get();
.thenApply, .thenCompose, .thenCombine.CompletableFuture.allOf or
anyOf..get() blocks. Virtual threads make this less painful.// Fixed thread pool — for CPU-bound work
ExecutorService cpuPool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
// Scheduled executor — for delayed or periodic tasks
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
scheduler.scheduleAtFixedRate(this::cleanup, 0, 1, TimeUnit.MINUTES);
// Virtual-thread-per-task — for I/O-bound work
ExecutorService ioPool = Executors.newVirtualThreadPerTaskExecutor();
try (var pool = ...). They
implement AutoCloseable as of Java 19.Executors.newCachedThreadPool() in production —
unbounded growth. Use virtual threads or a bounded ThreadPoolExecutor.Order of preference:
List.of, Map.of.ConcurrentHashMap, CopyOnWriteArrayList,
ConcurrentLinkedQueue.AtomicReference / AtomicInteger / AtomicLong — for single
fields.ReentrantLock / ReadWriteLock — for coarser critical
sections; interoperates with virtual threads.synchronized — only when the above don't fit and you're
not on the virtual-thread hot path.// Prefer this...
private final Map<String, User> users = new ConcurrentHashMap<>();
users.computeIfAbsent(id, this::loadUser);
// ...over this
private final Map<String, User> users = new HashMap<>();
synchronized (users) {
users.computeIfAbsent(id, this::loadUser);
}
AtomicLong counter = new AtomicLong();
long next = counter.incrementAndGet();
AtomicReference<Config> config = new AtomicReference<>(loadDefault());
config.updateAndGet(old -> merge(old, overrides));
AtomicLong for counters.LongAdder for high-contention counters (optimized for writes).AtomicReference for atomic updates to object references.VarHandle for lower-level atomic access when needed.final + immutable, atomic, or protected by a lock?synchronized that should be
outside?Thread.stop() — deprecated forever, undefined behaviour.Thread.sleep() for synchronization — use CountDownLatch,
Phaser, or await primitives.new Thread() scattered throughout the code — use an
executor.synchronized on a mutable public field — clients can bypass
the lock.volatile or
AtomicReference.InterruptedException and swallowing it — always
re-set Thread.currentThread().interrupt() if you can't propagate.for tool in java javac gradle; do
command -v "$tool" >/dev/null && echo "ok: $tool" || echo "MISSING: $tool"
done
java.util.concurrent: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/package-summary.html