An online shop needs two pieces of information before it can show an offer: whether a product is available and when it could arrive. Asking the stock service and then the shipping service is simple. Asking both before waiting for their answers can reduce the wait. Giving a complicated calculation to several CPU cores solves a different problem.
That distinction is the foundation of concurrency versus parallelism. It also explains why adding threads sometimes helps, sometimes changes nothing, and sometimes overloads the database.
This guide starts with the vocabulary, then builds three small Java experiments and a Spring Boot endpoint. You need basic Java methods and classes; no previous threading knowledge is assumed. All supplier calls are local simulations. The downloadable project includes the complete sources, tests and editable diagrams.
1. Why this matters in November 2026
The relevant trend is making blocking Java services easier to scale while making task ownership more explicit. Three developments explain the choice of topic:
- Virtual threads are part of modern Java’s standard toolkit, available without preview flags since Java 21. They are especially relevant when many tasks wait for I/O. Their benefit is inexpensive waiting at scale, rather than faster arithmetic. Oracle’s virtual-thread guide
- Spring supports this execution model directly. Boot can configure virtual-thread task execution, and Spring’s February 2026 MVC investigation also examines virtual threads in its particular benchmark. Those measurements describe that sample, not a universal speedup for every application. Spring Boot task execution, Optimizations in Spring MVC
- Structured Concurrency remains a preview API in Java 27. It aims to give related subtasks a shared lifetime and clearer failure handling. It is useful context, but the beginner lab below uses stable executor and future APIs. JDK 27 release notes
As of 11 November 2026, Spring’s documentation lists Boot 4.1.2 as stable and 4.2.0-RC2 as a preview. RC2 was announced on 6 November. The lab deliberately pins 4.1.2 and compiles for Java 21. This is a reproducible teaching baseline, not advice to postpone future maintenance updates. Spring documentation, RC2 announcement
2. Concurrency, parallelism and asynchronous work
Concurrency means organizing multiple tasks whose lifetimes overlap. The program can make progress on one while another is unfinished. On a single CPU core, a scheduler can alternate between them. At a particular instant, that core still executes only one task’s instructions.
Parallelism means executing work at the same time on multiple processing resources. Two independent calculations can use two CPU cores simultaneously. A concurrent program may execute in parallel when the hardware and scheduler allow it; concurrency itself does not promise this.
Think of a cook preparing two meals. While one dish is in the oven, the cook can chop vegetables for the other: overlapping tasks with one active cook. Two cooks chopping different vegetables at the same time illustrate parallel work. Real kitchens also have limited ovens, pans and worktops—the equivalents of constrained downstream resources.
Asynchronous describes a calling relationship: the caller can receive a handle and continue before the operation finishes. That operation might later run on another thread, wait for an event, or already be complete. Synchronous means the caller waits for the result before continuing. Neither term tells you how many CPU cores are executing instructions.
Conceptual sketch: equal-looking blocks are teaching symbols, not measured timings. Waiting periods and scheduling overhead are omitted.
A program can be concurrent with one thread, for example by processing events from several unfinished operations. Multiple threads are one way to implement concurrency. They are not its definition.
2.1 What exactly is waiting?
When a stock request is waiting for an external system, three different things matter. The shop’s logical operation is unfinished. The calling thread may be blocked until an answer arrives. The CPU can meanwhile run something else. “The request is waiting” does not mean that the processor must sit idle.
This makes blocking and CPU-intensive work fundamentally different. Calculating a large report keeps a processor busy. Waiting for a remote report download usually does not. Both take elapsed time, but increasing CPU capacity addresses only part of the second operation.
Nonblocking I/O allows a program to initiate an operation and later process its completion without holding a thread in a blocking call for the whole wait. An event loop can coordinate many such operations. That is another way to organize concurrency. A beginner can therefore encounter both a blocking call running on a virtual thread and a nonblocking operation coordinated through events. Both can support many unfinished requests, while their programming models differ.
For the exercises below, keep two questions separate: “Can the caller continue?” and “What is executing on the CPU right now?” A future can let the caller continue even if the task is still queued. A virtual thread can be blocked while another thread uses the processor. The words asynchronous, concurrent, nonblocking and parallel describe related but different properties.
2.2 Follow the same two tasks through three schedules
Assume task A and task B each need six small units of CPU work. There is no I/O wait in this particular model. With one core and sequential execution, the schedule completes all six A units, then all six B units. With one core and concurrent execution, it alternates A and B. Both tasks become partially complete early, but the core still executes twelve units in total. Interleaving alone has not increased its capacity.
With two cores, one can execute A while the other executes B. The model finishes after six time slots because both cores contribute work. That idealized halving assumes independent tasks, equal work and no scheduling or communication overhead. Real programs rarely satisfy those assumptions exactly. The animation makes the schedules visible; it is not a measured Java benchmark.
Teal is task A; orange is task B. Each task requires six work units. Filled cells show executed work and the outlined cell is the current slot. All panels share one clock. Press Play to start. The video has pause, seek and replay controls; the interactive version can also advance one slot at a time. The static poster shows slot 6.
Download the interactive animation (ZIP). Extract it and open concurrency-parallelism-animation.html in a browser. It includes playback, step, timeline and speed controls, respects reduced-motion preferences on startup, and makes no network requests. You can also download the animated GIF.
3. Tasks, threads, cores and executors
| Term | Beginner meaning | Shop example |
|---|---|---|
| Task | A unit of work you want done | Look up shipping information |
| Thread | An execution path that runs instructions | The path running that lookup |
| CPU core | Hardware that can execute instructions | A resource for calculating prices |
| Executor | A component that accepts tasks and arranges execution | Where you submit the lookup |
| Future | A handle for a result that may arrive later | A shipping result you can await |
| Latency | Time from starting an operation until its result | How long one shopper waits |
| Throughput | Work completed per time interval | Offers served per second |
Java’s ExecutorService separates submitting work from managing the execution mechanism. Its submit method returns a Future; get waits if the result is not ready. Executors also have a lifecycle: stop accepting work and finish or cancel outstanding tasks before discarding them. ExecutorService API, Future API
A platform thread is associated with an operating-system thread. The JVM schedules virtual threads onto carrier platform threads. During supported blocking operations, a virtual thread can release its carrier so other virtual threads can run. Virtual threads can also execute on multiple cores; they are not restricted to single-core concurrency. Oracle’s virtual-thread guide
3.1 How an executor, a task and a future fit together
Imagine leaving a repair job at a workshop. The job description is the task. The workshop’s dispatcher is the executor. A mechanic doing the work is an execution resource. Your collection ticket is the future: it identifies the result you will collect, not a second copy of the job.
In Java, submitting a task does not guarantee that it starts immediately. A busy executor may queue it. Receiving a future does not mean the task has completed. Calling get can wait, return a value, or report that the computation failed or was cancelled. Those are separate stages of one operation.
The main thread remains a participant too. It runs the submission code and later collects the answers. In our waiting example, it submits two tasks and then waits. During that wait, the worker threads can proceed independently. This is why seeing get in the source does not, by itself, mean the entire program is sequential: the position of the submissions matters.
4. Java experiment: overlap waiting
Our first experiment has two independent read operations. Each sleeps for 200 milliseconds to imitate waiting. Sleep is not network I/O, and this tiny demonstration is not a server benchmark.
The following methods come from WaitingDemo.java in the download:
public static String sequential(Callable<String> stock,
Callable<String> shipping) throws Exception {
return stock.call() + " / " + shipping.call();
}
public static String concurrent(Callable<String> stock,
Callable<String> shipping) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var stockResult = executor.submit(stock);
var shippingResult = executor.submit(shipping);
return stockResult.get() + " / " + shippingResult.get();
}
}
Callable<String> means “work that produces a string and may throw an exception.” The expressions () -> ... supplied by the demo are lambdas: small functions passed as values.
In the sequential version, the shipping call starts after the stock call returns. In the concurrent version, both tasks are submitted first. Retrieving the stock result first does not stop the shipping task from progressing. Reversing the order of result collection would not change which result belongs to which task.
For the deliberately simplified waiting model, sequential elapsed time is approximately 200 + 200 milliseconds; overlapping waits can approach max(200, 200) plus overhead. That improvement comes from overlapping independent waits. Replacing platform threads with virtual threads alone does not shorten a supplier’s response time.
One local run on OpenJDK 27 recorded approximately 412 ms sequentially and 214 ms concurrently. These are illustrative observations from one run, not performance guarantees. The tests verify overlap using a latch—a coordination tool that makes both tasks announce they have started—instead of asserting a fragile timing ratio.
The try statement closes the executor. For these finite tasks, closing waits for completion. This example has no deadline and does not automatically cancel the other task when one fails. Those limits matter before substituting real network calls. Executor lifecycle
4.1 Read the concurrent method in execution order
- Create an executor that starts a virtual thread for each submitted task.
- Submit the stock lookup and keep its result handle.
- Submit the shipping lookup and keep its separate handle.
- Wait for the stock result when constructing the return value. Shipping may already be running or finished.
- Retrieve shipping and combine the answers in the intended order.
- Leave the
tryblock and close the executor.
A common mistake is to submit stock, immediately wait for stock, and only then submit shipping. The second operation would not even exist while the first one waits. You would have added execution machinery while keeping the original sequential dependency.
Another mistake is to overlap operations that are not independent. If shipping needs the warehouse identifier returned by stock, that shipping request cannot start with its final arguments until the identifier is known. You may still overlap other independent work, but a thread cannot remove a genuine data dependency.
4.2 Result order is different from completion order
Suppose shipping completes in 50 ms and stock in 200 ms. Collecting stock first still produces “stock / shipping” after both are available. It does not force shipping to wait until 200 ms before doing its work. The lab explicitly tests this case by making the first result depend on a signal from the second task.
Console output can be misleading here. Messages from several threads may appear in different orders from run to run. A changed log order is not sufficient evidence of parallel execution, and an apparently tidy order does not prove sequential execution. Test the behavior you need: overlapping lifetimes, correct result association and handling of failures.
5. Java experiment: split CPU work
Now imagine calculating the sum of squares of the integers from zero up to a specified limit. There is no remote answer to wait for: the processor must perform the calculation.
CpuDemo.splitSum splits the range into two independent pieces, submits both and adds their results:
public static long splitSum(int size) throws Exception {
if (size < 0 || size > 1_000_000) {
throw new IllegalArgumentException("size must be 0..1000000");
}
int workers = Math.min(2, Runtime.getRuntime().availableProcessors());
try (var executor = Executors.newFixedThreadPool(workers)) {
int middle = size / 2;
var left = executor.submit(() -> sumSquares(0, middle));
var right = executor.submit(() -> sumSquares(middle, size));
return left.get() + right.get();
}
}
sumSquares(from, to) loops over the half-open interval: it includes from and excludes to. Casting i to long before multiplication keeps the multiplication in 64-bit arithmetic. The public method caps the exercise’s size so the supported sums fit in long.
Each task owns a local accumulator. Only the calling thread combines the completed results. Both implementations return 333332833333500000 for one million values.
With two available processors, the pool permits the pieces to execute in parallel. On one available processor, it uses one worker and the pieces run sequentially. Even with two workers, a speedup is not guaranteed: task submission, scheduling and result collection cost time, and this loop is small. The tests check numerical equivalence, including empty and odd-sized ranges. They do not claim to prove simultaneous hardware execution.
newFixedThreadPool bounds the number of active workers but uses an unbounded task queue. Our demo submits exactly two tasks. A service accepting unlimited requests needs a bounded queue or separate admission control. Executors API
5.1 Why dividing work has a limit
If a report spends one quarter of its time in a strictly sequential preparation step and three quarters in perfectly divisible calculations, using four workers for only the calculation gives an idealized total time of 0.25 + 0.75 / 4 = 0.4375 of the original. That corresponds to about 2.29 times the original speed, even before adding coordination overhead. Four workers do not automatically mean four times the speed.
This is an illustrative calculation, not a benchmark result. It explains why you should identify the part that can actually run independently. Reading input, allocating objects, combining partial results and accessing memory can become the next constraint. More workers may then make the program contend for the same resources.
For a first implementation, create a correct sequential version and use it as a reference. Split into a small number of independent pieces. Check that the combined result stays identical, including boundary cases. Only then measure whether parallel execution benefits a representative workload.
6. Java experiment: why shared state is dangerous
Suppose two tasks increment the same counter. An ordinary counter++ consists conceptually of reading the value, adding one and writing it back. The following interleaving loses an update:
| Step | Task A | Task B | Stored value |
|---|---|---|---|
| 1 | Reads 0 | 0 | |
| 2 | Reads 0 | 0 | |
| 3 | Writes 1 | 1 | |
| 4 | Writes 1 | 1 |
Two increments happened, but the final value is one. That is a race condition. It can happen through interleaving even without simultaneous CPU execution. Marking the field volatile does not turn a read-modify-write sequence into one atomic action.
For an independent counter, AtomicInteger.incrementAndGet() provides an atomic increment. Here is the complete safe method from CounterDemo.java:
public static int safeCount(int tasks) throws Exception {
if (tasks < 0 || tasks > 10_000) {
throw new IllegalArgumentException("tasks must be 0..10000");
}
var counter = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < tasks; i++) {
executor.submit(counter::incrementAndGet);
}
} // close waits for these finite tasks to finish.
return counter.get();
}
The lab also contains a deliberately broken version. A barrier makes both tasks read the old value before either writes, so the lost update is reproducible rather than a bug that appears only occasionally. The safe version retains all 1,000 increments in the demo. AtomicInteger API
An atomic counter does not make a whole business process atomic. “Check available stock, then reduce stock” is a multi-step invariant and may require a lock, an atomic database operation or an appropriate transaction. Prefer isolated task data and combining results where possible, as in the CPU example.
6.1 Thread safety belongs to an operation, not a class name
Two threads reading an immutable value are usually easier to reason about than two threads editing one shared object. In our offer example, each task produces its own result record. In the CPU example, each task computes its own sum. Neither needs to edit a shared business accumulator.
An individual thread-safe action also does not protect a whole sequence of actions. Imagine one item remains in stock. Two tasks can both check that the quantity is positive before either reserves it. Even if each quantity access is individually safe, the combined “check and reserve” rule is still exposed. Choose a mechanism that protects that whole invariant.
Remember that a Spring singleton service can be called by many requests. Keeping a mutable “current customer” or “current offer” in a field would mix request data in the same object. Keep request-specific values in method arguments and local variables, and deliberately design any shared state.
7. Spring Boot: virtual threads and @Async
Spring does not change the definitions above. It helps configure execution and connect application components. In the download, LabApplication uses both @SpringBootApplication and @EnableAsync. Its properties are:
spring.application.name=concurrency-lab
spring.threads.virtual.enabled=true
spring.main.keep-alive=true
With Java 21+ and no custom executor overriding the default configuration, Boot provides a virtual-thread SimpleAsyncTaskExecutor when virtual threads are enabled. The default embedded Tomcat request handling in this project also uses virtual threads; the HTTP test checks that directly. keep-alive keeps the JVM alive even when only daemon threads remain. Boot task execution, Boot virtual-thread configuration
The supplier component deliberately lives in a separate bean:
package com.homannsoftware.concurrency;
import java.util.concurrent.CompletableFuture;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class SupplierTasks {
public record Part(String value, boolean virtualThread) {}
@Async("applicationTaskExecutor")
public CompletableFuture<Part> stock() throws InterruptedException {
Thread.sleep(80); // Local simulation of a blocking supplier call.
return CompletableFuture.completedFuture(
new Part("In stock", Thread.currentThread().isVirtual()));
}
@Async("applicationTaskExecutor")
public CompletableFuture<Part> shipping() throws InterruptedException {
Thread.sleep(80);
return CompletableFuture.completedFuture(
new Part("Delivery tomorrow", Thread.currentThread().isVirtual()));
}
}
@Service makes it a Spring-managed component. @Async("applicationTaskExecutor") selects Boot’s executor by name. A call through Spring’s proxy is scheduled on that executor; the controller receives a future. Creating a completed future inside the method wraps the result produced by the worker.
The Part record carries the simulated answer and a diagnostic showing whether the worker was virtual. Records provide a compact representation of data with generated accessors such as value().
Here is the controller method and its result record; the complete class, imports and constructor are in the download:
public record Offer(String stock, String shipping,
boolean requestVirtual, boolean tasksVirtual) {}
@GetMapping("/offer")
public Offer offer() throws Exception {
var stock = suppliers.stock();
var shipping = suppliers.shipping();
CompletableFuture.allOf(stock, shipping).get(2, TimeUnit.SECONDS);
return new Offer(stock.join().value(), shipping.join().value(),
Thread.currentThread().isVirtual(),
stock.join().virtualThread() && shipping.join().virtualThread());
}
@GetMapping exposes an HTTP route. Both supplier calls happen before the group wait. After allOf completes successfully, reading each result with join does not wait for unfinished work. The browser receives one combined JSON response.
The supplier work is asynchronous relative to the controller. The HTTP interaction still waits for the offer. This is a synchronous MVC endpoint that coordinates asynchronous subtasks; it does not return a background-job identifier or stream a response.
A crucial Spring rule: in the default proxy mode, calling an @Async method from another method on the same object bypasses the proxy. Calling new SupplierTasks() also bypasses Spring. That is why the example injects the service into a different component. Spring’s @Async documentation
Also, CompletableFuture.supplyAsync(...) without an explicit executor uses its own default asynchronous execution facility, normally the common fork/join pool. Boot’s property does not redirect every Java concurrency API to virtual threads. CompletableFuture API
7.1 Two separate places where Spring can introduce concurrency
First, a server handles requests from different clients. Shopper A and shopper B may each have an unfinished HTTP request. That is concurrency across requests. Second, one request may start several subtasks, such as stock and shipping. That is concurrency within a request.
Enabling virtual request threads can change the execution resources used for the first case. It does not inspect your controller and automatically turn two successive supplier method calls into overlapping work. Our second case is explicit: the controller calls two proxied @Async methods before waiting for the group.
| Layer in this lab | Responsibility | What it does not decide |
|---|---|---|
| Embedded HTTP server | Runs the incoming request handler | Which business operations are independent |
| Controller | Starts both lookups and combines their answers | How the supplier would implement a real remote call |
| Spring async proxy | Submits annotated calls to the named executor | Whether the downstream system has spare capacity |
| Task executor | Arranges worker execution | Whether shared business data is consistent |
| Future group | Represents completion of both results | Cancellation or a useful HTTP error policy |
7.2 What the framework annotations mean
@SpringBootApplication starts the application’s component discovery and auto-configuration. @EnableAsync enables Spring’s asynchronous method handling. @Service registers the supplier component, and constructor injection supplies its managed proxy to the controller. @Async selects execution for a method call. @RestController and @GetMapping expose the HTTP interaction. Each annotation has a distinct role; none is a general “make everything parallel” switch.
The two diagnostic booleans answer where the request and supplier code actually executed. They do not show whether two CPU cores were simultaneously active. The difference matters when interpreting the demo: a successful virtual-thread flag confirms the execution model, while the latch tests confirm overlapping task lifetimes.
7.3 An asynchronous method is not a shared transaction
For ordinary thread-bound Spring transactions, the caller’s transaction does not automatically propagate into a newly started thread. If an asynchronous worker starts its own transaction, that is a separate boundary; it does not become one atomic unit with the caller’s work just because both methods carry annotations. Reactive transaction management uses a different context model. Spring Transactional API
For example, a controller that updates an order and separately launches an email task needs a deliberate policy for what happens if the order later rolls back. The email may already have been sent. Our read-only supplier simulation avoids that problem so beginners can first understand execution. Adding business side effects requires another design step.
8. UML and BPMN: what overlaps, and what waits?
The UML sequence diagram follows one HTTP request. Submission is ordered: stock first, shipping second. Their execution can overlap after submission. The controller waits before producing the combined answer.
The BPMN diagram expresses the same happy-path coordination as a process. A plus-marked parallel gateway starts two branches. The matching gateway synchronizes them: both answers are required before combining the offer. BPMN’s word “parallel” describes process branching; it does not promise two CPU cores execute simultaneously.
These are explanatory models. The supplied BPMN 2.0 XML is non-executable and includes diagram coordinates; it is not a deployed workflow. Timeout and failure paths are deliberately outside this normal-path diagram. Editable SVGs, PlantUML sequence source and BPMN XML are included in the download.
9. More concurrency also needs limits
Imagine a remote supplier permits 20 active calls. Starting 10,000 virtual threads does not increase that capacity. It may simply produce more waiting, memory use or rejected requests.
A semaphore can restrict active access to a resource. Acquire a permit before the call and release it in a finally block. The lab tests a two-permit semaphore with 20 submitted tasks and verifies that active calls never exceed two and all permits are returned. Semaphore API
That is still only one limit. Waiting for permits can itself accumulate an unbounded backlog. Real request handling also needs an admission policy: how much work can queue, how long it may wait and what response to give when full. A concurrency cap limits simultaneous work; a rate limit controls work over time. They solve different constraints.
The Spring example’s two-second get limits how long the controller waits for the group after submitting it. A timeout does not stop the tasks. CompletableFuture.allOf also does not provide fail-fast sibling cancellation, and CompletableFuture.cancel(true) does not interrupt its underlying computation. Future timeout semantics, CompletableFuture cancellation
Before replacing the local sleeps, add actual client connection/read or request deadlines, a cancellation policy and appropriate HTTP error mapping. An interrupt is a cooperative request, not a guarantee that arbitrary work stops. The tests distinguish a wait timing out from a task being interrupted through an executor future.
Finally, check which context crosses a thread boundary. Do not assume a caller’s transaction or thread-local state automatically follows an asynchronous task. The lab avoids database writes and transactions entirely. Production tracing, security context and transaction boundaries need explicit design and verification.
9.1 Race conditions, deadlock and overload are different failures
A race condition can produce an incorrect answer because the outcome depends on an unsafe interleaving. Our forced lost update is an example. A deadlock means tasks wait indefinitely on one another, so useful progress stops. For example, one task holds lock X and waits for Y while another holds Y and waits for X. Oracle’s classic deadlock tutorial illustrates this enduring concept, though its sample targets older Java. Oracle: Deadlock
A related trap occurs when every worker in a small pool waits for child tasks queued to that same pool. The parents occupy all execution slots; the children need a slot to finish. Avoid designing a task graph that requires unavailable execution resources to release the resources it already holds.
Overload is different again: new work arrives faster than the constrained system can finish it. An expanding queue can make everything look alive while response times become unacceptable. Virtual threads do not make these coordination or capacity problems disappear. Decide how much work to accept, which resource each limit protects and what should happen when the limit is reached.
10. A practical decision table
| Situation | Starting approach | What to verify |
|---|---|---|
| Two independent blocking reads | Submit both, then await both | Deadlines, result order, failures |
| Many blocking request handlers | Evaluate virtual threads | Throughput, tail latency, downstream saturation |
| Expensive independent calculations | Controlled CPU parallelism | Correct reduction, core budget, queue bounds |
| Tiny calculation | Keep the straightforward loop first | Whether scheduling overhead dominates |
| One operation depends on another | Preserve the dependency | Whether overlap is actually valid |
| Shared mutable business data | Isolate state or protect the invariant | Race conditions and consistency |
| Existing nonblocking reactive pipeline | Keep its execution model coherent | Avoid blocking its event-loop threads |
These are engineering starting points. Measure representative workloads, including slow and failing dependencies. Track high-percentile response times as well as averages, CPU use, active work, backlog and resource saturation. An attractive result from two sleeps says little about database-heavy production traffic.
11. Download, run and verify the examples
Download the Java and Spring concurrency lab
The ZIP contains a Maven project, three standalone Java experiments, the Spring application, tests and editable diagrams. Install a JDK 21 or newer and Maven 3.6.3 or newer. Open a terminal in the extracted directory:
mvn test
mvn package
java -cp target/classes com.homannsoftware.concurrency.WaitingDemo
java -cp target/classes com.homannsoftware.concurrency.CpuDemo
java -cp target/classes com.homannsoftware.concurrency.CounterDemo
java -jar target/concurrency-lab-1.0.0.jar
Open http://localhost:8080/offer. In this default configuration the response includes "requestVirtual":true and "tasksVirtual":true, alongside the stock and shipping answers. Stop the server with Ctrl+C. Restart with --spring.threads.virtual.enabled=false appended to the jar command to compare the platform-thread configuration. Both diagnostic booleans should then be false. They are learning aids, not a suggested public API design.
The README also explains how to compile and run the three plain-Java experiments without Maven. The Spring application needs Maven dependencies, which normally require network access on the first build. It needs no database, Docker, API key or paid service.
Verification for this article: 26 Maven test invocations passed with no failures, errors or skips on OpenJDK 27+35-2325, compiling with --release 21, using Maven 3.9.11 and Boot 4.1.2. The tests cover task overlap, result ordering, exception propagation, numerical boundaries, deterministic lost updates, atomic increments, timeout behavior, cooperative interruption, semaphore limits, a Spring proxy and real embedded-server HTTP responses. The reader ZIP was independently extracted and its tests and demo commands rerun. The packaged server was also exercised with virtual threads enabled and disabled.
A separate Java 21 runtime was not used. No sustained-load benchmark, external supplier integration, database transaction, deployment or workflow-engine execution is claimed. Diagram references and coordinates were checked; the BPMN was not certified against the OMG XSD, and the UML image is an authored rendering consistent with its PlantUML source.
The useful first habit is to ask what each task spends time doing. Overlap independent waits. Split substantial independent CPU work when measurement supports it. Protect shared invariants, and give every task an owner, a limit and a failure policy.
11.1 Try these learning exercises
| Exercise | What to change or inspect | What to look for |
|---|---|---|
| Unequal waits | Change the two simulated waits to different values | Combined waiting approaches the slower independent call, plus overhead |
| Accidental serialization | In a disposable copy, collect the first result before submitting the second | The waits stop overlapping |
| One processor | Run CpuDemo with the JVM option -XX:ActiveProcessorCount=1 before -cp |
The result stays the same; the demo chooses one worker |
| Completion order | Read resultOrderDoesNotDependOnCompletionOrder |
A result’s identity does not depend on which task finishes first |
| Reproducible race | Read the barrier in forcedLostUpdate |
Both tasks read zero before either writes one |
| Thread model | Restart the Spring jar with virtual threads disabled | The response values stay the same while both diagnostics change |
These are optional experiments, not additional benchmark results. The supplied, unmodified lab is the tested baseline. If you change timings or scheduling, keep the correctness tests and expect elapsed-time observations to vary.
11.2 Check your understanding
Can two tasks be concurrent on one core? Yes. Their lifetimes can overlap while execution alternates. They do not need to execute instructions simultaneously.
Does a future create a thread? A future is a result handle. The executor or other mechanism arranging the computation decides how execution happens.
Why can a blocking endpoint still benefit from concurrency? Its independent subtasks can progress while the request waits for the combined answer. The HTTP caller still waits, but the internal waits may overlap.
Do virtual threads make a shared object safe? No. The same rules about atomic operations, visibility and protected invariants still apply.
When should I keep sequential code? When the work is small, has real dependencies, or measurement shows that added scheduling and coordination do not help. Straightforward code is also a valuable correctness reference.
12. Sources and further reading
Primary sources checked on 11 November 2026:
Oracle: Virtual Threads, Java 27 — execution model and suitable workloads.
Oracle: JDK 27 release notes — structured concurrency preview status.
Spring Boot: Task Execution and Scheduling — auto-configuration and executor selection.
Spring Boot: SpringApplication — virtual threads and keep-alive.
Spring Framework: Task Execution and Scheduling — @Async and proxy behavior.
Spring: Optimizations in Spring MVC, 25 February 2026 — framework performance investigation with stated context.
Spring Boot 4.2.0-RC2 announcement, 6 November 2026 — current release-candidate context.
Java API: ExecutorService, Executors and Future — task submission, queues, lifecycle and waiting.
Java API: CompletableFuture, AtomicInteger and Semaphore — asynchronous results, atomic counters and resource limits.
Spring: Transactional API — thread-bound and reactive transaction context.
Oracle: Deadlock — the general circular-wait concept; an older tutorial, not a current API guide.
For earlier steps, see the Homann Software guides to Java fundamentals and the Learning Hub.