Practical Multithreading Best Practices: Java Edition — Conventions for the Virtual Thread Era
· Go Komura · Multithreading, Java, Business Applications, Bug Investigation, Design
“We want to parallelise a business batch job in Java.” “The shared cache in our Spring web app occasionally gets corrupted.” “We inherited an old Swing app riddled with new Thread.” — Java has had multithreading built into the language since JDK 1.0, has matured into the java.util.concurrent toolbox, and, with virtual threads in JDK 21, has rewritten the conventional wisdom of concurrent programming once again. Precisely because the toolset is so rich, which tool you choose becomes the design quality itself.
This article is the Java edition of the multithreading-in-practice series. Aimed at developers who write business systems, batch jobs, and server applications in Java, it maps the principles of multithreading design — never create threads directly, reduce shared mutable state, discipline your locking, design how you stop things before anything else — onto Java’s tools (targeting primarily the LTS release, JDK 21 and later), and pulls together the choices of the virtual-thread era and Java-specific pitfalls, grounded in primary sources as of August 2026. It is written to be read on its own. The same principles applied to other languages are covered in the companion articles “.NET Edition”, “C++ Edition”, and “C Edition”.
1. The Bottom Line First
- Never write
new Threadin business code — the same rule applies in Java. Hand tasks off toExecutorServiceand let the library manage thread lifecycles.1 - Route tasks that are mostly waiting on I/O to virtual threads. Virtual threads, made official in JDK 21, are used one-per-task and must never be pooled. Limit concurrency with
Semaphore, not with pool size.23 - Virtual threads are a throughput tool, not a tool for making computation faster. CPU-bound parallelism is still the job of platform threads sized to roughly the core count — a fixed pool or a parallel stream, as before.3
- Lock on a
private finallock object, or a dedicatedReentrantLock.synchronized(this)and locking on a publicly exposed object can collide with outside code. If you need timed acquisition (tryLock), useReentrantLock. - JDK 21-23 had a problem where blocking inside a
synchronizedblock pinned virtual threads; JDK 24 (JEP 491) fixed it. Distinguish old warnings from current reality.34 volatileguarantees visibility and ordering, not atomicity. UseAtomicInteger/LongAdderfor counters, and locks for compound state.5- Cooperative stopping via interruption is the only correct way to stop a thread.
Thread.stop/suspend/resumenow throwUnsupportedOperationException. Never swallowInterruptedException— either restore the status or rethrow.6 - Stop an
ExecutorServicewith the two-stage pattern: shutdown → awaitTermination → shutdownNow.shutdownNowis best-effort (the standard implementation works via interruption), so it presupposes that your tasks respond to interruption.1 - Swing’s UI belongs exclusively to the EDT (Event Dispatch Thread). Request updates from other threads via
SwingUtilities.invokeLater.7
2. Why Multithreading Is Hard — Race Conditions, Deadlocks, and the Memory Model
Boil down the problems multithreading introduces, regardless of language, and there are two kinds.
A race condition is a bug where the outcome depends on the order in which multiple threads reach a particular piece of code. The classic example is a shared counter: the single expression count++ actually breaks down into three steps — read, add, write back. If two threads enter these three steps at the same time, one thread’s increment gets overwritten and lost when the other writes back. The result changes on every run, and there is no way to predict which result you will get.
sequenceDiagram
participant A as Thread A
participant M as Shared variable count
participant B as Thread B
Note over M: count = 10
A->>M: Read (10)
B->>M: Read (10)
A->>A: Add locally (11)
B->>B: Add locally (11)
A->>M: Write back (11)
B->>M: Write back (11)
Note over M: Two increments happened,<br/>but count = 11 — Thread A's increment was lost
Figure 1: A classic race condition in which an increment on a shared counter is lost. If another thread interleaves during the three steps of count++, whichever write-back happens last overwrites the other
A deadlock is a state in which two threads each wait for a lock the other is holding, so neither can proceed. Thread A holds lock 1 and waits for lock 2; thread B holds lock 2 and waits for lock 1 — that alone is enough for both to stop forever.
flowchart LR
A["Thread A<br/>holding lock 1"] -->|"waiting to release lock 2"| B["Thread B<br/>holding lock 2"]
B -->|"waiting to release lock 1"| A
Figure 2: The circular wait of a deadlock. The moment the waiting arrows form a loop, every thread inside that loop stops forever
Both are timing-dependent. An interleaving that only shows up once in tens of thousands of runs on a development machine can happen every day on a production server with different core counts and different load. “It stops reproducing once I attach a debugger” and “it disappeared when I added logging” are both because observation changes the timing — classic behaviour for a race bug. That is exactly why every principle in this article points in the direction of reducing the places that need synchronisation, before worrying about synchronising correctly.
2.1. A Java-Specific Premise — the Memory Model and happens-before
On top of that, what is specific to Java is that how shared data is seen is defined by happens-before relationships in the Java Memory Model (JMM).
Reading and writing a shared variable without synchronisation does not become C++-style “undefined behaviour”, but it can legitimately result in stale values continuing to be seen, or writes appearing out of order. A memory consistency error where “a loop is watching a boolean flag, but the value another thread changed never becomes visible” is behaviour the JMM permits, not a JVM bug.5 The tools that guard against this are the mechanisms that create happens-before relationships — synchronized, volatile, and the classes in java.util.concurrent. Use the concurrent collections correctly and the library guarantees “a happens-before relationship between an update operation and a subsequent retrieval” for you.8
In other words, Java’s practical guidance can be summed up like this: don’t get clever with a raw shared variable. Use the tools in java.util.concurrent for sharing, and let the library create the happens-before relationships.
3. How to Create Threads — ExecutorService and Virtual Threads
3.1. Separating the Task from How It Runs
ExecutorService is what carries the “never create threads yourself” principle in Java. It separates the work (Runnable / Callable) from how it is executed — how many threads, which queue — and leaves thread creation, reuse, and disposal to the library.1
From JDK 21 onward, choosing how to run something has become a simple binary choice.23
flowchart TB
S["Task you want to run concurrently"] --> Q1{"What drives the task?"}
Q1 -->|"Mostly I/O wait<br/>HTTP calls, DB, files"| VT["Virtual threads<br/>Executors.newVirtualThreadPerTaskExecutor<br/>one per task, never pooled"]
Q1 -->|"CPU-bound computation"| PT["Fixed pool of platform threads<br/>Executors.newFixedThreadPool - about as many as cores<br/>or a parallel stream"]
VT --> LIMIT["Limit concurrency to external services<br/>with Semaphore, not pool size"]
Figure 3: Choosing how to execute work from JDK 21 onward. Draw the line first — “change how you wait for I/O, parallelise for CPU” — then hand I/O-bound work to virtual threads and CPU-bound work to a conventional pool
There is one caveat on the CPU-bound side. Executors.newFixedThreadPool limits the thread count, but its queue is unbounded. In a long-running service where submissions keep outpacing processing, only the threads are capped at the core count — the tasks piling up in the queue, and their data, keep eating memory. In that kind of setup, either use ThreadPoolExecutor directly to configure a bounded queue plus a rejection policy, or put an admission control such as Semaphore on the submitting side so you can apply back-pressure (the same principle as the queue discussion in Section 4).
3.2. Don’t Misuse Virtual Threads
Virtual threads are lightweight threads decoupled from OS threads: during a blocking operation in the JDK (I/O, locking, sleep, and the like in the standard library) they release their OS thread, which is why a single JVM can run millions of them. That said, they don’t release it for every kind of block. If a virtual thread blocks while executing native code (JNI) or a foreign function, it stays pinned to its carrier thread. What JDK 24 (JEP 491, discussed below) fixed was pinning caused by synchronized; pinning at a native boundary remains, so loading a large number of virtual threads with operations that block for a long time via a JNI driver or device API will exhaust the carrier threads. But as the official guide stresses, they are not “faster threads”. Code execution speed does not change — what they provide is scale (throughput).3
There are three disciplines to using them.3
- Don’t pool them. Virtual threads are cheap and disposable; “number of tasks = number of virtual threads” is the correct state. Putting virtual threads into a
newFixedThreadPoolis a mistake — use the formtry (var executor = Executors.newVirtualThreadPerTaskExecutor()). - Limit concurrency with
Semaphore. Express a constraint such as “at most 10 concurrent connections to an external API” with a semaphore, not with pool size. - Don’t use them for CPU-bound work. Platform threads sized to roughly the core count remain the right tool for parallelising computation, as before.
Note that what runs inside a virtual thread is ordinary synchronous code. Rather than rewriting your code the way .NET’s async/await does, the design philosophy behind virtual threads is to let you run straightforward “one thread per request” code, unchanged, at massive scale.2
3.3. A Common Misunderstanding — “No Pooling Needed” Applies Only to Virtual Threads
Don’t read the “don’t pool them” discipline as meaning “Java has no such thing as a thread pool (or it’s inefficient)”. The reality is the opposite: Java’s pools have been a mature part of the standard library since JDK 5 (2004). The finely configurable general-purpose pool ThreadPoolExecutor (created via the various Executors factories), the work-stealing ForkJoinPool (whose common instance, commonPool(), is the default execution target for parallel streams and CompletableFuture), and ScheduledThreadPoolExecutor for periodic execution — these are still the leading players for CPU-bound work.
A pool is fundamentally an optimisation founded on “creating and holding an OS thread is expensive, so reuse it”. Virtual threads eliminate this premise by making creation cost close to zero, so there is no longer a reason to reuse them — the accurate understanding is not that pooling has become inefficient, but that threads have become light enough that the pooling optimisation is unnecessary. And underneath virtual threads, the JDK’s scheduler runs a set of carrier threads (OS threads) numbering roughly the core count, as a work-stealing ForkJoinPool.2 In other words, the shape of “handle a huge amount of concurrent work with a small pool of OS threads” is preserved; only the management of that pool has moved from the developer’s hands to the JVM. It’s fair to say Java reaches the same destination as .NET’s async/await, which returns its thread to the pool at the point of await, without changing the shape of the code.
4. Reducing Shared Mutable State — Partitioning, Immutability, Concurrent Collections, and Queues
Contention arises only when “multiple threads” and “shared mutable data” both exist. The number of threads is determined by requirements, so what design can cut down is the shared part. There are three families of technique — partitioning, making things immutable, and handing data off — and here is how you write them in Java.
Partition it. For parallel aggregation, rather than having each thread write to a shared total variable, have each thread build a partial result and merge them at the end. Parallel stream’s reduce / collect provide exactly this shape as a framework, and LongAdder, discussed below, is likewise an implementation of the partitioning strategy — internally splitting into cells to spread out contention, and summing them when read. Reducing how often you write to shared state comes before writing synchronisation correctly.
Make it immutable. Build data with record and immutable collections (List.copyOf / Map.copyOf) that is never rewritten after construction, and you can share it without synchronisation. For configuration and master data, the standard pattern is: when you need to replace it, build a new object and swap out a volatile reference. However, “looks read-only” and “is immutable” are different things. A record’s accessors return the raw references of its components, and List.copyOf’s copy is also shallow (it does not duplicate the element objects), so if the elements are mutable, anyone holding an alias can rewrite the contents, and contention remains. It is only safe to share without synchronisation when the entire object graph — including the elements — is immutable. If mutable elements are involved, either pass a deep copy or push the elements toward record / immutable types as well.
Use the compound operations on concurrent collections. For a ConcurrentHashMap’s “create it if absent, then insert” pattern, use computeIfAbsent. This method executes the whole call atomically, and if the key is absent, the mapping function is called exactly once within that single call.8 The guarantee differs from .NET’s ConcurrentDictionary.GetOrAdd (whose factory can run more than once under contention) — a point that people moving between the two languages easily confuse. It is not, however, “exactly once over the life of the key”. If the function returns null or throws, no mapping is registered, and the function runs again on a subsequent call (the same applies if the entry is removed after registration). If your initialisation cannot tolerate duplicated side effects, design it so the function succeeds and returns non-null. As the price of being atomic, though, some updates from other threads are blocked while the computation runs, so keep the mapping function short, and never update this same map from inside the function (a detected recursive update can throw IllegalStateException).8
// The standard pattern for a frequency counter: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
Hand data off through a queue. Route the flow of data between threads through BlockingQueue. With an ArrayBlockingQueue given a capacity, put blocks when it is full, giving natural back-pressure — the same shape as the bounded channel in the .NET edition. Even in the virtual-thread era, this design of drawing a clear boundary between producer and consumer remains effective.
5. Locking Discipline — synchronized and ReentrantLock
5.1. What to Lock On, and What Not to Do While Holding a Lock
Think of the unit of locking not as a “section of code” but as “data”. Map one lock object to each set of mutable data you want to protect, and take that same lock at every place that touches that data — the reality of a race bug is usually that this mapping has broken down somewhere. Avoid synchronized(this) and synchronized(SomeClass.class), because outside code can lock the same object; instead, pair the data you want to protect one-to-one with a private final Object lock = new Object(); that is never exposed outside.
Two more disciplines on top of that. First, don’t do anything slow, or anything that touches the outside world, while holding a lock. I/O, calling listeners, or running unknown code while still holding the lock both extends how long you hold it and risks the callee trying to take another lock, creating the circular wait from Figure 2. Second, fix the acquisition order for multiple locks. Where you take two or more locks, make it a rule that every thread takes them in the same order, and for places where you cannot guarantee the order, prepare a “give up and retry if you can’t get it” path with tryLock(timeout), discussed below.
synchronized is sufficient for “short, simple exclusion”. Move on to ReentrantLock once you need the following.
- Timed acquisition via
tryLock(timeout)(turning a permanent hang into a failure you can log and handle) - Fairness policies, multiple
Conditions, or when you want to split acquiring and releasing a lock across different methods
When using ReentrantLock, never break the pattern of try immediately after lock() and unlock() in a finally block (Java has no equivalent to C++’s RAII, so this pattern is the entire discipline).
5.2. Virtual Threads and Pinning — What Changed in JDK 24
When virtual threads were first introduced (JDK 21-23), there was a constraint whereby blocking inside a synchronized block pinned the virtual thread to its OS thread (it could not release the OS thread, losing the benefit of scale), and replacing frequent or long-running blocking sites with ReentrantLock was recommended.3 That constraint was resolved when JDK 24’s JEP 491 rewrote the monitor implementation, and synchronized no longer pins virtual threads.4 If you’re on JDK 24 or later, mechanically replacing synchronized as a pinning countermeasure is no longer necessary. It’s worth checking whether your organisation’s older guidelines are still stuck at the JDK 21-era warning.
5.3. Where Atomics and volatile Fit
Atomic updates to a single variable are handled by AtomicInteger / AtomicLong / AtomicReference (or, for statistics that are only ever incremented at high frequency, the contention-resistant LongAdder). volatile guarantees visibility and ordering (happens-before), not atomicity for compound operations.5 The same conclusion as the .NET and C++ editions holds in Java too: use atomics for flags and single values, locks for compound state, and don’t try to make volatile do it alone.
6. Designing How to Stop — Interruption as a Common Language
6.1. The Etiquette of interrupt
Stopping and cancellation in Java are unified around interruption. t.interrupt() sets the target thread’s interrupt status, and if the target is blocked in sleep / wait / join or the like, it throws InterruptedException to wake it immediately (at which point the interrupt status is cleared).6 Thread.stop / suspend / resume, the forcible mechanisms of the past, are fundamentally unsafe, so calling them now results in UnsupportedOperationException.6
flowchart TB
OWNER["Caller stops it - calls t.interrupt"] --> ST["Interrupt status is set"]
ST --> A["Thread doing computation:<br/>polls Thread.interrupted in a loop"]
ST --> B["Blocked in sleep / wait / join:<br/>InterruptedException fires and wakes it immediately<br/>status is cleared"]
A --> E["Clean up and finish on its own"]
B --> C{"What does the catch do?"}
C -->|"Can finish on its own"| E
C -->|"Cannot finish - e.g. inside a library"| R["Thread.currentThread.interrupt<br/>restores the status and leaves the signal"]
R --> E
Figure 4: Cooperative stopping via interruption. Swallowing InterruptedException makes the stop signal vanish — once you catch it, the choice is either “finish” or “restore”
There is just one discipline you need to remember in practice: never write code that catches InterruptedException and does nothing. If you can finish within your own responsibility, finish there; if you can’t, restore the status with Thread.currentThread().interrupt() and pass the signal on to your caller (see the FAQ).
6.2. The Two-Stage Shutdown of ExecutorService
ExecutorService’s shutdown APIs sit on top of the interruption model. shutdown() stops accepting new tasks and lets already-submitted tasks run to completion; shutdownNow() attempts to stop tasks that are running. As an interface specification this is best-effort, and it is explicitly documented that a standard implementation (such as ThreadPoolExecutor) typically cancels via Thread.interrupt() — meaning a task that doesn’t respond to interruption will not stop even with shutdownNow, and if you’re using a custom Executor implementation, you need to check its documentation to see how it cancels (whether it sends an interrupt at all).1 The standard stopping pattern shown by the official documentation is the following two-stage pattern.1
/** True once 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. Tasks that were dropped before running are
// returned, so mark their Futures as cancelled to wake callers blocked 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 did not complete. Report it in a form distinguishable 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; // This path too may leave the shutdown incomplete
}
}
flowchart TB
S["shutdown<br/>stops accepting new tasks"] --> W1{"awaitTermination<br/>waits for completion"}
W1 -->|"finishes within the deadline"| DONE["Shutdown complete"]
W1 -->|"times out"| NOW["shutdownNow<br/>sends interrupt to running tasks<br/>whether it responds is up to the task"]
NOW --> W2{"awaitTermination<br/>waits again"}
W2 -->|"completes"| DONE
W2 -->|"still not finished"| LOG["Log it as an anomaly<br/>suspect a task that ignores interrupts"]
Figure 5: The two-stage shutdown of ExecutorService. A staged design of “wait politely → request via interruption → if it still doesn’t finish, observe it as an anomaly”
There is one limitation to cancelling the Runnables that shutdownNow() returns. What comes back is the object that was sitting in the execution queue — for a plain submit that is the very FutureTask handed to the caller, but for tasks submitted through a wrapper such as ExecutorCompletionService, it is the wrapper inside the queue, a different object from the caller’s Future. In that configuration, the cancellation above will not complete the caller’s Future, so design it to keep your own list of Futures at submission time and cancel those on shutdown (or return the dropped tasks to their owner).
close() (AutoCloseable), available from JDK 19 onward, packages “call shutdown and wait for completion” into a form you can write with try-with-resources, and try (var executor = ...) combined with the virtual-thread newVirtualThreadPerTaskExecutor is the modern basic shape.1 However, close() is not a substitute for the two-stage pattern above. Because it waits for completion with no timeout, if even one task doesn’t respond to interruption or never finishes, the thread trying to close it blocks forever. It is a tool suited to scopes where the tasks are finite and guaranteed to run to completion (submit it right there, wait for it right there); for places like an application’s shutdown path, where you want it to always finish within a bounded time, use the timed two-stage pattern instead. Cancelling an individual task is likewise done via interruption, with Future.cancel(true).
7. The UI Thread — Swing’s EDT
Desktop applications, regardless of language or framework, follow the rule that the UI belongs exclusively to the thread that manages it. In Swing, that exclusive thread is 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. Request screen updates from other threads through SwingUtilities.invokeLater to the EDT, and conversely, since running long operations on the EDT freezes the UI, push heavy work out to a worker thread via SwingWorker or similar.7 JavaFX follows the same shape: UI updates are requested on the application thread via Platform.runLater.
8. Verification and Debugging — Thread Dumps as a Weapon
You cannot expect testing to find race bugs. Ordinary tests count a run where contention just happened not to occur as a success. Think of the defence in three layers.
The first line of defence is design. In review, check with a table: which mutable data is shared, which lock protects each item (the mapping from Section 5.1), whether the lock acquisition order is unique, whether any catch block is swallowing InterruptedException, and whether the stop path (shutdown/interruption) reaches every task.
Second, make good use of thread dumps. Java has a standard tool for capturing “the state of every thread at this frozen instant”: jstack (or jcmd <pid> Thread.print) prints stack traces, and the -l option adds lock information as well.9 Note that this traditional dump format is for platform threads; it does not include your application’s virtual threads. When tracing a blocked request in a configuration that uses virtual threads (Section 3), use jcmd <pid> Thread.dump_to_file -format=json <file>, which can dump virtual threads too.2 The basic procedure for investigating a hang is to take two or three dumps a few seconds apart and cross-check which lock each idle thread is waiting on, and who is holding that lock. If you log tryLock(timeout) timeouts (Section 5.2), you can even automate the trigger for taking a dump.
Third, shake things loose under load. Stress testing — running for a long time with more parallelism than you have cores, randomising processing order, injecting artificial delays — is a practical way to make it more likely you’ll draw the “hit” of a race condition on a development machine. Run at least one test with production-scale data volume and thread count before release.
9. Where Java Concurrency Is Heading — Structured Concurrency
A quick look half a step ahead, to finish. Built on the assumption of virtual threads, Structured Concurrency (StructuredTaskScope) — which treats multiple subtasks as a single unit of work and structures failure propagation and cancellation — is under development and, as of August 2026, still a preview feature. It was revised into an API form based on StructuredTaskScope.open() in JDK 25’s fifth preview (JEP 505), and continues into a sixth preview (JEP 525) in the current JDK 26.1011 Meanwhile, Scoped Values, immutable context sharing that solves the problems with ThreadLocal, was finalised in JDK 25.12 This article’s principles — clear task boundaries, immutable sharing, cooperative stopping — align with the direction these new APIs are heading, too.
10. Summary — the Java Checklist
- Is there any
new Threadleft in business code (is it built onExecutorService/ virtual threads)? - Are I/O-bound and CPU-bound work routed to different execution mechanisms (the branch in Figure 3)?
- Are virtual threads not being pooled, and is concurrency limited with
Semaphore? - Is shared data immutable (
record/List.copyOf), or built on the tools injava.util.concurrent? - Is there no
synchronized(this)or locking on a publicly exposed object? - Are you using
ConcurrentHashMap’s compound operations (computeIfAbsent, etc.) and keeping the mapping function short? - Are you not expecting atomicity from
volatile(are counters using the Atomic classes /LongAdder)? - Is there not a single catch block swallowing
InterruptedException? - Does stopping an
ExecutorServicefollow the timed two-stage pattern (and are places usingclose()limited to scopes where task completion is guaranteed)? - Are Swing/JavaFX UI updates consolidated onto the EDT / application thread?
Java is one of the languages best equipped with concurrency tools, and the arrival of virtual threads has opened up a path to scaling straightforward synchronous code without rewriting it. That’s exactly why correctly grasping the division of labour among the tools — which one is for throughput, which is for exclusion, and what signals a stop — is the substance of multithreading design in Java.
Related Articles
- Practical Multithreading Best Practices: .NET Edition — What to Decide Before You Add More Threads
- Practical Multithreading Best Practices: C++ Edition — Eliminating Accidents by Structure with RAII and jthread
- Practical Multithreading Best Practices: C Edition — Writing Safely the Win32 API Way
- A Practical Decision Table for C# async/await - Task.Run and ConfigureAwait
Related Consulting Areas
KomuraSoft LLC handles multithreading design reviews for Java business systems and batch processing, root-cause investigation of concurrency-related defects such as shared-state corruption and “occasionally won’t stop / hangs” symptoms (thread dump analysis), and technical consulting on adopting virtual threads.
References
-
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 tasks that were awaiting execution, though the typical implementation cancels via Thread.interrupt(), offering no guarantee beyond best-effort, so a task that does not respond to interruption will not terminate; on being able to wait for completion with awaitTermination; on close() (AutoCloseable, from Java 19 onward) calling shutdown and waiting for completion, usable with try-with-resources; and on the shutdown → awaitTermination → shutdownNow two-stage shutdown being shown as a usage example. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
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 request, one thread” synchronous code unchanged; 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), with traditional thread dumps not including virtual threads. ↩ ↩2 ↩3 ↩4 ↩5
-
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 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-running sites with ReentrantLock was advised; and on being able to detect pinning with -Djdk.tracePinnedThreads. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. On the JVM’s monitor implementation having been rewritten for JDK 24 to support virtual threads, so that blocking inside a synchronized block or method no longer pins a virtual thread to its carrier thread; and on this meaning the JDK 21-23-era countermeasure of “replacing synchronized with ReentrantLock” is, in principle, no longer necessary. ↩ ↩2
-
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
-
Oracle, Thread (Java SE 21 & JDK 21 API). On Thread.stop / suspend / resume being fundamentally unsafe (locks are released in an inconsistent state and broken objects become visible; suspend can invite 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 (which clears the interrupt status); and on the difference in how interrupted() and isInterrupted() treat that status. ↩ ↩2 ↩3
-
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, be done on the EDT; on requesting tasks on the EDT from other threads via SwingUtilities.invokeLater / invokeAndWait; and on tasks running on the EDT needing to finish quickly. ↩ ↩2
-
Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). On computeIfAbsent’s entire method call 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 itself, with a detected recursive update resulting in IllegalStateException; and on a retrieval operation (get) not blocking, with a happens-before relationship holding between an update for a given key and a subsequent retrieval. ↩ ↩2 ↩3
-
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. ↩
-
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 revised into a form opened via a static factory method (open); and on its being, as of JDK 25, a fifth preview and not yet a finalised feature. ↩
-
OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). On structured concurrency continuing as a sixth preview in JDK 26 as well — that is, as of August 2026, still a preview feature in the current JDK, requiring preview features to be enabled to use it. ↩
-
OpenJDK, JEP 506: Scoped Values. On Scoped Values having been finalised 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 with ThreadLocal (mutability, lifecycle management, and inheritance cost). ↩
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Practical Multithreading Best Practices: C Edition — Writing Safely the Win32 API Way
The established approach to multithreading in C with Win32 is thread creation via _beginthreadex, SRW locks and condition variables, Inte...
Practical Multithreading Best Practices: C++ Edition — Eliminating Accidents by Structure with RAII and jthread
In C++, multithreading is a world where a data race is undefined behaviour. This article works through the std::thread destructor trap, d...
Practical Multithreading Best Practices: .NET Edition — What to Decide Before You Add More Threads
A practical rundown of the design rules that keep multithreaded .NET/C# code from occasionally crashing or hanging: ride on Task instead ...
Volume Shadow Copy (VSS): The Mechanism and the Practice — Why Backup Software Can Copy Files That Are Still in Use
Files that are in use can't normally be copied because of sharing violations — so how does backup software manage it? This article explai...
When Not to Move a Windows App to the Web: A Decision Table and the Practical Answer of Splitting
Requests to move in-house Windows apps to the web are increasing, but for apps built around device integration, local file processing, of...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Bug Investigation & Long-Run Failures
Topic page for intermittent failures, communication diagnosis, long-run crashes, and failure-path test foundations.
Where This Topic Connects
This article connects naturally to the following service pages.
Bug Investigation & Root Cause Analysis
We investigate difficult production issues such as intermittent failures, long-run crashes, leaks, and communication stoppages.
Technical Consulting & Design Review
We help clarify design direction, architectural boundaries, lifetime ownership, and how to handle legacy Windows assets.
Frequently Asked Questions
Common questions about the topic of this article.
- If we have virtual threads now, do we no longer need a thread pool (ExecutorService)?
- It depends on the use case. Virtual threads are a mechanism for running huge numbers of I/O-wait-dominated tasks; they don't make code run faster, they raise throughput. For I/O-bound work, use one virtual thread per task (Executors.newVirtualThreadPerTaskExecutor) and never pool virtual threads. On the other hand, for parallelising computation that maxes out the CPU, a pool of platform threads limited to roughly the core count (or a parallel stream) is still the right tool, as before. And if you want to cap the number of concurrent connections to an external service, the virtual-thread-era recommendation is to limit it with a Semaphore rather than with pool size.
- Which should I use, synchronized or ReentrantLock?
- For short, simple exclusion, synchronized is enough, and the code stays concise. Choose ReentrantLock when you need features such as timed acquisition via tryLock, a fairness policy, or multiple Conditions. There's a historical caveat when combining this with virtual threads: JDK 21-23 had a problem where blocking inside a synchronized block pinned virtual threads to their OS thread, so replacing frequent or long-running blocking sites with ReentrantLock was recommended. JDK 24 (JEP 491) rewrote the monitor implementation and resolved this constraint. From JDK 24 onward, you don't need to replace synchronized for pinning reasons.
- Does adding volatile make something thread-safe?
- No. Java's volatile creates a happens-before relationship between a write to that variable and a read of it, guaranteeing visibility (that the latest write is seen by other threads) and ordering, but it does not guarantee atomicity for a compound operation such as "read, compute, write back". Increment a volatile int counter with ++ from multiple threads and additions get lost. Use AtomicInteger / AtomicLong (or LongAdder for high-frequency aggregation) for counters, and a lock when protecting several variables together. volatile is appropriate almost only in situations like a simple state flag — where one thread writes and the others only read.
- Is it all right to catch InterruptedException and ignore it?
- No. Interruption is Java's standard signal for stopping and cancellation, and swallowing it creates a thread that won't stop. By the time InterruptedException is thrown, the interrupt status has already been cleared, so if you can't finish the work yourself, either restore the status with Thread.currentThread().interrupt() to leave the signal for your caller, or rethrow the exception as-is. An empty catch block that does nothing is a classic cause of bugs where shutdown doesn't take effect or shutdownNow gets ignored.
- Can't I stop a thread with Thread.stop?
- No, you can't. Thread.stop is fundamentally unsafe (it releases locks while leaving them in an inconsistent state, exposing broken objects to other threads), so it has long been deprecated, and calling it in current Java now throws UnsupportedOperationException. The same applies to Thread.suspend / resume. The only legitimate way to stop a thread is cooperative stopping via interruption. If you're using ExecutorService, shutdown merely 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 (the standard implementation works via interruption).