Advanced Java (Multithreading, Collections, JDBC)

Chapter 10
Asynchronous and Modern Java Concurrency

Diploma in Programming Languages (Java & Python)
Advanced Java (Multithreading, Collections, JDBC)   •   Diploma

Asynchronous and Modern Java Concurrency

Introduction

Modern Java applications routinely perform several activities at once: they accept network requests, call databases and remote services, process files, and calculate results. Concurrency allows these activities to overlap, improving responsiveness and resource utilisation. However, concurrency also introduces coordination, failure-propagation, cancellation, memory-visibility, and resource-management problems.

Traditional Java concurrency is based largely on Thread, executors, locks, and futures. Modern Java adds higher-level tools: CompletableFuture, virtual threads, structured concurrency, and reactive streams. These tools are related but not interchangeable. A sound design begins by identifying the workload and its constraints rather than selecting the newest API automatically.

This chapter develops practical models for composing asynchronous workflows, compares platform and virtual threads, introduces structured and reactive approaches, and provides criteria for choosing between them.

Learning Objectives

By the end of this chapter, you should be able to:

  • compose sequential, parallel, conditional, and error-aware workflows with CompletableFuture;
  • distinguish platform threads from virtual threads and select suitable executors;
  • explain the goals and current status of structured concurrency in modern Java;
  • describe reactive streams, backpressure, and the Flow API;
  • choose a concurrency model for I/O-bound and CPU-bound workloads;
  • identify common hazards involving cancellation, blocking, shared state, and executor sizing;
  • design a maintainable concurrent service and evaluate its operational risks.

1. A Model of Asynchronous Work

A synchronous method normally blocks its caller until it returns or throws an exception. An asynchronous method returns a representation of work that may complete later. In Java, that representation may be a Future, CompletableFuture, a reactive publisher, or a task executed by a virtual thread.

Concurrency is not the same as parallelism. Concurrency means that multiple activities are in progress during an overlapping period. Parallelism means that activities execute simultaneously, usually on different processor cores. A single-core system can support concurrency through scheduling, but cannot execute two instructions at exactly the same instant.

A useful performance approximation for an I/O-bound system is that useful concurrency is related to the ratio between waiting time and service time:

N ≈ C × (1 + W/S)

Here, N is a rough target number of concurrently active tasks, C is the number of available processor cores, W is average waiting time, and S is average CPU service time. The estimate assumes similar tasks, stable load, and that waiting is mostly independent of CPU work. It is not a capacity guarantee: excessive concurrency can exhaust database connections, memory, file descriptors, or remote-service quotas.

For CPU-bound tasks, increasing concurrency substantially beyond the number of processor cores generally increases scheduling overhead rather than throughput. For I/O-bound tasks, many activities may wait, so a larger number of lightweight tasks can be beneficial—provided downstream resources can support them.

Important terminology

  • Task: A unit of work to be executed.
  • Executor: An object that accepts tasks and decides where and when to run them.
  • Future: A handle representing a result that may become available later.
  • Completion stage: A stage that performs an action after another asynchronous computation completes.
  • Non-blocking: The caller does not wait synchronously for the operation to finish. The underlying operation may still use resources or block internally.
  • Backpressure: A mechanism by which a consumer limits the rate at which a producer emits data.
  • Cancellation: A request that unfinished work should stop; it is not always guaranteed to interrupt the underlying operation.

2. CompletableFuture: Composing Asynchronous Workflows

CompletableFuture<T> implements Future<T> and CompletionStage<T>. It represents a computation that eventually completes with a value, completes exceptionally, or is cancelled. Unlike a basic Future, it supports declarative composition.

2.1 Creating stages

CompletableFuture<String> immediate =
        CompletableFuture.completedFuture("ready");

CompletableFuture<String> loaded =
        CompletableFuture.supplyAsync(() -> loadDocument());

CompletableFuture<Void> recorded =
        CompletableFuture.runAsync(() -> writeAuditRecord());

The no-argument supplyAsync and runAsync methods use the common ForkJoinPool. This is convenient for short, non-blocking work, but it is often inappropriate for blocking database or network calls. An explicit executor makes ownership and capacity clearer:

ExecutorService ioExecutor = Executors.newFixedThreadPool(32);

CompletableFuture<Customer> customer =
        CompletableFuture.supplyAsync(
                () -> customerRepository.find(id), ioExecutor);

The executor should be shut down by the component that owns it. In a long-running application, it is usually managed by the application framework or lifecycle container.

2.2 Transforming and combining results

thenApply transforms a successful result:

CompletableFuture<String> name =
        customer.thenApply(Customer::displayName);

thenAccept consumes a result and returns CompletableFuture<Void>:

customer.thenAccept(auditService::recordCustomerAccess);

thenRun runs an action without receiving the previous result:

customer.thenRun(() -> metrics.increment("customer.loaded"));

The Async variants request execution through an executor rather than performing the continuation in the thread that completes the previous stage:

customer.thenApplyAsync(Customer::displayName, ioExecutor);

The distinction is important. Non-Async methods may execute inline in the completing thread. This can be efficient, but a slow continuation can unexpectedly delay an I/O callback or worker. Explicit executors make performance assumptions visible.

When the next operation itself returns a CompletableFuture, use thenCompose to flatten the stages:

CompletableFuture<Order> order =
        customer.thenCompose(c -> orderService.findLatest(c.id()));

Using thenApply in this situation would produce a nested type such as CompletableFuture<CompletableFuture<Order>>.

Independent operations can be started together and combined:

CompletableFuture<Customer> c = customerService.find(id);
CompletableFuture<List<Order>> o = orderService.findForCustomer(id);

CompletableFuture<CustomerSummary> summary =
        c.thenCombine(o, CustomerSummary::new);

allOf completes when all supplied stages complete, but it does not directly return a typed list of results:

CompletableFuture<Void> all =
        CompletableFuture.allOf(c, o);

CompletableFuture<CustomerSummary> result =
        all.thenApply(ignored ->
                new CustomerSummary(c.join(), o.join()));

The join calls above do not normally block because all has already completed successfully. If one stage failed, all completes exceptionally and the continuation is not used.

anyOf completes when any supplied stage completes, whether normally or exceptionally. It returns CompletableFuture<Object>, so explicit conversion may be required.

2.3 Failure handling

Asynchronous errors are represented as exceptional completion. Three frequently used methods have different purposes:

CompletableFuture<String> safe = loadDocument()
        .exceptionally(ex -> "fallback")
        .whenComplete((value, ex) -> audit(value, ex));
  • exceptionally converts failure into a replacement value.
  • handle receives both the result and exception and can produce either a normal or replacement result.
  • whenComplete observes completion but normally preserves the original result or failure.

A recovery policy should distinguish expected failures from programming defects. Returning an empty result for authentication failure, data corruption, or cancellation may conceal serious problems. Record the cause, preserve correlation identifiers, and apply timeouts at service boundaries.

CompletableFuture<Response> response =
        client.call(request)
              .orTimeout(2, TimeUnit.SECONDS)
              .exceptionally(ex -> fallbackResponse(ex));

orTimeout completes the stage exceptionally after the duration. completeOnTimeout instead completes it with a fallback value. Neither automatically guarantees that a remote server has stopped processing the request.

2.4 Cancellation and blocking hazards

join() waits and throws an unchecked CompletionException; get() waits and declares checked exceptions. Calling either from a request thread, event-loop thread, or asynchronous continuation can defeat the purpose of asynchronous composition and may create thread starvation or deadlock.

Prefer composition:

CompletableFuture<String> combined =
        first.thenCombine(second, (a, b) -> a + ":" + b);

rather than:

String value = first.join() + ":" + second.join();

Cancellation propagates only according to the APIs and operations involved. A cancelled CompletableFuture may not cancel an already-running database query or HTTP request. Integrations should support explicit cancellation, deadlines, and resource cleanup where possible.

3. Platform Threads and Virtual Threads

A platform thread is an operating-system-backed Java thread. It is powerful and familiar, but each thread consumes finite native and JVM resources. Creating very large numbers of platform threads is usually unsuitable.

A virtual thread is a lightweight Java thread scheduled by the Java runtime. Virtual threads became a final feature in Java 21. They are intended primarily for high-throughput applications containing many tasks that spend substantial time waiting for I/O. They preserve the straightforward thread-per-task programming style while reducing the cost of maintaining blocked tasks.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> a = executor.submit(() -> httpClient.fetch("/a"));
    Future<String> b = executor.submit(() -> httpClient.fetch("/b"));
    return a.get() + b.get();
}

Virtual threads do not make CPU work faster. A computation still requires processor time, and millions of virtual threads performing CPU-intensive loops merely compete for the same cores. Use a bounded platform-thread pool, such as a fixed executor, for CPU-heavy stages.

Virtual threads also do not remove external limits. A service may create many virtual threads but still need to limit database connections, concurrent calls to a partner API, or memory-intensive operations. Semaphores, connection pools, rate limiters, and bulkheads remain important.

3.1 Pinning and compatibility

A virtual thread can be prevented from yielding efficiently in some circumstances, historically including certain uses of synchronized blocks or native code. Java versions continue to improve this area, so current documentation and profiling should guide decisions. Long-running synchronized sections are poor practice regardless of thread type; use small critical sections and appropriate concurrency primitives.

Blocking APIs are generally more practical with virtual threads than with platform-thread pools, but the operation must still be compatible with the runtime and external resource. A blocking call consumes a virtual-thread continuation and may occupy a carrier thread under particular conditions. Measure representative workloads.

3.2 Comparison

ConcernPlatform threadsVirtual threads
Creation costRelatively highRelatively low
Best fitCPU work, bounded workers, special thread affinityLarge numbers of mostly waiting tasks
Programming styleThread-per-task or pooledThread-per-task is practical
CPU throughputLimited by coresStill limited by cores
External resource limitsMust be enforcedMust still be enforced
Typical debuggingMature and familiarImproving; thread identity and observability still matter

4. Structured Concurrency

Unstructured concurrency allows a child task to outlive its initiating method, hide failures, or remain active after a request has ended. Structured concurrency treats concurrent subtasks as a lexical unit: a parent operation creates children, waits for them, handles their failures, and then closes their scope.

The goal is similar to structured programming. Just as a block makes control flow visible, a structured task scope makes the lifetime and ownership of concurrent work visible.

The StructuredTaskScope API has been delivered as a preview feature in recent Java releases and its exact API may change. It must therefore be compiled and run with the relevant preview options when applicable. The following illustrates the design, not a claim that every shown signature is identical in every release:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<Customer> customer =
            scope.fork(() -> customerService.find(id));
    Subtask<List<Order>> orders =
            scope.fork(() -> orderService.findForCustomer(id));

    scope.join();
    scope.throwIfFailed();

    return new CustomerSummary(customer.get(), orders.get());
}

The scope waits for children and closes them together. In a failure-oriented scope, one failed child can request cancellation of the others. This is often easier to reason about than manually creating futures, collecting them, propagating exceptions, and attempting cancellation.

Structured concurrency is particularly attractive for request-scoped fan-out: a request calls several services, combines their results, and should not leave abandoned work after the request fails. It also improves observability because the parent-child relationship can be represented in traces and diagnostic tools.

It is not a universal replacement for message queues or durable workflows. A task that must survive process failure belongs in a durable job system, not merely in a thread scope. Long-running independent activities may also require an explicit lifecycle rather than a request-local scope.

5. Reactive Streams and the Flow API

Reactive programming represents data and events as asynchronous streams. A stream may be finite or unbounded, and consumers react to arrivals rather than repeatedly polling. Java’s java.util.concurrent.Flow API provides interfaces corresponding to the Reactive Streams model:

  • Publisher<T> produces items;
  • Subscriber<T> consumes items;
  • Subscription connects them and supports demand and cancellation;
  • Processor<T,R> consumes one type and publishes another.

Backpressure is central. A subscriber calls request(n) to express how many items it can currently accept. A compliant publisher must not send more than the requested demand. This prevents a fast producer from overwhelming a slow consumer, though it does not solve every memory, latency, or downstream-capacity problem.

class LoggingSubscriber<T> implements Flow.Subscriber<T> {
    private Flow.Subscription subscription;

    public void onSubscribe(Flow.Subscription s) {
        subscription = s;
        s.request(1);
    }

    public void onNext(T item) {
        process(item);
        subscription.request(1);
    }

    public void onError(Throwable error) { log(error); }
    public void onComplete() { log("complete"); }
}

The subscriber must follow the protocol: receive one subscription, avoid requesting invalid non-positive demand, handle onNext, and respond to completion or error. In production, most applications use a mature reactive library rather than implementing a complete operator ecosystem directly with Flow.

Reactive approaches are valuable for high-volume streams, event pipelines, messaging, and applications where controlling demand is more important than preserving a simple blocking style. They can become difficult when developers mix blocking calls into event-loop threads, lose context across operators, or create complex error and cancellation paths. Virtual threads often provide a simpler alternative for request/response I/O, while reactive streams remain strong for continuous streaming and explicit backpressure.

6. Selecting a Concurrency Model

Selection should follow workload shape and lifecycle requirements.

Workload or requirementOften suitable modelMain caution
CPU-intensive transformationBounded platform-thread executorDo not create unbounded tasks; protect shared state
Many independent blocking I/O callsVirtual threads or bounded I/O executorLimit databases, APIs, memory, and file descriptors
Request fan-out with shared lifetimeStructured concurrency or composed futuresPropagate deadlines and cancellation
Continuous event streamReactive streamsImplement backpressure and avoid blocking event loops
Durable background workQueue and worker architectureThreads alone do not provide durability
Small independent asynchronous stagesCompletableFutureAvoid hidden executor and exception policies

A practical decision process is:

  1. Is the work CPU-bound, blocking I/O, or a continuous stream?
  2. Must child work finish with its parent request?
  3. Is backpressure required because production and consumption rates differ?
  4. What are the limits of downstream systems?
  5. How will failure, timeout, cancellation, tracing, and shutdown be handled?
  6. Can the design be tested deterministically?

Concurrency is an end-to-end property. Replacing a fixed pool with virtual threads does not increase a database’s connection capacity. Replacing callbacks with reactive operators does not automatically create correctness. The bottleneck and failure boundary must be analysed as a system.

7. Applied Case: Illustrative Order Summary Service

Background

An online retailer returns an order summary containing customer data, recent orders, and an optional recommendation list. The customer and order services are independent network calls. Recommendations are useful but not essential.

Problem

The original implementation calls the services sequentially. Average latency is approximately the sum of the two remote-call latencies, and recommendation failures sometimes delay the whole response. A redesign must reduce latency, apply a two-second deadline, avoid unbounded concurrency, and prevent abandoned work after the request ends.

Analysis

Customer and order retrieval are independent and can start together. If their average latencies are 120 ms and 180 ms, sequential remote waiting is approximately 300 ms, excluding local processing. Parallel execution has a lower bound near the larger call, approximately 180 ms, plus scheduling, network, and combination overhead. These are estimates, not guarantees; tail latency and failures must be measured separately.

Recommendations are optional, so they should have a separate timeout and fallback policy. The service must also respect connection pools and partner rate limits.

Approach

For a Java 21 application using straightforward blocking client APIs, a request-scoped virtual-thread approach is readable:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<Customer> customer = executor.submit(
            () -> customerClient.fetch(customerId));
    Future<List<Order>> orders = executor.submit(
            () -> orderClient.recent(customerId));
    Future<List<Recommendation>> recommendations = executor.submit(
            () -> recommendationClient.find(customerId));

    Customer c = customer.get(2, TimeUnit.SECONDS);
    List<Order> o = orders.get(2, TimeUnit.SECONDS);
    List<Recommendation> r;
    try {
        r = recommendations.get(300, TimeUnit.MILLISECONDS);
    } catch (TimeoutException ex) {
        r = List.of();
    }
    return new Summary(c, o, r);
}

A production implementation must address interruption, cancellation, partial failure, client-specific timeouts, logging, and resource cleanup. A structured-concurrency implementation may express the same parent-child lifetime more directly when the relevant preview API is enabled. A CompletableFuture implementation is also appropriate when existing clients already return futures or when stages must be reused in a larger composition.

Outcome

The redesign reduces avoidable sequential waiting, isolates optional recommendations, and makes the executor lifecycle request-scoped. Load testing should verify p95 and p99 latency, connection usage, error rates, cancellation behaviour, and memory consumption. A successful average-latency result is insufficient if tail latency or partner overload worsens.

Learning points

  • Parallelism is useful only for genuinely independent work.
  • Optional work should have a distinct reliability and latency policy.
  • Virtual threads simplify blocking I/O but do not remove capacity limits.
  • Structured lifetimes are safer than forgotten background tasks.
  • Performance claims require production-like measurement.

8. Best Practices, Governance, and Limitations

Best practices

  • Give every executor a clear owner, purpose, and capacity policy.
  • Prefer immutable results and message passing over shared mutable state.
  • Use deadlines, timeouts, and cancellation at external boundaries.
  • Separate CPU, blocking-I/O, and event-loop execution contexts.
  • Avoid blocking event-loop threads and asynchronous callbacks.
  • Bound queues and concurrency where overload is possible.
  • Preserve causes and correlation identifiers in asynchronous errors.
  • Test success, timeout, cancellation, partial failure, and shutdown paths.
  • Measure throughput, latency percentiles, queue depth, active tasks, rejection, and downstream saturation.

Governance and operational concerns

Concurrency bugs can expose data, duplicate financial actions, or violate ordering requirements. A retry must be designed with idempotency; otherwise a timeout may cause a second charge or duplicate order. Access control and audit events must not be silently lost in fire-and-forget tasks. Sensitive data should not be written into thread names, diagnostic logs, or tracing baggage.

Graceful shutdown requires rejecting new work, allowing bounded time for existing work, cancelling or draining queues according to policy, and closing clients and executors. A process that exits while asynchronous work is still pending must have a clearly defined outcome.

Contemporary direction

Modern Java increasingly supports a choice between simple blocking-style code on virtual threads and explicit asynchronous or reactive pipelines. Structured concurrency aims to make task lifetimes and failure relationships explicit, but its preview status means teams must monitor Java release documentation before standardising on it. Framework support, observability tooling, client-library behaviour, and deployment constraints are as important as the language feature itself.

Chapter Summary

Asynchronous Java programming is concerned with composing work, controlling resources, and making failure and lifetime explicit. CompletableFuture supports transformations, composition, parallel combination, recovery, deadlines, and cancellation, but careless use of join, hidden executors, or weak error policies can produce fragile systems. Platform threads remain useful for bounded CPU work and specialised execution. Virtual threads, final since Java 21, make large numbers of mostly blocking tasks practical without making CPU-bound work faster or removing downstream limits.

Structured concurrency groups child tasks under a visible parent lifetime and is especially suitable for request-scoped fan-out; its API status should be checked for the target Java release. Reactive streams model asynchronous data flow and provide backpressure, making them suitable for continuous or high-volume streams. The correct model depends on workload type, resource limits, lifecycle, failure semantics, and operational requirements. Professional concurrency design therefore combines language features with bounded resources, observability, testing, idempotency, and graceful shutdown.

Key Terms

CompletableFuture: A future and completion-stage abstraction for composing asynchronous computations.

CompletionStage: An interface describing actions triggered by completion of another computation.

Executor: A component that schedules and runs submitted tasks.

Platform thread: An operating-system-backed Java thread.

Virtual thread: A lightweight Java thread scheduled by the Java runtime.

Concurrency: Overlapping progress of multiple activities.

Parallelism: Simultaneous execution of activities.

I/O-bound workload: Work that spends substantial time waiting for input or output.

CPU-bound workload: Work whose principal cost is processor computation.

Structured concurrency: A model in which child-task lifetimes are bounded by a parent scope.

Reactive stream: An asynchronous stream with a protocol for demand, data, completion, and failure.

Backpressure: Consumer-driven control of producer emission rate.

Cancellation: A request to stop unfinished work.

Timeout: A maximum permitted waiting duration.

Executor saturation: A condition in which available worker or queue capacity is exhausted.

Fan-out: Starting multiple independent operations from one parent operation.

Fan-in: Combining results from multiple operations.

Idempotency: The property that repeating an operation has the same intended effect as performing it once.

Carrier thread: A platform thread that temporarily executes a virtual thread.

Bulkhead: An isolation limit that prevents one workload from consuming all shared capacity.

Review Questions

5 Short Answer Questions

  1. Distinguish concurrency from parallelism.
  2. What is the difference between thenApply and thenCompose?
  3. Why are virtual threads particularly useful for I/O-bound workloads?
  4. What problem does backpressure address?
  5. Why can join() be harmful inside an asynchronous workflow?

5 Long Answer Questions

  1. Explain how CompletableFuture can compose sequential, parallel, and error-recovery stages. Include suitable method names and examples.
  2. Compare platform threads and virtual threads in terms of cost, workload suitability, CPU limits, and external resource limits.
  3. Explain the principles of structured concurrency and compare them with unstructured task creation.
  4. Describe the Flow API and explain the roles of publisher, subscriber, subscription, and processor.
  5. Discuss a systematic process for selecting between futures, virtual threads, structured concurrency, reactive streams, and durable queues.

3 Analytical / Application Questions

  1. A service makes three independent blocking calls and then combines their results. Design two solutions—one using CompletableFuture and one using virtual threads—and identify the failure and timeout policies each requires.
  2. A reactive event consumer is slower than its producer. Explain how backpressure should operate and identify the risks of blocking the consumer’s event-loop thread.
  3. A CPU-intensive report generator is changed from a bounded platform-thread pool to an unbounded virtual-thread-per-task executor. Analyse the likely effects and recommend a safer design.

Multiple Choice Questions with Answers

Question 1

Which CompletableFuture method is most appropriate when a function returns another CompletableFuture and the result should be flattened?

A. thenApply

B. thenCompose

C. thenAccept

D. whenComplete

Correct Answer: B. thenCompose

Question 2

What is the primary intended advantage of virtual threads?

A. They make CPU-bound algorithms execute faster than processor cores allow.

B. They eliminate the need for database connection limits.

C. They support many mostly waiting tasks with lower thread-management cost.

D. They guarantee that all blocking operations become non-blocking.

Correct Answer: C. They support many mostly waiting tasks with lower thread-management cost.

Question 3

Which CompletableFuture method observes completion while normally preserving the original result or failure?

A. exceptionally

B. completeOnTimeout

C. handle

D. whenComplete

Correct Answer: D. whenComplete

Question 4

What does backpressure allow in a reactive stream?

A. A consumer can control how much data it is ready to receive.

B. A producer can ignore subscriber cancellation.

C. All stream processing becomes CPU-parallel automatically.

D. A stream can never encounter an error.

Correct Answer: A. A consumer can control how much data it is ready to receive.

Question 5

For a CPU-bound task, which executor strategy is generally most appropriate?

A. An unbounded number of virtual threads

B. A bounded executor with a worker count related to available cores

C. A new platform thread for every loop iteration

D. A reactive event loop that performs blocking computation

Correct Answer: B. A bounded executor with a worker count related to available cores

Question 6

What is a central goal of structured concurrency?

A. Allowing child tasks to outlive every parent operation

B. Making child-task lifetime and failure handling part of a parent scope

C. Replacing all durable background jobs with threads

D. Preventing any task from running concurrently

Correct Answer: B. Making child-task lifetime and failure handling part of a parent scope

Question 7

Why can using join inside an asynchronous callback be problematic?

A. It always cancels the future being joined.

B. It can block a worker or event-loop thread and cause starvation.

C. It converts every normal result into null.

D. It disables exception handling in the JVM.

Correct Answer: B. It can block a worker or event-loop thread and cause starvation.

Question 8

Which statement about virtual threads is correct?

A. They remove the physical limit imposed by CPU cores.

B. They are intended mainly to improve CPU instruction throughput.

C. They can simplify thread-per-task designs for many blocking I/O operations.

D. They guarantee unlimited database concurrency.

Correct Answer: C. They can simplify thread-per-task designs for many blocking I/O operations.

Question 9

In a fan-out/fan-in workflow, fan-in means:

A. Starting one task for every processor instruction

B. Combining results from multiple operations

C. Cancelling every child before it starts

D. Converting a platform thread into a virtual thread

Correct Answer: B. Combining results from multiple operations

Question 10

Which workload is the strongest candidate for reactive streams?

A. A continuous event pipeline requiring explicit demand control

B. A small calculation that completes immediately

C. A single immutable object construction

D. A local arithmetic expression with no asynchronous boundary

Correct Answer: A. A continuous event pipeline requiring explicit demand control

Advanced Java (Multithreading, Collections, JDBC)
Chapter 10 of 15