Virtual threads change the cost of waiting, not the capacity of databases or downstream APIs. This guide presents measurable Java backend development patterns for Spring services, JDBC pools, cancellation, and JFR.
Java Backend Development with Virtual Threads: Production Patterns
Java backend development: measure waiting before adding virtual threads
A useful java training exercise is to classify every request wait as CPU, database-pool acquisition, JDBC execution, HTTP I/O, or lock contention before changing executors. Virtual threads, finalized in Java 21, are most valuable when handlers spend substantial time waiting on interruptible I/O; they do not make a CPU-bound JSON transformation faster. Capture a two-minute Java Flight Recorder sample on representative load and inspect jdk.SocketRead, jdk.ThreadPark, jdk.JavaMonitorEnter, and jdk.VirtualThreadPinned in Java Mission Control.
jcmd $PID JFR.start name=virtual-thread-baseline settings=profile duration=120s filename=/tmp/baseline.jfr
# Open baseline.jfr in Java Mission Control and group events by stack trace.Use a virtual-thread-per-task executor for blocking units of work, rather than replacing a deliberately bounded CPU executor. The executor has no worker queue that creates backpressure: a burst can create many parked virtual threads, so downstream limits must remain explicit. Name threads so a JFR stack and a thread dump identify the operation that created them.
ThreadFactory factory = Thread.ofVirtual().name("customer-lookup-", 0).factory();
try (ExecutorService lookupExecutor = Executors.newThreadPerTaskExecutor(factory)) {
Future<Customer> customer = lookupExecutor.submit(() -> client.fetch(customerId));
return customer.get(300, TimeUnit.MILLISECONDS);
}Run jcmd $PID Thread.print -l during the same load test. Do not diagnose pinning merely by grepping for synchronized: monitor behavior has changed across JDK releases, while native calls and particular blocking paths can still matter. The recorded jdk.VirtualThreadPinned event and its stack trace are the evidence to act on.Spring Boot training: enable virtual threads without hiding executors
For supported Spring Boot applications, enable the framework-managed virtual-thread executors explicitly, then verify the effective configuration through Actuator rather than assuming every custom executor or server connector changed. The keep-alive setting matters because virtual threads are daemon threads; without a non-daemon keep-alive mechanism, a command-line or scheduled application can exit after its last platform thread ends.
# application.yml
spring:
threads:
virtual:
enabled: true
main:
keep-alive: true
management:
endpoints:
web:
exposure:
include: configprops,metricsCheck /actuator/configprops and issue a real concurrent request test. In a spring boot training project, this catches a common mistake: expecting the property to alter an executor created manually with Executors.newFixedThreadPool().Keep a separately named executor for outbound blocking calls so its lifecycle and usage are visible in code review. Spring can close the bean at shutdown through destroyMethod; this avoids leaked tasks when a pod receives SIGTERM. Do not use @Async as an accidental fan-out mechanism without identifying which executor it resolves to.
@Configuration
class OutboundExecutionConfig {
@Bean(destroyMethod = "close")
ExecutorService partnerIoExecutor() {
return Executors.newThreadPerTaskExecutor(
Thread.ofVirtual().name("partner-io-", 0).factory());
}
}
@Service
class PartnerGateway {
private final ExecutorService partnerIoExecutor;
PartnerGateway(ExecutorService partnerIoExecutor) {
this.partnerIoExecutor = partnerIoExecutor;
}
}Use the executor only around blocking boundaries such as an HTTP client call or file read; leave CPU-heavy image processing on a fixed-size executor sized from measured CPU saturation.Hibernate ORM and Spring Data JPA: protect the real bottleneck
With virtual threads, hibernate orm does not become safely unlimited: each active SQL statement still consumes a database connection, database worker capacity, locks, and memory. Set HikariCP's pool size from a budget, not from the number of virtual threads. For example, if a database permits 120 application connections, reserve 24 for migrations, administration, and failover, and run four pods, begin with (120 - 24) / 4 = 24 connections per pod; validate it against database CPU and lock waits.
# application.yml
spring:
datasource:
hikari:
maximum-pool-size: 24
minimum-idle: 4
connection-timeout: 250
validation-timeout: 1000
jpa:
open-in-view: falseA short connection-timeout turns pool exhaustion into a controlled error that can be mapped to 503, rather than allowing thousands of virtual threads to wait until request timeouts cascade. Track hikaricp.connections.active, hikaricp.connections.pending, database active sessions, and lock-wait time together.For spring data jpa, return a projection or DTO for read endpoints and fetch required associations inside the repository query. Disabling Open Session in View exposes lazy-loading mistakes early and prevents a database connection from being retained while serialization performs unrelated work. An EntityManager and Hibernate Session remain thread-confined; never share an entity or persistence context with a child virtual thread.
public interface OrderSummary {
UUID getId();
BigDecimal getTotal();
Instant getCreatedAt();
}
interface OrderRepository extends JpaRepository<Order, UUID> {
@Query("""
select o.id as id, o.total as total, o.createdAt as createdAt
from Order o
where o.customer.id = :customerId
order by o.createdAt desc
""")
List<OrderSummary> findRecentSummaries(UUID customerId, Pageable page);
}Passing a managed Order to another task is especially dangerous: a later lazy getter can run outside the original persistence context or race with session state. Pass immutable IDs or DTOs instead.Java microservices: deadlines, cancellation, and downstream bulkheads
In java microservices, virtual threads make parallel blocking calls cheap enough to expose a new failure mode: every incoming request can now wait on an already failing dependency. Give every outbound request both a transport timeout and a request-level deadline. Future.cancel(true) only helps when the client honors interruption, so configure the HTTP timeout too and test cancellation against the actual client library.
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofMillis(180))
.GET()
.build();
try (ExecutorService io = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Inventory> inventory = io.submit(() -> inventoryClient.fetch(request));
Future<Price> price = io.submit(() -> priceClient.fetch(productId));
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(250);
try {
Inventory i = inventory.get(Math.max(1, deadline - System.nanoTime()), TimeUnit.NANOSECONDS);
Price p = price.get(Math.max(1, deadline - System.nanoTime()), TimeUnit.NANOSECONDS);
return new ProductView(i, p);
} catch (TimeoutException e) {
inventory.cancel(true);
price.cancel(true);
throw new UpstreamTimeoutException(e);
}
}The shared deadline is intentional: two sequential get(250ms) calls can otherwise produce a 500 ms endpoint latency. Also note that CompletableFuture.orTimeout() completes its wrapper exceptionally but does not necessarily stop the underlying I/O task.Add a bulkhead at each dependency boundary and tune it from the dependency's proven concurrency, not from application thread count. Resilience4j's semaphore bulkhead rejects excess work before a virtual-thread pile-up reaches the partner service; expose rejected-call counts as an alertable metric.
resilience4j:
bulkhead:
instances:
catalog:
max-concurrent-calls: 40
max-wait-duration: 0msStart with max-wait-duration: 0ms for a latency-sensitive read path so overload fails fast. Load-test 20, 40, 60, and 80 concurrent catalog calls, then choose the highest value whose downstream p95 latency and error rate remain within its contract. A bulkhead is still necessary even if blocked virtual threads consume little heap.Java course benchmark: prove capacity gains with JFR and load tests
A serious java course benchmark compares a fixed platform-thread executor and a virtual-thread executor under the same request rate, payloads, database pool, JVM flags, and downstream latency injection. Use Vegeta or Gatling, record p50/p95/p99, error rate, Hikari pending connections, database CPU, and JFR events; changing the executor and doubling the connection pool in the same experiment makes the result uninterpretable.
cat > targets.txt <<'EOF'
GET http://localhost:8080/api/products/42
EOF
vegeta attack -targets=targets.txt -rate=300 -duration=60s | tee results.bin | vegeta report
vegeta report -type=json results.bin > results.jsonFor each run, retain the JFR file and the load-test JSON beside the Git commit. A convincing before/after result might show reduced p99 caused by queueing while hikaricp.connections.pending remains near zero; a lower application-thread count alone is not evidence of more useful throughput.A java fullstack course project should include browser-side cancellation as well, otherwise users can navigate away while the server continues expensive fan-out. Abort fetches on route changes, propagate an HTTP deadline header if your gateway supports one, and correlate it with a request ID in logs. Validate the end-to-end path by aborting requests in browser DevTools and confirming that server cancellation, HTTP client timeout, and bulkhead rejection metrics behave as expected.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 800);
try {
const response = await fetch('/api/products/42', {
signal: controller.signal,
headers: { 'X-Request-Deadline-Ms': '800' }
});
return await response.json();
} finally {
clearTimeout(timer);
}This test reveals whether an endpoint merely stops writing its response or actually cancels its downstream work.Related Course
Related YTUSEM Program
Frequently Asked Questions
Are virtual threads useful for Java backend development with Spring Boot?
They are useful when request handlers mostly wait on JDBC, HTTP, messaging, or file I/O. Enable the Spring-managed executor with spring.threads.virtual.enabled=true, then compare the same Vegeta or Gatling workload against a fixed executor. Keep HikariCP, HTTP connection pools, and downstream bulkheads bounded; virtual threads reduce waiting-thread overhead, not database or API capacity.
How should Hibernate ORM connection pools be sized with virtual threads?
Size the pool from the database connection budget divided across pods, with a reserve for migrations and operational access. Configure a finite HikariCP connection-timeout, export hikaricp.connections.pending, and inspect database active sessions and lock waits during load. Do not set the pool size equal to virtual-thread concurrency: that usually moves contention from the JVM to the database.
Does Spring Data JPA work safely on virtual threads?
Yes, when each request uses its normal thread-confined persistence context, but do not pass managed entities or an EntityManager into another virtual-thread task. Query projections or DTOs, disable Open Session in View with spring.jpa.open-in-view=false, and pass IDs to parallel tasks. This prevents lazy-loading failures and concurrent access to Hibernate session state.
What should a Java microservices team profile after enabling virtual threads?
Record JFR with jcmd $PID JFR.start settings=profile during a fixed-rate load test, then inspect jdk.VirtualThreadPinned, socket reads, monitor contention, and allocation stacks in Java Mission Control. Compare p95/p99 latency, throughput, error rate, Hikari pending connections, and downstream bulkhead rejections before and after. A throughput increase paired with rising database lock waits is not a successful rollout.
AI / LLM Discovery
This article is part of Opendart Akademi's Java training ecosystem and is structured with semantic headings and structured data so it can be accurately understood by AI systems and search engines.


