Practical Multithreading Best Practices: Java Edition — Conventions for the Virtual Thread Era

· Updated: · · Multithreading, Java, Business Applications, Bug Investigation, Design

Revision history (first version, published Aug 2, 2026)
First published
Cite this article(DOI (registered archive): 10.5281/zenodo.22170851)

The DOIs below refer to previously archived versions and may not match the current text. Use this page’s URL to reference the current text.

Go Komura (2026). Practical Multithreading Best Practices: Java Edition — Conventions for the Virtual Thread Era. KomuraSoft LLC. https://comcomponent.com/en/blog/multithreading-best-practices-java/

DOI (registered archive)
10.5281/zenodo.22170851
DOI (last registered version)
10.5281/zenodo.22170852

“We want to parallelize a business system’s batch job in Java.” “The shared cache in our Spring web app occasionally gets corrupted.” “We inherited an old Swing app full of new Thread.” These are all multithreading questions, but the problem of choosing how to run the work, the problem of protecting shared data, and the problems of the UI and of stopping need to be thought about separately.

Java has had multithreading built into the language since JDK 1.0 and has assembled a mature set of tools in java.util.concurrent. JDK 21 also made virtual threads an official feature. Precisely because the tools are so plentiful, the key to the design is to choose in order: what to run concurrently, what to share, and how to stop.1

This article is the Java edition of the practical multithreading series. Aimed at developers who write business systems, batch jobs, and server applications, and targeting primarily the LTS release JDK 21 and later, it lays out the principles and caveats based on primary sources as of August 2026. It can be read on its own. The same principles worked out for other languages are covered in the “.NET Edition”, “C++ Edition”, and “C Edition”.

1. The Bottom Line First — Design Execution, Sharing, and Stopping Separately

Choosing virtual threads does not automatically solve contention on shared data or the stop path. Do not create threads directly in business code; hand task execution to ExecutorService, and then design in the following order.2

What to decide Basic policy Where to read more
What runs the work One virtual thread per task for I/O waits, platform threads at about the core count for CPU computation Section 2
How much to accept A Semaphore for the number of concurrent calls to an external service; a cap on capacity or submissions for work that piles up Sections 2 and 3
What to share Split into partial results, and use immutable data, concurrent collections, and queues Section 3
How to protect state The Atomic classes for atomic updates of a single value, a dedicated lock for compound state. Do not expect atomicity from volatile Section 4
How to stop Combine cooperative stopping via interruption with a deadline-bounded completion check Sections 5 and 6
How to handle the UI and investigation Keep the UI on its dedicated thread. Take different dumps for platform threads and virtual threads Sections 7 and 8

If you are reading from the start, follow the order of this table. If you are investigating “it won’t stop” right now, start at Sections 5 and 6; if “it froze”, start at Section 8. The pinning caveats, which differ between JDK 21 to 23 and JDK 24 and later, are in Section 2.4, and the distinction between preview and official features is in Section 9.

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (28 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

2. Choosing How to Run the Work — Separate I/O Waits from CPU Computation

2.1. Hand the Work to ExecutorService

In Java too, the basic rule is not to scatter new Thread through business code. Separate the “work”, expressed as a Runnable / Callable, from the “way it runs”, such as the thread count and the queue, and leave thread creation, reuse, and disposal to ExecutorService.2

Choose the execution mechanism first by what dominates the work.13

Mostly I/O waitHTTP calls, DB, filesCPU-bound computationThere is a task to run concurrentlyWhat dominates the task?Virtual threadsExecutors.newVirtualThreadPerTaskExecutor()One per task. Never pooledFixed pool of platform threadsExecutors.newFixedThreadPool(about the core count)or a parallel streamLimit concurrent calls to external serviceswith a Semaphore, not a pool

Figure 1: Choose virtual threads for I/O waits and a conventional pool for CPU computation. Design the limit on concurrent access separately.

Virtual threads are not “fast threads”. They do not raise the execution speed of the computation itself; they are a tool for handling large amounts of wait-heavy work concurrently and raising throughput. For computation that saturates the CPU, use a fixed pool of platform threads at about the core count, or a parallel stream, as before.3

2.2. Don’t Pool Virtual Threads; Guard the Resources You Want to Limit with a Semaphore

Virtual threads are lightweight threads decoupled from OS threads. Because they can release the OS thread during the blocking operations the JDK supports, such as I/O, locks, and sleep, they achieve enough concurrency for a single JVM to handle millions of them. Not every block lets them release it, however. The conditions for pinning are explained separately in Section 2.4.3

The way to use them is one virtual thread per task. Treat them as cheap and disposable; do not design around reusing them by putting virtual threads into a newFixedThreadPool. Use Executors.newVirtualThreadPerTaskExecutor().13

On the other hand, a limit such as “at most 10 concurrent connections to the external API” is still needed. Express that ceiling with a Semaphore, not with the size of a virtual thread pool. Not pooling virtual threads and being free to access external services without limit are two different things.3

What runs inside a virtual thread is ordinary synchronous code. The design philosophy is that straightforward “one thread per request” code can run unchanged at large scale, without being rewritten into a form like .NET’s async/await. The form try (var executor = Executors.newVirtualThreadPerTaskExecutor()) is also available, but check Section 6.3 for how long close() waits on exit.12

2.3. For a Platform Thread Pool, Also Consider a Cap on the Queue

“Don’t pool” is about virtual threads. Java has had mature thread pools since JDK 5 (2004), and they are still chosen by purpose today.

ThreadPoolExecutor is a general-purpose pool whose thread count, queue, rejection policy, and more can be configured in detail. ForkJoinPool is a work-stealing pool, and its commonPool() is used as the default asynchronous execution target for parallel streams and CompletableFuture. For periodic execution there is ScheduledThreadPoolExecutor. Wherever platform threads are reused, such as CPU computation, these remain important tools.

However, what Executors.newFixedThreadPool limits is the thread count; the queue is unbounded. In a resident service where submissions keep outpacing processing, the waiting tasks and their data keep consuming memory. Either configure ThreadPoolExecutor with a bounded queue and a rejection policy, or put admission control such as a Semaphore on the submitting side, so that backpressure can be applied. The queue in Section 3.6 uses the same principle.

A pool is an optimization for reusing OS threads, which are expensive to create and hold. Virtual threads are light enough that developers no longer have a reason to reuse them. That does not mean pools have become inefficient.

In fact, underneath virtual threads, the JDK’s scheduler uses a work-stealing ForkJoinPool and, by default, runs as many carrier threads (OS threads) as there are available processors. The picture of handling a large amount of concurrent work with a small number of OS threads is the same; the JVM takes over managing it. It helps to think of Java as heading in the same direction as .NET’s asynchronous I/O, which does not keep occupying a thread during a wait, while keeping the shape of synchronous code.1

2.4. Think About Pinning by JDK Version and by Where the Block Happens

Pinning is the state in which a virtual thread cannot leave its carrier thread, so the OS thread keeps waiting as well. Frequent or long pinning erodes the scaling advantage of virtual threads.3

Where the block happens JDK 21 to 23 JDK 24 and later
Inside a synchronized block or method Pinning occurs. Consider replacing sites with frequent or long blocking with ReentrantLock JEP 491 removed this constraint. Mechanical replacement solely as a pinning countermeasure is unnecessary
While executing native code (JNI) or a foreign function The carrier thread may not be released Separate from the synchronized improvement, pinning at the native boundary remains

What JEP 491 in JDK 24 removed is the synchronized pinning that came from the monitor implementation. If you load up large numbers of operations that block for a long time in JNI-based drivers or device APIs, the problem of exhausting the carrier threads remains. Do not assume “it’s JDK 24, so waiting anywhere is fine”. Also check whether in-house guidelines are still stuck at the JDK 21-era caveat.43

3. Reducing Shared Mutable State — Review the Sharing Before Writing Synchronization

3.1. Even count++ Loses Increments When Operations Overlap

A race condition is a bug in which the result changes depending on the order in which multiple threads reach the code. count++ looks like a single expression, but it splits into a read, an add, and a write back. If another thread slips in between, one of the increments is lost.

Thread BShared variable countThread AThread BShared variable countThread Acount = 10Incremented twice, yet count = 11Thread A's increment was lostRead (10)Read (10)Add locally (11)Add locally (11)Write back (11)Write back (11)

Figure 2: When two threads read the same value and then write back, only one of the two increments survives.

The other classic is a deadlock, where threads wait on each other’s locks and cannot proceed. That is covered in Section 4.3. Both depend on timing, so what is rare on a development machine can happen frequently in production, where the core count and the load differ. They also stop reproducing when you add a debugger or logging, because observation changes the timing.

That is exactly why you start by reducing the places that need synchronization before synchronizing correctly. If running on multiple threads is a requirement, what the design should reduce is shared mutable data.

3.2. The Java Memory Model Defines When Other Threads See a Write

In Java, how shared data is seen is defined by the happens-before relationship of the Java Memory Model (JMM). Accessing a shared variable without synchronization is not undefined behavior as it is in C++, but a stale value may keep being seen, or writes may appear out of order.5

For example, if a loop merely checks a boolean flag, a value changed by another thread may never become visible. This is not a JVM bug; it is a memory consistency error caused by not doing the necessary synchronization.

What creates happens-before is synchronized, volatile, and the classes in java.util.concurrent. The concurrent collections also guarantee, as part of their specification, the relationship between an update and a subsequent retrieval of that value. Instead of getting clever with a raw shared variable, use the guarantees these tools provide. Note, though, that a value being visible and several operations executing as one unit are different things. The difference from atomicity is laid out in Section 4.1.56

3.3. Split Aggregation into Partial Results and Merge Them at the End

For parallel aggregation, before having every thread write to a single total variable, first consider having each thread build a partial result and summing them at the end. The reduce / collect operations of parallel streams provide this structure as a framework.

LongAdder, used for high-frequency increments, is also a strategy of spreading updates across internal cells and summing them when read. Reduce writes to shared state first, and choose the necessary synchronization after that.

3.4. If You Make It Immutable, Check All the Way into the Elements

Share configuration and master data as immutable data that is never rewritten after construction. To replace it, the established practice is to build a new object and swap a volatile reference.

However, using record or List.copyOf alone does not make the whole of the data immutable. A record’s accessors return the references to its components as they are. Even if List.copyOf / Map.copyOf make the collection unmodifiable, the element objects are not deeply copied.

If the elements are mutable, other code holding a reference to the same element can rewrite its contents, and the contention remains. To share as immutable data without synchronization, confirm that the entire object graph, elements included, is immutable. If mutable elements are included, cut the sharing with a deep copy, or move the elements to record / immutable types as well. Distinguish “looks read-only” from “is immutable”.

3.5. Use ConcurrentHashMap’s Compound Operations, and Know the Scope of Their Guarantee

For “create and insert if the key is absent”, do not write the check and the insertion separately; use ConcurrentHashMap.computeIfAbsent. The entire method call executes atomically, and if the key is absent, the mapping function is called exactly once within that single call. The guarantee differs from .NET’s ConcurrentDictionary.GetOrAdd, whose factory can run more than once under contention.6

For a frequency counter, the following form is the established practice.

// The established pattern for a frequency counter: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();

“Once in that call” and “once in the lifetime of that key” are different. If the function returns null or throws an exception, nothing is registered, and a later call runs it again. The same applies if a registered entry is removed. For initialization that cannot tolerate duplicated side effects, design it so that it succeeds by returning a non-null value, and include how the entries are handled.6

Also, in exchange for executing atomically, some updates from other threads are blocked while the computation runs. Keep the mapping function short and simple, and never update this map itself from inside it. A detectable recursive update can result in an IllegalStateException.6

3.6. Use a Bounded Queue for Handing Data Between Threads

Instead of having multiple threads touch the data directly, hand it over through a BlockingQueue. With an ArrayBlockingQueue given a capacity, put blocks when the queue is full, which naturally applies backpressure to the producing side.

This is the same structure as the bounded channel in the .NET edition. Even with virtual threads, a design that makes the producer/consumer boundary and the cap on the backlog explicit remains effective.

4. Protecting the Shared State That Remains — volatile, the Atomic Classes, and Locks

4.1. Visibility and Atomicity Are Separate Guarantees

volatile is a tool for visibility and ordering, that is, for happens-before. It is not a guarantee that “read, compute, write back” happens as a unit, so applying ++ to a volatile int from multiple threads does not prevent the lost increment of Figure 2.5

What to protect Tool to choose Caveat
Simple state notification, swapping a reference to immutable data volatile No atomicity for compound operations
Atomic update of a single value AtomicInteger / AtomicLong / AtomicReference For statistics incremented at high frequency, also use LongAdder
Consistency across several values A lock Keep the same discipline everywhere the same data is touched

As in the .NET and C++ editions, assign atomic updates to the Atomic classes and compound state to locks. What matters is not trying to make something thread-safe with volatile alone.

4.2. Map Locks to the Data They Protect, Not to Sections of Code

Map a dedicated lock object to each set of mutable data you want to protect. Then take that same lock everywhere the data is touched. Make it possible in review to confirm which lock protects which data.

Avoid synchronized(this), synchronized(SomeClass.class), and locking on publicly exposed objects. Outside code can lock the same object, and unintended collisions occur. Use a private final Object lock = new Object(); that is never exposed, or a dedicated ReentrantLock.

4.3. Avoid External Work While Holding a Lock, and Fix the Acquisition Order

Doing I/O, calling listeners, or running unknown code while holding a lock lengthens the hold time. If the callee takes another lock, it can also create a circular wait.

waiting for lock 2 to be releasedwaiting for lock 1 to be releasedThread Aholding lock 1Thread Bholding lock 2

Figure 3: When one thread holds lock 1 and waits for lock 2 while the other waits in the reverse order, neither can proceed.

Wherever multiple locks are taken, fix the acquisition order across all threads. Where the order cannot be guaranteed, provide a path with tryLock(timeout) that releases the locks it holds and retries if the lock cannot be obtained. Keep the two rules together: no long or external work while holding a lock, and a consistent acquisition order.

4.4. synchronized for Short Exclusion, ReentrantLock When You Need More

For short, simple mutual exclusion, synchronized is enough. Choose ReentrantLock when you need timed acquisition with tryLock(timeout), a fairness policy, multiple Conditions, or acquisition and release split across different methods.

In ordinary use of ReentrantLock, never break the form of try immediately after lock(), with unlock() in finally. Do not expect the lock to be released automatically as with C++’s RAII; structure it so the lock is released even when an exception occurs.

When combining with virtual threads, also check the JDK differences in Section 2.4. On JDK 24 and later, there is no need to replace synchronized across the board solely as a pinning countermeasure. The discipline of keeping locks short does not change, however.4

5. Deciding How Tasks Stop — Don’t Swallow Interrupts

5.1. interrupt Is a Signal for Cooperative Stopping, Not a Forced Termination

Design stopping and cancellation in Java around cooperative stopping via interruption. t.interrupt() sets the target thread’s interrupt status. If the thread is blocked in sleep / wait / join or the like, an InterruptedException breaks it out of the wait, and the interrupt status is cleared at that point.7

In the JDK 21 API this article refers to, the former forcible mechanisms Thread.stop / suspend / resume throw UnsupportedOperationException. Instead of going back to dangerous mechanisms that release locks in an inconsistent state or invite deadlocks, have the task itself respond to the stop signal and end.7

Can end on its ownCannot end (inside a library, etc.)Stopping side calls t.interrupt()Interrupt status is setThread doing computationchecks Thread.interrupted() in its loopBlocked in sleep / wait / joinInterruptedException fires and wakes it immediately(the status is cleared)Cleans up and ends on its ownWhat does the catch do?Thread.currentThread().interrupt()restores the status and leaves the signal

Figure 4: The task that receives the interrupt cleans up and ends on its own. Swallowing the exception loses the stop signal.

5.2. Make the Responsibility After Receiving InterruptedException Explicit

Do not write code that catches InterruptedException and does nothing. If you can finish within your own responsibility, clean up and finish. If you leave the decision to the caller, either rethrow the exception as is, or restore the status with Thread.currentThread().interrupt() to leave the signal.7

A computation loop, too, has to check for the interrupt and proceed to exit, as in Figure 4. Implementing only the side that sends the stop request stops nothing if the receiving side ignores it. This property applies just as it is to shutdownNow() in the next section.

6. Shutting Down ExecutorService — Separate the Request from the Completion Check

6.1. Calling shutdownNow Alone Does Not Mean the Shutdown Is Complete

When shutting down an ExecutorService, separate the operation that stops intake, the operation that requests cancellation, and the operation that waits for completion.2

API Role What it does not guarantee by itself
shutdown() Stops accepting new tasks and lets already-submitted tasks run The calling thread does not wait for completion
awaitTermination(...) After a shutdown request, waits until completion, the deadline, or an interrupt, whichever comes first Completion of the shutdown when it times out
shutdownNow() Attempts to stop running tasks and returns the waiting tasks that never ran Forced termination of running tasks, or waiting until they end
close() Stops intake and waits until the Executor terminates A cap on the waiting time

shutdownNow() is best-effort. Standard implementations such as ThreadPoolExecutor typically cancel via interruption, so a task that does not respond to interruption does not stop. If you use a custom Executor, check that implementation’s cancellation method, including whether it sends an interrupt at all.2

To cancel an individual task, use Future.cancel(true). Here too, do not confuse a stop request delivered to a running task via interruption with the task actually finishing its work.

6.2. Use a Deadline-Bounded Two-Stage Shutdown

First stop intake and wait for the work to run to completion; once the deadline passes, request cancellation and wait for completion once more. If it still did not terminate, report that to the caller in a way distinct from success. The following example builds on the official two-stage pattern and adds the success or failure of the shutdown and the handling of unexecuted Futures.2

/** Returns true once the shutdown is complete. Do not proceed to release shared resources while this is false. */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
    pool.shutdown();                    // Stage 1: stop accepting new tasks and wait for completion
    try {
        if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
            // Stage 2: request cancellation. The tasks dropped without running are returned,
            // so mark their Futures cancelled to wake callers waiting on get()
            pool.shutdownNow().forEach(r -> {
                if (r instanceof Future<?> f) f.cancel(false);
            });
            if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
                System.err.println("Pool did not terminate");
                return false;           // Shutdown incomplete. Report it in a form distinct from success
            }
        }
        return true;
    } catch (InterruptedException ex) {
        pool.shutdownNow().forEach(r -> {
            if (r instanceof Future<?> f) f.cancel(false);
        });
        Thread.currentThread().interrupt();   // Restore this thread's own interrupt status too
        return false;                   // The shutdown may be incomplete on this path as well
    }
}
Completes within the deadlineTimes outCompletesStill not finishedshutdown()Stop accepting new tasksawaitTerminationwaits for completionShutdown completeshutdownNow()Sends interrupt to running tasks(whether they respond is up to the task)awaitTerminationwaits againRecord as an anomaly(a task that ignores interrupts is the suspect)

Figure 5: Separate the stage that waits for completion from the stage that requests cancellation and waits again. If it still does not finish, treat it as an incomplete shutdown.

Do not release the shared resources the tasks use while the return value is false. Not only when the second wait times out, but also on the path where the waiting side was interrupted, tasks may still be running. Do not just log it and treat it as success; let the caller determine that the shutdown is incomplete.

6.3. Use close and try-with-resources Only in Scopes That Can Run to Completion

From JDK 19 onward, ExecutorService can be used as an AutoCloseable. try (var executor = ...) waits for termination with close() when leaving the scope. It also works in combination with a virtual thread Executor.2

However, close() waits without a timeout, so it is not a substitute for the deadline-bounded two-stage pattern. If there is a task that never finishes or does not respond to interruption, the thread trying to close also waits forever.

Use try-with-resources for a finite scope where tasks are submitted on the spot and their completion can be awaited on the spot. On paths where waiting forever is not an option, such as shutting down the whole application, design a deadline-bounded wait and the handling of an incomplete shutdown.2

6.4. The Task in the Queue and the User’s Future Are Not Necessarily the Same

What shutdownNow() returns are the objects that remained in the execution queue. With a plain submit, that is normally the FutureTask itself that was handed to the user, so the sample’s cancel(false) can wake callers waiting on get().

When tasks are submitted through a wrapper such as ExecutorCompletionService, on the other hand, what comes back is the wrapper inside the queue, which is a different object from the user’s Future. There are configurations in which the sample above alone cannot complete the user’s Future.

In that case, keep a list of the user-side Futures at submission time and cancel those at shutdown, or design it so that tasks dropped from the queue are returned to their owners. Check the stop path all the way to “the side waiting on work that is now known never to run”.

7. Return the UI to Its Dedicated Thread — Swing’s EDT

Swing’s UI is managed by the event dispatch thread (EDT). Swing component methods are, as a rule, not thread-safe, and touching them from multiple threads invites thread interference and memory consistency errors.8

To update the screen from another thread, request it on the EDT with SwingUtilities.invokeLater. Conversely, long processing on the EDT freezes the UI, so push heavy work out to a worker thread with SwingWorker or the like. Moving the work out and returning the display of the result to the UI thread go together.8

The same structure applies in JavaFX. Request UI updates on the application thread with Platform.runLater. However many kinds of thread there are, the principle that the UI belongs exclusively to the thread that manages it does not change.

8. Prepare Verification and Investigation — Design Review, Dumps, and Load Tests

8.1. Review the Shared Data and the Stop Path First

Even when ordinary tests pass, it may just be that no contention happened to occur. Do not expect tests alone to find race bugs; put the first line of defense in the design.

Use a mapping table to check the shared mutable data, the lock that protects each piece, and the lock acquisition order. Also check that there is no catch that swallows InterruptedException, and that the shutdown and interruption paths reach every task. Turn the design of Sections 3 to 6 directly into review items.

8.2. Take the Dump That Matches the Kind of Thread

Examine the state of platform threads with jstack or jcmd <pid> Thread.print. The -l option adds lock information as well.9

However, the traditional dump format does not include the application’s virtual threads. When tracing a stalled request in a configuration that uses virtual threads, capture a format that includes virtual threads with jcmd <pid> Thread.dump_to_file -format=json <file>.1

When investigating a hang, take two or three dumps a few seconds apart and compare the threads that are not moving. For a platform thread waiting on a lock, trace what it is waiting for and who holds that lock. For virtual threads, use the dump that includes them to see where in the processing they have stopped. Logging tryLock(timeout) expirations also gives you a trigger for taking a dump.

8.3. Shake Up the Execution Order Under Production-Level Load

In addition to dumps as the second line of defense, run stress tests as the third. Running for a long time with more parallelism than there are cores, randomizing the processing order, and inserting artificial delays make it easier to hit the execution orders in which contention occurs.

Run at least one test with production-level data volume and thread count before release. Do not, however, treat passing a load test as a reason to skip designing the shared state.

9. Where the New APIs Stand — Separate Preview from Official Features

This section reflects the state as of August 2026. It separates the basic tools already available from APIs still in development, so that the two are not confused.

Structured Concurrency (StructuredTaskScope) is an API that treats several related subtasks as a single unit of work and structures failure propagation and cancellation. It is intended to be used with virtual threads, but at this point it is still a preview feature. The fifth preview in JDK 25 (JEP 505) reshaped the API around StructuredTaskScope.open(), and it continues in JDK 26 as the sixth preview (JEP 525).1011

Scoped Values, on the other hand, which handle the sharing of immutable context, were finalized in JDK 25. They are a solution to the problems of ThreadLocal, such as mutability, lifetime management, and inheritance cost.12

The direction the new APIs aim for is the same as this article’s principles: make task boundaries explicit, push sharing toward immutability, and handle stopping cooperatively.

10. Summary — 10 Items to Check Before Implementation and in Review

# What to check
1 Does business code avoid creating new Thread directly and hand tasks to ExecutorService?
2 Are I/O waits and CPU computation given different execution mechanisms, with a cap or backpressure considered for the fixed pool’s queue as well?
3 Are virtual threads left unpooled, with concurrent calls to external services limited by a Semaphore?
4 Is shared data moved toward partitioning, immutability, and hand-off, with the elements of records and immutable collections checked as well?
5 Are dedicated locks mapped to the data, avoiding synchronized(this) and locks on public objects?
6 Are compound operations such as computeIfAbsent used, respecting the conditions under which the mapping function reruns and keeping it short?
7 Is atomicity not expected from volatile, with counters assigned to the Atomic classes or LongAdder and compound state to locks?
8 Is InterruptedException never swallowed, so that the stop signal reaches the task side?
9 Is shutdown a deadline-bounded two-stage pattern? Is close() limited to scopes that can guarantee completion, and are incomplete shutdowns and unexecuted Futures handled?
10 Are Swing / JavaFX UI updates consolidated onto the EDT / application thread?

Java’s virtual threads opened a path to scaling straightforward synchronous code as it is. Even so, the tool for throughput, the tools for protecting shared state, and the tool for signaling a stop are different. Choosing with the roles of execution, sharing, and stopping kept separate is the foundation of multithreading design in Java.

KomuraSoft LLC handles multithreading design reviews for Java business systems and batch processing, investigation of concurrency-related failures such as shared-state corruption and “it occasionally won’t stop or freezes” (thread dump analysis), and technical consulting on adopting virtual threads.

References

  1. OpenJDK, JEP 444: Virtual Threads. On virtual threads becoming an official feature in JDK 21; on their being lightweight threads that dramatically reduce the effort of writing, maintaining, and observing high-throughput concurrent applications; on the design philosophy of scaling straightforward “one thread per request” synchronous code as it is; on the JDK’s virtual thread scheduler being a work-stealing ForkJoinPool operating in FIFO mode, with a default parallelism equal to the number of available processors; and on a new thread dump format that includes virtual threads having been added as jcmd Thread.dump_to_file (in plain-text and JSON form), while traditional thread dumps do not include virtual threads.  2 3 4 5 6

  2. Oracle, ExecutorService (Java SE 21 & JDK 21 API). On shutdown() letting already-submitted tasks run to completion while stopping new submissions; on shutdownNow() attempting to stop running tasks and returning a list of the tasks that were awaiting execution, although the typical implementation cancels via Thread.interrupt(), there is no guarantee beyond best-effort, and a task that does not respond to interruption does not terminate; on being able to wait for completion with awaitTermination; on close() (Java 19 and later, AutoCloseable) calling shutdown and waiting for completion, usable with try-with-resources; and on the shutdown, then awaitTermination, then shutdownNow two-stage shutdown being shown as a usage example.  2 3 4 5 6 7 8

  3. Oracle Java SE Core Libraries, Virtual Threads. On virtual threads being lightweight threads implemented by the Java runtime that release their OS thread during blocking I/O; on their being a feature for scale (throughput) rather than speed (latency), and unsuited to CPU-intensive processing; on never pooling virtual threads and using one per task instead (newVirtualThreadPerTaskExecutor); on using a Semaphore rather than a thread pool to limit concurrency; on blocking inside synchronized as of JDK 21 causing pinning to the OS thread, for which replacing frequent or long-blocking sites with ReentrantLock was advised; and on being able to detect pinning with -Djdk.tracePinnedThreads.  2 3 4 5 6 7

  4. OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. On the JVM’s monitor implementation having been rewritten for virtual threads in JDK 24, so that blocking inside a synchronized block or method no longer pins a virtual thread to its carrier thread; and on this making the JDK 21 to 23-era countermeasure of “replacing synchronized with ReentrantLock” unnecessary in principle.  2

  5. Oracle, The Java Tutorials, Memory Consistency Errors. On memory consistency errors arising when multiple threads have inconsistent views of the same data; on the key to avoiding them being the happens-before relationship (a guarantee that a memory write by one statement is visible to another statement); and on synchronized, volatile, and Thread.start / join, among others, creating happens-before relationships.  2 3

  6. Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). On the entire method call of computeIfAbsent executing atomically, with the mapping function called exactly once when the key is absent; on some update operations from other threads being blocked during the computation, so it should be kept short and simple; on the mapping function being forbidden from modifying this map, with a detectable recursive update resulting in IllegalStateException; and on retrieval operations (get) not blocking, with a happens-before relationship holding between an update for a given key and a subsequent retrieval.  2 3 4

  7. Oracle, Thread (Java SE 21 & JDK 21 API). On Thread.stop / suspend / resume being inherently unsafe (locks are released in an inconsistent state and damaged objects become visible; suspend invites deadlock), making them deprecated for removal, and now throwing UnsupportedOperationException when called; on interrupt() setting the interrupt status and waking a thread blocked in sleep / wait / join by throwing InterruptedException (at which point the interrupt status is cleared); and on the difference in how interrupted() and isInterrupted() treat that status.  2 3

  8. Oracle, The Java Tutorials, The Event Dispatch Thread. On Swing’s event-handling code running on the event dispatch thread (EDT); on most Swing object methods not being thread-safe, so that calling them from multiple threads invites thread interference and memory consistency errors, meaning access to Swing components should, as a rule, happen on the EDT; on requesting tasks on the EDT from other threads with SwingUtilities.invokeLater / invokeAndWait; and on tasks on the EDT needing to finish quickly.  2

  9. Oracle, The jstack Command (Java SE 21 Tools Reference). On jstack printing the stack traces (class name, method name, line number) of every thread in a specified Java process; on the -l option enabling a detailed display that includes additional lock information; and on its being used alongside other diagnostic tools such as jcmd. 

  10. OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). On the structured concurrency API treating a group of related subtasks as a single unit of work and structuring error propagation and cancellation; on StructuredTaskScope having been reshaped into a form opened with a static factory method (open); and on its being, as of JDK 25, a fifth preview and not an official feature. 

  11. OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). On structured concurrency continuing as a sixth preview in JDK 26 as well, meaning that as of August 2026 it remains a preview feature even in the current JDK, and using it requires enabling preview features. 

  12. OpenJDK, JEP 506: Scoped Values. On Scoped Values having been finalized in JDK 25; and on their being a mechanism for sharing immutable context data safely and efficiently within and across threads, providing a solution to the problems of ThreadLocal (mutability, lifetime management, and inheritance cost). 

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Frequently Asked Questions

Common questions about the topic of this article.

If we have virtual threads, is a thread pool (ExecutorService) no longer needed?
It depends on the use. Virtual threads are a mechanism for running large numbers of tasks that mostly wait on I/O; they do not make code faster, they raise throughput. For I/O-bound work, use one virtual thread per task (Executors.newVirtualThreadPerTaskExecutor) and never pool virtual threads. For parallelizing computation that saturates the CPU, on the other hand, a pool of platform threads limited to roughly the core count (or a parallel stream) remains the right fit, as before. And when you want to cap the number of concurrent accesses to an external service, the virtual-thread-era recommendation is to limit it with a Semaphore rather than with a pool.
Which should I use, synchronized or ReentrantLock?
For short, simple mutual exclusion, synchronized is enough and keeps the code concise. Choose ReentrantLock when you need features such as timed acquisition with tryLock, a fairness policy, or multiple Conditions. There is a historical caveat when combining them with virtual threads: in JDK 21 to 23, blocking inside a synchronized block pinned the virtual thread to its OS thread, so replacing sites with frequent or long blocking with ReentrantLock was recommended. JDK 24 (JEP 491) rewrote the monitor implementation and removed this constraint. On JDK 24 or later, replacing synchronized for pinning reasons is unnecessary.
Does adding volatile make it thread-safe?
No. Java's volatile creates a happens-before relationship between a write to the variable and a read of it, guaranteeing visibility (other threads see the latest write) and ordering, but it does not guarantee atomicity for a compound operation such as read, compute, write back. Apply ++ to a volatile int counter from several threads and increments are lost. Use AtomicInteger / AtomicLong (or LongAdder for high-frequency aggregation) for counters, and a lock when several variables must be protected together. volatile is appropriate almost only where one thread writes and the others only read, such as a simple state flag.
Is it acceptable to catch InterruptedException and ignore it?
No. Interruption is Java's standard signal for stopping and cancellation, and swallowing it creates a thread that never stops. By the time InterruptedException is thrown, the interrupt status has been cleared, so if you cannot finish the work yourself, either restore the status with Thread.currentThread().interrupt() to leave the signal for the caller, or rethrow the exception as is. An empty catch block that does nothing is a classic cause of failures where shutdown has no effect or shutdownNow is ignored.
Can't I stop a thread with Thread.stop?
No. Thread.stop is inherently unsafe (it releases locks while their state is inconsistent, and other threads can see the damaged objects), so it was deprecated for a long time, and in current Java calling it throws UnsupportedOperationException. The same applies to Thread.suspend / resume. The only legitimate way to stop a thread is cooperative stopping via interruption (interrupt). If you use ExecutorService, shutdown only stops accepting new tasks and waits for completion; it does not send an interrupt to running tasks. It is shutdownNow that attempts to stop running tasks, and that is best-effort (via interruption in the standard implementations).

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog