This chapter extends the concurrency items in Effective Java with fifteen items
for JDK 13+ programs targeting JDK 25. Each item treats the modern API as a
design tool, not as a novelty. The examples are intentionally small: a good
concurrency example should make the ownership boundary visible. Item 95 uses a
JDK 25 preview API and therefore requires --enable-preview.
The companion class
src/main/java/com/modern/effective/java/concurrency/ConcurrencyTheoryValidation.java
contains runnable checks for Items 85-99. It is not a benchmark suite; it
validates the ownership and API claims in this chapter with small deterministic
programs.
A virtual thread is still a Java Thread, but it is not an operating-system
thread. The JDK schedules many virtual threads over a smaller set of platform
threads. That distinction is the whole point: virtual threads are cheap enough
to dedicate one thread to one blocking task, but they do not make CPU cores
multiply.
The tempting mistake is to treat virtual threads as a universal replacement for all executors. Prime counting is a useful example because it is visibly CPU-bound: each candidate number is tested by arithmetic in a tight loop, and the task does not spend meaningful time blocked on I/O, locks, timers, or queues.
// Anti-pattern: virtual threads do not make prime counting CPU-free
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Range range : splitIntoTinyRanges(2, 2_000_000)) {
executor.submit(() -> countPrimes(range.start(), range.end()));
}
}This code may run correctly, but it is not a better CPU scheduler. When the work spends nearly all of its time running Java code, the limiting resource is the number of available processors. A bounded platform-thread executor remains the right shape for that work:
// Correct: bound CPU work to available processors
int workers = Runtime.getRuntime().availableProcessors();
List<Range> ranges = splitIntoRanges(2, 2_000_000, workers);
List<Future<Long>> counts = new ArrayList<>();
try (var executor = Executors.newFixedThreadPool(workers)) {
for (Range range : ranges) {
counts.add(executor.submit(() -> countPrimes(range.start(), range.end())));
}
long total = 0;
for (Future<Long> count : counts) {
total += count.get();
}
}The point is not that prime generation requires platform threads specifically. The point is that it consumes CPU while it runs. If you submit ten thousand prime-counting tasks to ten thousand virtual threads, only a small number can be executing Java arithmetic at the same instant. The remaining tasks are merely waiting their turn for a carrier and a core. A fixed pool sized near the number of processors makes the bottleneck honest.
Blocking I/O has a different shape. A request handler that waits on a socket, database call, file read, queue take, or remote service spends much of its life parked. This is where virtual threads simplify design:
// Correct: one blocking request per virtual thread
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Socket socket : sockets) {
executor.submit(() -> handleBlocking(socket));
}
}Use virtual threads to preserve the simple thread-per-task style for blocking work. Keep the body of the task straightforward: call blocking APIs directly, return a result, and let the executor own the thread lifetime (Item 87). Avoid rewriting blocking code into callback chains merely to save platform threads.
The JDK 25 Thread source says virtual threads are suitable for tasks that
spend most of their time blocked, often waiting for I/O. It also says they use a
small set of platform threads as carriers. That is the design boundary. Virtual
threads improve the cost of waiting, not the cost of computation.
There are counterexamples. A task can mix blocking I/O with CPU work; split the work if the CPU phase becomes dominant. A native call or foreign-function call may have different scheduling behavior (Item 90). And a library that already uses asynchronous I/O internally may not need a virtual thread wrapper. The rule is not "always use virtual threads"; it is "do not spend platform threads just to wait."
In summary, prefer virtual threads for blocking I/O, and keep CPU-bound work on bounded platform-thread executors. Virtual threads give you more waiting concurrency, not more processors.
Sources: JEP 444,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/lang/Thread.java:102.
Thread pools were a workaround for expensive platform threads. If every thread costs a large native stack and an operating-system scheduling resource, then a program must reuse a small number of them. Virtual threads remove that pressure. Pooling them reintroduces scarcity where the API deliberately removed it.
// Broken! A virtual-thread pool is the old design in new clothing
ExecutorService executor = Executors.newFixedThreadPool(
100,
Thread.ofVirtual().name("worker-", 0).factory());The problem is not that this code creates virtual threads. The problem is that it limits concurrency by thread count rather than by the real constrained resource. If the scarce resource is a database connection, limit database connections. If it is an external service, rate-limit that service. Do not use a virtual-thread pool as a hidden semaphore.
// Correct: one virtual thread per task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request request : requests) {
executor.submit(() -> handle(request));
}
}Create a new virtual thread for each task; never pool virtual threads. The
JDK supplies Executors.newVirtualThreadPerTaskExecutor() for exactly this
style. In the JDK source, that factory delegates to
newThreadPerTaskExecutor(Thread.ofVirtual().factory()), making the design
literal: every submitted task gets its own thread.
When you need a concurrency limit, make the limited thing explicit:
// Correct: limit the database, not virtual threads
Semaphore databasePermits = new Semaphore(32);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request request : requests) {
executor.submit(() -> {
databasePermits.acquire();
try {
queryDatabase(request);
return null;
} finally {
databasePermits.release();
}
});
}
}This code says what the program means. It permits many request tasks to exist, but only thirty-two of them may be in the database at once. That distinction is important for debugging and for future maintenance. A "pool of 32 virtual threads" would obscure whether the limit protects the database, the network, a CPU phase, or an old habit.
There are two useful boundaries. First, naming virtual threads is still useful when they represent important work (Item 96). Second, a thread-per-task executor must still be closed (Item 87). Cheap threads are not free resources in the sense that their tasks, queues, failures, and cancellation can be ignored.
In summary, do not pool virtual threads. Create one virtual thread per task, and express backpressure with semaphores, rate limiters, connection pools, or other limits tied to the resource that is actually scarce.
Sources: JEP 444,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/concurrent/Executors.java:248,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/concurrent/Executors.java:263.
An executor is an owner of work. If a method creates one and then returns
without closing it, the method has leaked ownership. Modern Java makes this
mistake easier to avoid because ExecutorService extends AutoCloseable.
// Broken! The executor lifetime is not bounded
void refreshAll(List<URI> uris) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (URI uri : uris) {
executor.submit(() -> refresh(uri));
}
}The method creates a resource and then abandons it. Some tasks may still be running, exceptions may be lost, and the caller cannot tell when the refresh is finished. This is not merely untidy. It destroys the lifetime relationship between the caller and the work it requested.
// Correct: the lexical block owns the executor
void refreshAll(List<URI> uris) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (URI uri : uris) {
executor.submit(() -> refresh(uri));
}
}
}Close every executor whose lifetime is local to a method or scope. In JDK
25, ExecutorService.close() initiates orderly shutdown and waits for
termination. If interrupted while waiting, it behaves as if shutdownNow() were
called, continues waiting for active tasks to complete, and reasserts the
interrupt status before returning. That behavior is exactly why try-with-
resources is a better default than scattered shutdown code.
This rule pairs naturally with virtual threads. A virtual-thread-per-task executor is often cheap enough to create around a short burst of blocking work. That does not make it disposable in the garbage-collector sense. Its lifetime should still be visible in the program text.
Use a longer-lived executor when the application architecture calls for one: for example, a service-owned CPU pool, a scheduler, or a shared infrastructure executor. In that case, the service or application component must own closing. The rule is not "create an executor everywhere"; it is "whoever creates the executor must define when it ends."
In summary, put locally owned executors in try-with-resources. Executor lifetime is part of concurrent correctness; if the owner is not visible, task completion, cancellation, and exception reporting will not be visible either.
Sources:
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/concurrent/ExecutorService.java:149,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/concurrent/ExecutorService.java:373.
Unstructured concurrency scatters ownership. One method starts tasks, another waits for them, a third cancels them, and a fourth discovers that one failed. This is how small concurrent features become shutdown bugs.
// Broken! The subtasks outlive the structure of the method
Response handle(Request request, ExecutorService executor) throws Exception {
Future<User> user = executor.submit(() -> loadUser(request));
Future<List<Order>> orders = executor.submit(() -> loadOrders(request));
return new Response(user.get(), orders.get());
}If loadUser fails quickly, loadOrders may continue even though its result is
no longer useful. If the caller is interrupted, the relationship between caller
and subtasks depends on surrounding code. The method reads like a sequential
function, but its work escapes.
JDK 25 previews StructuredTaskScope, with open, fork, and join as the
basic shape:
// Modern: related subtasks are owned by one lexical scope
import java.util.concurrent.StructuredTaskScope;
Response handle(Request request) throws Exception {
try (var scope = StructuredTaskScope.open()) {
StructuredTaskScope.Subtask<User> user =
scope.fork(() -> loadUser(request));
StructuredTaskScope.Subtask<List<Order>> orders =
scope.fork(() -> loadOrders(request));
scope.join();
return new Response(user.get(), orders.get());
}
}Use structured concurrency when concurrent subtasks are part of one logical operation. The caller can see the lifetime of the child tasks because it is the lifetime of the try-with-resources block. The owner forks, joins, retrieves results, and closes the scope in one place.
The JDK 25 source shows that StructuredTaskScope.open() creates a scope whose
join() waits for subtasks according to a Joiner. The default open form uses
a joiner for the common all-successful case. Richer policies, such as "first
successful result wins," are expressed by opening the scope with a different
Joiner:
// Modern: first successful answer wins
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<Quote>anySuccessfulResultOrThrow())) {
scope.fork(() -> quoteFrom(primary));
scope.fork(() -> quoteFrom(secondary));
return scope.join();
}Do not hide business logic inside custom joiners. A joiner should state the
completion policy: all successful, any successful, all until a predicate, and so
on. The application code should still make application decisions. Also remember
that this is a preview API in JDK 25, so it requires --enable-preview and may
change in later JDKs.
In summary, use StructuredTaskScope to keep related concurrent work within
one owner, one join point, and one cancellation boundary. If a subtask cannot
be explained as part of the caller's operation, it probably should not be forked
inside that operation.
Sources: JEP 505,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/concurrent/StructuredTaskScope.java:42,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/concurrent/StructuredTaskScope.java:914.
ThreadLocal is a mutable map attached to a thread. That makes it powerful, but
it also makes lifetime and data flow hard to see. A value can be set in one
method, changed in another, forgotten in a pool thread, and observed later by
unrelated code.
// Broken! Hidden mutable context with manual cleanup
final class RequestContext {
static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
static Response serve(Request request) {
CURRENT_USER.set(authenticate(request));
try {
return Application.handle(request);
} finally {
CURRENT_USER.remove();
}
}
}The finally block is necessary, but the need for it is the smell. The context
has no syntactic lifetime. Every method that can reach the ThreadLocal can
change it.
JDK 25 finalizes ScopedValue for the common case where data should flow in one
direction, from caller to callees, for a bounded dynamic scope:
// Correct: bounded one-way context
final class RequestContext {
private static final ScopedValue<User> CURRENT_USER =
ScopedValue.newInstance();
static Response serve(Request request) {
User user = authenticate(request);
return ScopedValue.where(CURRENT_USER, user)
.call(() -> Application.handle(request));
}
static User currentUser() {
return CURRENT_USER.get();
}
}Prefer ScopedValue when context is written once by the caller and read by
callees. The lifetime is the body passed to run or call. When that body
returns or throws, the binding is gone. There is no set method for distant
code to mutate the value.
ScopedValue also composes with structured concurrency. The JDK 25 source says
bindings are captured when a StructuredTaskScope is created and inherited by
threads started with fork. That is the safe cross-thread case: child work is
bounded by the parent scope (Item 88).
// Correct: ScopedValue inherited by structured subtasks
return ScopedValue.where(CURRENT_USER, user).call(() -> {
try (var scope = StructuredTaskScope.open()) {
var profile = scope.fork(() -> loadProfile());
var orders = scope.fork(() -> loadOrders());
scope.join();
return new Page(profile.get(), orders.get());
}
});Do not use a scoped value as a global variable with better syntax. Keep the key private or tightly controlled; possession of the key is the capability to read the value. The value should be immutable, or access to it must be synchronized, especially when shared into child threads.
In summary, use ScopedValue for bounded, one-way context transmission.
Keep ThreadLocal for cases that truly require mutable per-thread state, and
be explicit about cleanup when you do.
Sources: JEP 506,
JDK 25 ScopedValue,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/lang/ScopedValue.java:138.
Virtual-thread pinning is not a synonym for "using synchronized." It is a
scheduling condition in which a virtual thread cannot unmount from its carrier
platform thread while blocked. In JDK 21-23, blocking inside a synchronized
method or statement was one of the central causes. In JDK 24 and JDK 25, JEP
491 changes the implementation so virtual threads can block in synchronized
constructs without tying up their carriers in nearly all cases.
A virtual thread normally runs by mounting onto a carrier. A carrier is an ordinary platform thread, owned by the JDK scheduler, that temporarily executes the virtual thread's Java code. Your application does not create or manage carrier threads directly; it creates virtual threads, and the runtime assigns them to carriers when they are ready to run.
For example, suppose a server has many virtual threads handling requests. When
virtual thread request-17 is computing a response, the scheduler may mount it
on carrier platform thread ForkJoinPool-1-worker-3. If request-17 then
performs a cooperative blocking operation, such as Thread.sleep(Duration) or a
JDK socket read, the runtime can unmount request-17: the virtual thread is
parked, and ForkJoinPool-1-worker-3 is free to run another virtual thread,
such as request-18.
A pin is the condition that prevents that unmount. Pinning is the period
during which the carrier remains occupied by the blocked virtual thread. If
request-17 blocks while pinned, ForkJoinPool-1-worker-3 cannot be reused for
another virtual thread until the blocking operation completes or the pinning
condition ends.
This distinction prevents three common misunderstandings. First, pinning is not the same thing as deadlock; the program may still make progress. Second, pinning is not the same thing as owning a monitor; what matters is whether the virtual thread blocks while it cannot unmount. Third, pinning is not a CPU-speed problem; it is a carrier-availability problem. A short critical section that runs to completion is not the concern. A virtual thread that blocks for a long time while pinned can reduce the number of carriers available to other virtual threads.
The practical question is therefore not "does this code contain a synchronized block?" The practical question is "can this virtual thread block while mounted in a way the scheduler cannot unmount?" On JDK 25, ordinary synchronized blocking is no longer the broad answer it was in JDK 21-23. Remaining pinning cases are more likely to involve native code, foreign-function calls, or other runtime boundaries where the scheduler cannot safely detach the virtual thread from its carrier.
This matters because old advice can become wrong advice:
// Old JDK 21-23 migration rule: often used to avoid synchronized pinning
private final ReentrantLock lock = new ReentrantLock();
void update() {
lock.lock();
try {
mutateInMemoryState();
} finally {
lock.unlock();
}
}The code is fine, but in JDK 25 avoiding synchronized pinning is no longer the
main reason to write it. JEP 491 says that after the synchronized change, choose
between synchronized and java.util.concurrent.locks based on which solves
the problem at hand. That restores the older design rule: use synchronized
where it is practical and use explicit locks when you need their extra powers.
The ReentrantLock source makes those powers concrete. It describes the class
as having the same basic behavior and semantics as intrinsic monitor locks, but
with extended capabilities. Those capabilities include optional fairness,
interruptible acquisition, timed acquisition, conditions, and instrumentation
methods:
// Correct: ReentrantLock because acquisition is interruptible and timed
private final ReentrantLock lock = new ReentrantLock();
boolean tryUpdate(Duration timeout) throws InterruptedException {
if (!lock.tryLock(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
return false;
}
try {
mutateInMemoryState();
return true;
} finally {
lock.unlock();
}
}In JDK 25, choose ReentrantLock for its semantics, not out of stale fear of
synchronized. If you need timed lock acquisition, interruptible acquisition,
multiple wait sets via Condition, fairness, or lock state inspection, use
ReentrantLock. If you need a small private critical section, synchronized
remains concise and hard to misuse.
The companion validation deliberately checks ReentrantLock features rather
than pinning folklore: a fair lock exposes its fairness policy, tryLock can
time out, and lockInterruptibly lets a waiting virtual thread respond to
interruption. Those are semantic reasons to choose the explicit lock API.
That does not mean pinning has disappeared. JEP 491 retains diagnostics for
remaining pinning cases, especially involving native code or foreign-function
calls that call back into Java and then block. JFR still exposes
jdk.VirtualThreadPinned, and JDK 25's jfr metadata lists virtual-thread
events including jdk.VirtualThreadStart, jdk.VirtualThreadEnd,
jdk.VirtualThreadPinned, and jdk.VirtualThreadSubmitFailed (Item 91).
Use this decision matrix:
| Situation | Prefer |
|---|---|
| Small private in-memory critical section | synchronized |
| Timed or interruptible acquisition | ReentrantLock |
| Multiple condition queues | ReentrantLock.newCondition() |
| Need fairness policy | new ReentrantLock(true) |
| Suspected pinning in JDK 25 | JFR observation first |
| Native or foreign blocking path | Measure with JFR and isolate the call |
The most important source-level rule for ReentrantLock is still mechanical:
the source documentation recommends calling lock() immediately before a
try block and unlock() as the first statement in finally. With explicit
locks, the compiler will not release the lock for you.
In summary, observe pinning with JFR, and choose locking APIs for their
semantic differences. On JDK 25, replacing every synchronized block with
ReentrantLock is not modern; it is cargo-cult migration from an older runtime.
Sources: JEP 444,
JEP 491,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/concurrent/locks/ReentrantLock.java:39,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/concurrent/locks/ReentrantLock.java:158,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/concurrent/locks/ReentrantLock.java:478,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/concurrent/locks/ReentrantLock.java:537.
Concurrency failures are often invisible to ordinary logs. A request is slow, but no exception is thrown. A virtual thread is blocked, but the stack trace is gone by the time anyone looks. A pool is saturated, but the code that submitted the work has returned. Java Flight Recorder is the standard observability tool for these cases because it records runtime events with low overhead.
Use JFR before rewriting concurrency code on suspicion. Start with the
events that answer the design question. For virtual threads, JDK 25 metadata
includes jdk.VirtualThreadStart, jdk.VirtualThreadEnd,
jdk.VirtualThreadPinned, and jdk.VirtualThreadSubmitFailed. For monitor and
parking behavior, it includes events such as jdk.JavaMonitorEnter,
jdk.JavaMonitorWait, jdk.ThreadPark, and jdk.ThreadSleep.
There are four useful collection modes:
| Mode | Use when |
|---|---|
| JVM startup recording | You can reproduce from process start |
jcmd recording |
The process is already running |
Programmatic Recording |
The application owns the diagnostic workflow |
RecordingStream |
Tests or tools need live event callbacks |
Command-line recordings are the least invasive:
# Observable: record from process start
java -XX:StartFlightRecording=filename=app.jfr,dumponexit=true \
--enable-preview \
com.example.Main
# Observable: inspect virtual-thread events later
jfr print --events jdk.VirtualThreadPinned,jdk.VirtualThreadSubmitFailed app.jfrFor a running process, use jcmd:
# Observable: start, dump, and stop a recording from outside the process
jcmd <pid> JFR.start name=concurrency settings=profile
jcmd <pid> JFR.dump name=concurrency filename=concurrency.jfr
jcmd <pid> JFR.stop name=concurrencyFor tests and local diagnostics, RecordingStream gives a precise pattern:
// Observable: fail a test if pinning appears
import java.util.concurrent.atomic.AtomicBoolean;
import jdk.jfr.consumer.RecordingStream;
AtomicBoolean pinned = new AtomicBoolean();
try (var stream = new RecordingStream()) {
stream.enable("jdk.VirtualThreadPinned").withoutThreshold();
stream.onEvent("jdk.VirtualThreadPinned", event -> pinned.set(true));
stream.startAsync();
runConcurrencyScenario();
stream.stop();
}
if (pinned.get()) {
throw new AssertionError("Pinned virtual thread observed");
}The counterpoint is volume. JFR is not a license to record every event at full
detail forever. Use settings, thresholds, max age, max size, and event filters.
The JDK 25 jfr command can print, summarize, and display metadata for
recordings, which means the same file can serve both humans and scripts.
Custom events are useful when JVM events need application meaning:
// Observable: application-level event correlated with JVM events
@jdk.jfr.Name("app.BatchStarted")
@jdk.jfr.Label("Batch Started")
final class BatchStarted extends jdk.jfr.Event {
@jdk.jfr.Label("Batch Id")
String batchId;
}The same pattern can be validated without writing a recording file. Start a
RecordingStream, enable the event name, commit an event, and wait for the
callback:
// Observable: live callback for a custom event
AtomicBoolean observed = new AtomicBoolean();
try (var stream = new RecordingStream()) {
stream.enable("app.BatchStarted").withoutThreshold();
stream.onEvent("app.BatchStarted", event -> observed.set(true));
stream.startAsync();
BatchStarted event = new BatchStarted();
event.batchId = "batch-42";
event.commit();
awaitObservation(observed);
stream.stop();
}Do not use custom events as logging with a different API. Use them for durable diagnostic facts: a request class, a resource name, a retry count, a queue length, or a cancellation reason. Keep sensitive data out of recordings, and scrub before sharing.
In summary, treat JFR as the concurrency microscope for modern Java. Use it to decide whether pinning, parking, monitor contention, blocking, or thread lifecycle is actually the problem before changing the design.
Sources: JDK 25 jfr command,
JDK 25 RecordingStream,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/bin/jfr metadata.
Polling a Future is usually a sign that a program has confused completion
with retrieval. Calling get() can block; calling it in a result-draining loop
can accidentally serialize work that was supposed to be concurrent.
// Broken! The first incomplete future stalls the whole drain
for (Future<Result> future : futures) {
try {
publish(future.get());
} catch (ExecutionException failure) {
report(failure.getCause());
}
}JDK 19 added a small but important inspection API to Future: state(),
resultNow(), and exceptionNow(). The intent is visible in the JDK source
example, which filters successful futures and maps them with Future::resultNow.
// Modern: drain only futures that are already complete
for (Future<Result> future : futures) {
switch (future.state()) {
case SUCCESS -> publish(future.resultNow());
case FAILED -> report(future.exceptionNow());
case CANCELLED -> countCancellation();
case RUNNING -> {
// leave it for a later pass
}
}
}Use resultNow() and exceptionNow() only after the state proves the result
is available. These methods are not nonblocking versions of get() for
unfinished work. They are retrieval methods for code that already knows the
future's completion state.
This API is most useful at boundaries: draining a set of futures after a join point, integrating with an event loop, collecting partial results, or writing tests that should not block. It is not a replacement for structured concurrency (Item 88), and it does not cancel unfinished work for you.
There is a simple decision table:
| Need | Use |
|---|---|
| Wait for one result | get, join, or a structured scope |
| Inspect without blocking | state() |
| Retrieve known success | resultNow() |
| Retrieve known failure | exceptionNow() |
| Cancel unfinished work | cancel or the owner scope |
In summary, separate completion inspection from result retrieval. Use
state() to decide what is available, then use resultNow() or
exceptionNow() only for futures that have already completed in the matching
state.
Sources:
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/concurrent/Future.java:174,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/concurrent/Future.java:188,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/concurrent/Future.java:251.
Time and identity are easy to get almost right. A timeout passed as a naked
long loses its unit. A thread identifier read with an overridable method is
not quite the thread ID. Old thread-control methods promise force but deliver
broken invariants.
// Old: what unit is 500?
Thread.sleep(500);
// Old: getId is deprecated as of JDK 19
long id = Thread.currentThread().getId();Modern Java gives the clearer forms:
// Modern: unit is part of the value
Thread.sleep(Duration.ofMillis(500));
// Modern: final thread identifier method
long id = Thread.currentThread().threadId();Prefer APIs that put units and identity into the type system. JDK 19 added
Thread.sleep(Duration), Thread.join(Duration), and Thread.threadId(). The
JDK 25 Thread source deprecates getId() because it is not final and may be
overridden to return something other than the thread ID.
This item also closes the door on the old control methods. Do not design with
Thread.stop, suspend, or resume. They are not cancellation APIs. They are
ways to violate invariants by stopping or freezing a thread at an arbitrary
point. Cooperative interruption is the cancellation protocol (Item 98), and
structured ownership is the better design for related tasks (Item 88).
Duration is not magic. A negative sleep duration is a no-op in Thread.sleep,
which may be exactly what you want or a bug you should reject earlier. Very
large durations are subject to conversion and scheduler realities. Use
Duration because it communicates intent, not because it removes the need to
think about clocks.
In summary, make time units explicit with Duration, use threadId() for
thread identity, and do not use obsolete forceful thread-control methods.
Modern concurrency code should make waiting, identity, and cancellation visible
in the API it calls.
Sources:
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/lang/Thread.java:594,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/lang/Thread.java:1989,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/lang/Thread.java:2293.
Randomness is state. Sharing one mutable generator across parallel work can create contention, hidden ordering, or statistical mistakes. Modern Java's random API gives the generator shape a name, so choose the shape that matches the computation.
// Anti-pattern: one generator becomes shared state in parallel work
RandomGenerator random = RandomGenerator.getDefault();
List<Point> points = IntStream.range(0, workers)
.parallel()
.mapToObj(i -> samplePoint(random))
.toList();JEP 356 introduced RandomGenerator and specialized interfaces including
SplittableGenerator. Splitting is designed for parallelism: derive multiple
generators from one root so each worker can use its own generator.
// Correct: split one root into independent worker generators
import java.util.random.RandomGenerator;
int workers = Runtime.getRuntime().availableProcessors();
RandomGenerator.SplittableGenerator root =
RandomGenerator.SplittableGenerator.of("L64X128MixRandom");
List<Double> samples = root.splits(workers)
.parallel()
.map(rng -> monteCarloSlice(rng, 100_000))
.toList();Use a splittable generator when parallel tasks need independent streams of
pseudorandom values. The JDK 25 RandomGenerator source describes
SplittableGenerator as producing generators by splitting, with stream
methods such as splits() and splits(long). That is a better model than
sharing a single mutable generator across workers.
The counterpoint is security. RandomGenerator is for pseudorandom simulation,
testing, randomized algorithms, sampling, and load distribution. It is not a
cryptographic API. Use SecureRandom where unpredictability is a security
requirement.
Reproducibility is another boundary. If tests need deterministic results, choose the algorithm and seed explicitly. If production code only needs a good default, the factory can select an algorithm by name or by capabilities. Do not let a parallel stream hide the generator policy from the caller.
In summary, pick a RandomGenerator whose shape matches the computation.
For parallel streams, that usually means a SplittableGenerator; for security,
it means not using this API at all.
Sources: JEP 356,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/random/RandomGenerator.java:91,
/opt/homebrew/opt/java/libexec/openjdk.jdk/Contents/Home/lib/src.zip!/java.base/java/util/random/RandomGenerator.java:1075.
Lazy initialization is often introduced as a performance trick, but in concurrent code it is more often a correctness trap. The value should appear only once, all readers should observe the initialized state, and failed initialization should not leave the object in a half-published form. The old idiom is easy to write and hard to get right:
// Broken! Racy lazy initialization
final class ClientRegistry {
private ExpensiveClient client;
ExpensiveClient client() {
if (client == null) {
client = ExpensiveClient.connect();
}
return client;
}
}The bug is not that two clients may be created, though that may be expensive.
The deeper bug is publication: without synchronization, the assignment is not a
safe handoff to other threads. Variants using double-checked locking improve
the situation only if all details are correct, including volatile.
JDK 25 introduces java.lang.StableValue as a preview API for values that are
set at most once. Its higher-level supplier factory gives the common lazy
case a small and explicit shape:
// Modern: preview API, compile and run with --enable-preview
import java.lang.StableValue;
import java.util.function.Supplier;
final class ClientRegistry {
private final Supplier<ExpensiveClient> client =
StableValue.supplier(ExpensiveClient::connect);
ExpensiveClient client() {
return client.get();
}
}Use StableValue when the object is logically final but should be computed
only on demand. The API expresses the invariant directly: once the value is
successfully computed, it never changes. The JDK 25 API specifies that
StableValue.supplier invokes the underlying supplier successfully at most
once, even when multiple threads race to call get.
This is not a replacement for every cache. A stable value has no eviction policy, and its contents are strongly reachable while the stable value is reachable. It is a poor fit for request data, user data, large bounded caches, or values that should be refreshed. It is a good fit for lazily initialized application services, lookup tables, immutable metadata, and derived constants whose inputs are fixed for the lifetime of the holder.
// Observable sample: the initializer runs once
import java.lang.StableValue;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
public class StableValueOnce {
private static final AtomicInteger starts = new AtomicInteger();
private static final Supplier<String> TOKEN =
StableValue.supplier(() -> "token-" + starts.incrementAndGet());
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
executor.submit(() -> System.out.println(TOKEN.get()));
}
}
System.out.println("initializations = " + starts.get());
}
}There are two important counterpoints. First, StableValue is a preview API in
JDK 25, so production use must account for --enable-preview and possible API
change. Second, ordinary final fields are still better when eager
initialization is cheap and unconditional. A stable value is not more correct
than a final field; it is a way to keep final-like reasoning when initialization
must be delayed.
In summary, prefer StableValue for one-time lazy initialization of values
that are logically final. Use it for immutable, long-lived state, not for
mutable caches, request context, or values whose lifetime is shorter than the
owning object.
Sources: JEP 502,
JDK 25 StableValue.
Threads are part of the diagnostic surface of a program. Their names appear in stack traces, debuggers, logs, JFR events, and thread dumps. In modern Java, a thread is also an inheritance boundary: inheritable thread-local values and context class loaders may be copied from the creating thread unless you say otherwise.
The default factory is convenient, but convenience is not a design:
// Anti-pattern: anonymous ownership and accidental inheritance
var executor = java.util.concurrent.Executors.newFixedThreadPool(8);The threads created by this executor will have generic names. Worse, the code does not document whether inheritable thread-local state should cross into the worker. In server code, that omission can turn a request-scoped accident into a long-lived leak.
JDK 21 introduced Thread.Builder, and JDK 25 keeps it as the central way to
make thread construction explicit:
// Modern: named platform workers with inheritance disabled
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
ThreadFactory factory = Thread.ofPlatform()
.name("indexer-", 0)
.inheritInheritableThreadLocals(false)
.uncaughtExceptionHandler((thread, failure) ->
System.err.println(thread.getName() + " failed: " + failure))
.factory();
try (var executor = Executors.newFixedThreadPool(8, factory)) {
executor.submit(() -> rebuildIndex());
}The same rule applies to virtual threads. A virtual thread should still be named when its work is important enough to diagnose:
// Modern: named virtual threads, still one per task
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
ThreadFactory factory = Thread.ofVirtual()
.name("fetch-", 0)
.inheritInheritableThreadLocals(false)
.factory();
try (var executor = Executors.newThreadPerTaskExecutor(factory)) {
executor.submit(() -> fetchBlockingResource());
}Make thread naming and inherited context deliberate at the factory boundary.
This is not cosmetic. A clear name tells you which subsystem is blocked or
failing. Disabling inheritable thread-local values says that context must be
passed intentionally, for example with method parameters, structured task
ownership, or ScopedValue (Item 89).
The JDK 25 Thread.Builder documentation makes two details worth preserving in
the chapter. The builder itself is not thread-safe, but the ThreadFactory
created by factory() is safe for concurrent use. Also, the default is to
inherit inheritable thread-local values. That default is a compatibility choice,
not a recommendation for application architecture.
The simplest way to see the risk is to put a value such as "secret" in an
InheritableThreadLocal, then create a thread with
inheritInheritableThreadLocals(false). The worker should see the deliberate
thread name and should not see the inherited value. That is the boundary a
thread factory should make explicit.
Do not put secrets, user identifiers, access tokens, or high-cardinality request
data in thread names. Names are for operational identity, not business data.
Prefer stable subsystem names such as billing-fetch-, indexer-, or
thumbnail-. For per-request data, use logs and scoped context.
In summary, build thread factories explicitly, name the work they own, and disable inherited context unless inheritance is part of the design. Good thread construction pays for itself the first time a dump or JFR recording must explain a production failure.
Sources: JDK 25 Thread.Builder,
JDK 25 Thread.
CompletableFuture looks like a value pipeline, but every asynchronous stage is
also a scheduling decision. If the code does not name an executor, the JDK must
choose one. That implicit choice is often invisible until the application is
under load.
// Broken! Blocking work leaks into the default async executor
CompletableFuture.supplyAsync(() -> readFromSocket())
.thenApplyAsync(bytes -> parse(bytes))
.thenAcceptAsync(record -> writeToDatabase(record));The JDK 25 CompletableFuture specification says that async methods without an
explicit Executor use the default asynchronous execution facility, and the
standard implementation uses the ForkJoinPool.commonPool() for many such
operations. That is acceptable for small nonblocking CPU work. It is a poor
default for blocking I/O, slow continuations, or application code whose
diagnostics should identify the subsystem that ran it.
// Correct: the scheduling policy is part of the pipeline
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
try (var blocking = Executors.newVirtualThreadPerTaskExecutor();
var cpu = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors())) {
CompletableFuture<Void> pipeline = CompletableFuture
.supplyAsync(() -> readFromSocket(), blocking)
.thenApplyAsync(bytes -> parse(bytes), cpu)
.thenAcceptAsync(record -> writeToDatabase(record), blocking);
pipeline.join();
}Pass an explicit executor whenever a CompletableFuture stage has a
meaningful scheduling policy. Blocking stages belong on an executor designed
for blocking, which usually means virtual threads in modern Java (Item 85).
CPU-bound stages belong on bounded platform-thread executors. Continuations
that must run near the completing thread can often use non-async methods such as
thenApply, but that is also a deliberate choice.
This item is not an argument against CompletableFuture. It is an argument
against hiding scheduler ownership. A pipeline with explicit executors tells the
reader which stages may block, which stages consume CPU, and which resources
must be closed (Item 87).
There are valid counterexamples. A tiny nonblocking transformation can use the
default async executor if it has no subsystem-specific scheduling needs. A
library may accept an executor from its caller rather than creating one. Code
already running inside a structured task may be clearer without
CompletableFuture at all (Item 88). If you must block inside a ForkJoinPool
task, ForkJoinPool.ManagedBlocker exists for managed blocking, but it is a
specialist tool, not a substitute for choosing the right executor.
In summary, do not let CompletableFuture choose important execution policy
by accident. Make executor choice visible at the stage that needs it, and make
blocking, CPU work, and resource ownership obvious in the code.
Sources: JDK 25 CompletableFuture,
JDK 25 ForkJoinPool.ManagedBlocker.
Java does not cancel a thread by destroying it. It asks the thread to stop by interrupting it. That design is why interruption must be treated as part of a method's contract, not as a nuisance exception to catch and ignore.
// Broken! Lost interrupt; shutdown may never complete
final class Worker implements Runnable {
private final java.util.concurrent.BlockingQueue<Job> queue;
Worker(java.util.concurrent.BlockingQueue<Job> queue) {
this.queue = queue;
}
@Override
public void run() {
for (;;) {
try {
process(queue.take());
} catch (InterruptedException ignored) {
// keep going
}
}
}
}The catch block clears the interruption and hides it. If an executor calls
shutdownNow, or a higher-level owner cancels the work, this task refuses the
protocol. The program now has a cancellation bug, not an exception-handling
detail.
If a method can declare InterruptedException, let it. If it cannot, restore
the interrupt status and finish promptly:
// Correct: interruption ends the task
final class Worker implements Runnable {
private final java.util.concurrent.BlockingQueue<Job> queue;
Worker(java.util.concurrent.BlockingQueue<Job> queue) {
this.queue = queue;
}
@Override
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
process(queue.take());
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} finally {
closeWorkerState();
}
}
}Treat interruption as cooperative cancellation and preserve it unless you
complete the cancellation yourself. The rule has two halves. Propagate
InterruptedException when your method signature allows it. Restore the status
with Thread.currentThread().interrupt() when you must catch it in a method
that cannot throw it, such as Runnable.run.
The distinction between Thread.interrupted() and isInterrupted() matters.
Thread.interrupted() is static and clears the current thread's interrupt
status. isInterrupted() observes without clearing. Clearing is occasionally
useful, but it should be an explicit decision. A loop condition almost always
wants observation, not accidental erasure.
This rule also explains the limits of executor shutdown. The JDK 25
ExecutorService documentation says shutdownNow() makes a best-effort attempt
to stop actively executing tasks, typically by interrupting them, but tasks that
fail to respond may never terminate. Structured cancellation, executor closing,
and manual Future.cancel(true) all depend on the same discipline.
Some operations are not interruptible, and some I/O libraries translate cancellation into other exceptions. That does not weaken the rule; it tells you where to put timeouts, close underlying resources, or choose APIs with explicit cancellation. The wrong response is swallowing the interrupt and continuing as if the owner had not asked the task to stop.
In summary, interruption is the cancellation protocol of Java threads. Propagate it when you can, restore it when you cannot, and write blocking tasks so their owners can actually stop them.
Sources: JDK 25 Thread,
JDK 25 InterruptedException,
JDK 25 ExecutorService.
The object used as a monitor is part of a class's synchronization design. If the object is public, shared, interned, pooled, value-based, or otherwise not under your control, the lock is not under your control either.
// Broken! Integer is value-based and may be shared
final class Counter {
private final Integer lock = 0;
private int value;
void increment() {
synchronized (lock) {
value++;
}
}
}This code is wrong even before virtual threads enter the discussion. The lock object is a primitive wrapper. JEP 390 designates primitive wrapper classes as value-based and added warnings for synchronization on value-based objects. A value-based object should be treated as identity-free; using it as a monitor depends on the very identity the class tells you not to rely on.
Use a private identity object when intrinsic locking is the right tool:
// Correct: the monitor is private identity state owned by the class
final class Counter {
private final Object lock = new Object();
private int value;
void increment() {
synchronized (lock) {
value++;
}
}
int value() {
synchronized (lock) {
return value;
}
}
}Synchronize only on private final identity objects that your class owns. This rule excludes boxed primitives, strings, optionals, date-time values, collections returned by other code, class objects, and any object exposed to clients. A monitor is a coordination mechanism; exposing it lets unrelated code join your coordination by accident.
This item is related to virtual-thread pinning (Item 90), but it is not the same rule. Pinning asks what happens while a virtual thread is inside a monitor. This item asks whether the monitor is a valid lock object in the first place. You can violate either rule independently.
There are still good uses of synchronized. It is concise, memory-safe, and
well understood for small critical sections guarded by private monitors. Use
ReentrantLock when you need timed acquisition, interruptible acquisition,
multiple conditions, or explicit lock instrumentation. Use atomics when the
state transition is a small compare-and-set operation. Do not switch APIs merely
to avoid thinking about ownership; every synchronization mechanism has an owner.
JEP 390 gives this rule teeth. javac -Xlint:synchronization can warn when
source code synchronizes on a value-based class, and HotSpot has diagnostics for
monitor entry on value-based instances. These warnings are not style advice;
they are compatibility warnings for a platform moving toward identity-free
values.
In summary, the monitor object is part of your API even when it is private, so make it private, final, and identity-based. Synchronize on objects you own, or use a higher-level lock whose ownership is equally explicit.
Sources: JEP 390, JDK 25 value-based classes.
The runnable companion code gives each item a concrete check. These checks are small by design: they validate API shape, ownership, and cancellation behavior; they are not throughput benchmarks.
Run the companion with:
mvn -q compile exec:exec \
-Dexec.mainClass=com.modern.effective.java.concurrency.ConcurrencyTheoryValidation| Item | What the companion validates |
|---|---|
| 85 | Blocking waits run on virtual threads, while a CPU-bound prime-counting task runs on a bounded platform-thread pool and matches the sequential prime count. |
| 86 | A virtual-thread-per-task executor gives each submitted task its own virtual thread identity. |
| 87 | Closing a locally owned executor with try-with-resources waits for submitted tasks to complete. |
| 88 | A StructuredTaskScope owns related subtasks, joins them, and retrieves their results inside one lexical scope. |
| 89 | A ScopedValue binding is inherited by structured subtasks and disappears when its dynamic scope ends. |
| 90 | ReentrantLock is chosen for semantic features: fairness inspection, timed acquisition, and interruptible acquisition. |
| 91 | A RecordingStream can observe a committed custom JFR event without writing a recording file. |
| 92 | Future.state() separates successful, failed, and cancelled futures before resultNow() or exceptionNow() is called. |
| 93 | threadId(), Thread.sleep(Duration), and Thread.join(Duration) make identity and time units explicit. |
| 94 | A splittable RandomGenerator creates independent generators for parallel sampling. |
| 95 | A StableValue.supplier initializes once even when many virtual threads race to call get. |
| 96 | A deliberate thread factory gives workers stable names and disables accidental inherited thread-local context. |
| 97 | Explicit executors make CompletableFuture stage placement visible across blocking and CPU stages. |
| 98 | Interruption wakes a blocking worker, the worker restores interrupt status, and the task terminates promptly. |
| 99 | A private identity monitor guards shared counter state under concurrent virtual-thread access. |