Skip to content

Latest commit

 

History

History
1281 lines (1041 loc) · 54.9 KB

File metadata and controls

1281 lines (1041 loc) · 54.9 KB

Modern Effective Java Concurrency

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.

Item 85: Prefer virtual threads for blocking I/O; keep CPU-bound work on platform threads

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.

Item 86: Create one virtual thread per task; never pool them

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.

Item 87: Close executors with try-with-resources

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.

Item 88: Scope concurrent subtasks with StructuredTaskScope

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.

Item 89: Prefer ScopedValue over ThreadLocal for one-way transmission

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.

Item 90: Understand virtual-thread pinning; observe it, do not guess

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.

Item 91: Use Java Flight Recorder for concurrency observability

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.jfr

For 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=concurrency

For 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.

Item 92: Drain completed futures with state(), resultNow(), and exceptionNow()

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.

Item 93: Use Duration overloads; prefer threadId(); forget stop, suspend, and resume

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.

Item 94: Pick a splittable RandomGenerator for parallel streams

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.

Item 95: Use StableValue for lazy one-time initialization

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.

Item 96: Create thread factories with deliberate names and inheritance

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.

Item 97: Pass explicit executors to asynchronous CompletableFuture stages

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.

Item 98: Propagate interruption as cooperative cancellation

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.

Item 99: Synchronize only on private identity objects you own

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.

Companion validation

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.