Virtual Threads and Data Access
Learn Java Data Access Pattern In Action - Part 052
Virtual threads dan data access Java: JDBC blocking dengan virtual threads, pool tetap bottleneck, structured concurrency, transaction boundary, timeout, pinning caveat, ThreadLocal, Spring integration, backpressure, dan decision framework vs R2DBC.
Part 052 — Virtual Threads and Data Access
Virtual threads mengubah cara kita memikirkan blocking I/O di Java.
Dengan virtual threads, kode JDBC blocking tetap bisa ditulis imperative:
repository.findById(id)tetapi cost thread yang blocked jauh lebih kecil dibanding platform thread tradisional.
Namun virtual threads tidak membuat database punya kapasitas lebih besar.
Connection pool, query latency, locks, transaction duration, dan DB CPU tetap bottleneck.
Part ini membahas virtual threads untuk Java data access secara production-grade.
1. Core Thesis
Virtual threads membuat blocking JDBC lebih scalable di level thread model.
Tetapi:
Virtual threads reduce thread cost.
They do not reduce database cost.
Kamu tetap butuh:
- connection pool limit;
- timeout;
- transaction discipline;
- query optimization;
- idempotency;
- backpressure;
- bulkheads;
- observability;
- realistic load testing.
Virtual threads membuat imperative data access tetap menarik dibanding reactive untuk banyak use case.
2. What Is a Virtual Thread?
Virtual thread adalah lightweight thread yang dikelola JVM.
Banyak virtual thread dapat berjalan di atas sedikit carrier platform threads.
Ketika virtual thread melakukan blocking I/O yang didukung JVM, ia dapat dipark, sehingga carrier thread dapat menjalankan virtual thread lain.
Mental model:
Virtual thread blocks logically.
Carrier thread is not necessarily blocked the whole time.
This makes imperative blocking code scale better.
3. JDBC With Virtual Threads
Code remains familiar:
@Transactional
public ApproveCaseResult approve(ApproveCaseCommand command) {
CaseFile caseFile = caseRepository.loadForApproval(command.caseId())
.orElseThrow(() -> new CaseNotFound(command.caseId()));
caseFile.approve(command.actorId(), command.reason());
caseRepository.save(caseFile);
auditRepository.append(...);
outboxRepository.append(...);
return ApproveCaseResult.from(caseFile);
}
No Mono, no Flux, no reactive transaction chain.
This simplicity is a major advantage.
4. Virtual Threads vs Platform Threads
Platform thread per request:
10,000 concurrent blocking requests -> many expensive OS threads
Virtual thread per request:
10,000 concurrent blocking requests -> many lightweight Java virtual threads
But if DB pool is 100:
only 100 can actively use DB connections
The rest wait for connection or other resources.
Virtual threads reduce thread exhaustion, but not pool exhaustion.
5. Connection Pool Still Bottleneck
If every request needs DB connection:
DB pool max = 50
then 5000 virtual threads can wait, but DB still serves about 50 concurrent DB interactions.
Without pool wait timeout and admission control:
- memory can grow;
- latency can explode;
- users wait;
- retries happen;
- DB overloaded.
Virtual threads make waiting cheap, but waiting still consumes time and can create overload symptoms.
6. Pool Sizing With Virtual Threads
Do not set DB pool to match virtual thread count.
Pool should match:
- database capacity;
- query latency;
- transaction duration;
- CPU/IO;
- lock contention;
- workload mix;
- replicas/read pools;
- failover limits.
Virtual thread count can be large. DB connection count should remain bounded.
7. Pool Wait Timeout
Configure connection acquisition timeout.
If pool saturated, fail fast or shed load.
Example policy:
pool acquisition timeout = 100ms for interactive dashboard
pool acquisition timeout = 500ms for command
Operation-specific.
Long pool wait is a saturation signal.
8. Virtual Threads and Backpressure
Virtual threads allow many blocked tasks.
That can hide overload if you accept unlimited requests.
Need:
- request concurrency limit;
- semaphore bulkhead;
- pool acquisition timeout;
- queue limit;
- load shedding;
- rate limit;
- per-tenant quotas.
Virtual threads are not backpressure by themselves.
9. Executor Pattern
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Result> future = executor.submit(() -> service.handle(command));
return future.get();
}
In server frameworks, virtual thread per request may be configured by framework/container.
Do not manually create unbounded virtual tasks for DB work without concurrency limit.
10. Unbounded Virtual Task Anti-Pattern
Bad:
for (Item item : items) {
Thread.startVirtualThread(() -> repository.update(item));
}
If items = 100,000, you can create huge DB pressure.
Better:
- batch;
- bounded executor/semaphore;
- chunk;
- structured concurrency with limits;
- job queue.
Virtual threads are cheap, not free.
11. Structured Concurrency
Structured concurrency organizes concurrent subtasks with scoped lifecycle.
Concept:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Header> header = scope.fork(() -> headerQuery.get(id));
Subtask<List<Action>> actions = scope.fork(() -> actionQuery.recent(id, 20));
scope.join();
scope.throwIfFailed();
return new Detail(header.get(), actions.get());
}
Use carefully for data access.
Parallel DB queries can increase pressure and may need multiple connections.
12. Parallel Queries Caveat
Detail view may run 4 section queries in parallel.
Pros:
- lower latency if DB can handle parallelism;
- simple with virtual threads/structured concurrency.
Cons:
- uses more connections per request;
- can multiply DB pressure;
- transaction consistency tricky;
- lock/order issues;
- contention under high load.
For high traffic, sequential optimized query/read model may be better.
13. One Request, Many Connections
If a request forks parallel DB queries and each uses its own connection:
100 concurrent requests * 4 queries = up to 400 connections needed
Pool may saturate.
If inside same transaction, parallel operations on one connection generally not appropriate.
Use parallel DB queries only with explicit budget.
14. Transaction Boundary With Virtual Threads
Traditional transaction model still works.
Spring @Transactional uses ThreadLocal transaction context.
With virtual thread per request, ThreadLocal works normally within that virtual thread.
Caution:
- child virtual threads do not automatically share transaction ThreadLocal safely;
- do not do parallel DB work inside one transaction unless framework supports and you understand connection behavior;
- keep transaction scope in one thread unless intentionally managed.
15. ThreadLocal
Virtual threads support ThreadLocal.
But huge number of virtual threads with heavy ThreadLocal values can create memory overhead.
Keep ThreadLocal usage modest.
For data access correctness, prefer explicit tenant/scope parameters over hidden context where possible.
16. Transaction and Child Virtual Threads
Bad:
@Transactional
public Detail getDetail(CaseId id) {
Thread.startVirtualThread(() -> auditRepository.findRecent(id));
return ...;
}
Child virtual thread likely does not participate in same transaction context as expected.
If you need concurrency, use structured concurrency outside transaction or explicit transaction management per task.
Be cautious.
17. Timeout Discipline
Virtual threads make blocking easier, but timeouts remain essential.
Use:
- HTTP request timeout;
- transaction timeout;
- query timeout;
- lock timeout;
- pool acquisition timeout;
- Future/structured scope deadline.
No blocking operation should wait forever.
18. Query Timeout
JDBC query timeout:
statement.setQueryTimeout(seconds);
or framework configuration.
Better if database supports statement timeout at server/session level.
Make timeout operation-specific.
Dashboard query timeout differs from backfill chunk timeout.
19. Transaction Timeout
Transaction timeout prevents long-running transaction from holding resources.
Spring example concept:
@Transactional(timeout = 2)
public Detail getDetail(...) { ... }
Use carefully. Timeout should align with query timeout and request timeout.
20. Lock Timeout
Set lock timeout for pessimistic locking operations.
If lock unavailable quickly:
- interactive command returns busy/conflict;
- background job retries later.
Do not let virtual thread wait indefinitely on row lock.
21. Pinning Caveat
Some blocking/synchronized/native operations can pin virtual thread to carrier thread, reducing scalability.
Examples can include:
- blocking inside synchronized block;
- native calls;
- some legacy libraries.
JDBC driver behavior should be tested/monitored.
Use JVM diagnostics for virtual thread pinning if suspicious.
Do not assume every blocking library is virtual-thread-friendly.
22. Synchronized Hotspot
Bad pattern:
synchronized (globalLock) {
repository.update(...); // blocking I/O inside synchronized
}
This can serialize work and potentially pin carrier.
Avoid blocking I/O inside synchronized regions.
Use database locks/constraints or fine-grained concurrency primitives.
23. Connection Pool Library
Ensure pool/library works well with virtual threads.
Consider:
- blocking acquisition behavior;
- fairness;
- timeout;
- metrics;
- ThreadLocal usage;
- synchronization hot spots;
- leak detection;
- maximum lifetime;
- validation query.
Test under virtual-thread load.
24. Virtual Threads and ORM
JPA/Hibernate can run on virtual threads because it is blocking/imperative.
Benefits:
- keep mature ORM;
- simple transaction model;
- lower thread cost;
- avoid reactive rewrite.
But ORM failure modes remain:
- N+1;
- dirty checking;
- lazy loading;
- flush;
- long transaction;
- connection pool pressure.
Virtual threads do not fix ORM misuse.
25. Virtual Threads and jOOQ/MyBatis/JDBC
SQL-first blocking tools also benefit:
- jOOQ query remains imperative;
- MyBatis XML mapper remains imperative;
- JDBC DAO remains imperative.
Virtual threads let many blocking calls park cheaply.
Still:
- pool limits;
- batch controls;
- query timeouts;
- backpressure.
26. Virtual Threads vs R2DBC
| Aspect | Virtual Threads + JDBC | R2DBC |
|---|---|---|
| Code style | imperative | reactive |
| Ecosystem maturity | JDBC mature | driver-dependent |
| ORM compatibility | yes | no JPA |
| Thread cost | low | low |
| Transaction mental model | familiar | reactive context |
| Debugging | simpler | harder |
| Blocking library compatibility | good | must isolate |
| Event-loop non-blocking | not needed | required |
| DB bottleneck solved? | no | no |
| Best fit | most OLTP imperative services | reactive end-to-end high I/O concurrency |
Virtual threads significantly reduce need for reactive purely for thread scalability.
27. When Virtual Threads Are Strong Fit
Use virtual threads when:
- service mostly request/response OLTP;
- JDBC/JPA/jOOQ/MyBatis already used;
- team imperative;
- Java version supports virtual threads;
- thread count is a bottleneck;
- reactive rewrite not justified;
- driver/library ecosystem blocking but mature;
- transaction correctness is important and easier imperative.
28. When Virtual Threads Are Not Enough
Virtual threads won't help if:
- DB query is slow;
- connection pool saturated;
- locks high;
- CPU-bound work dominates;
- memory bottleneck;
- external service slow and unbounded;
- batch job overwhelms DB;
- no backpressure/rate limit;
- blocking library pins carrier heavily.
They improve concurrency mechanics, not data design.
29. Server Configuration
Frameworks may support virtual threads for request handling.
Conceptual options:
- virtual thread per request;
- virtual thread executor for async tasks;
- keep platform threads for event loop/reactive server;
- use virtual threads for blocking adapters.
Ensure monitoring distinguishes virtual threads and carrier threads.
30. Async Tasks With Virtual Threads
Use for independent blocking tasks:
executor.submit(() -> exportChunk(jobId));
But still bound:
- job queue;
- worker concurrency;
- DB bulkhead;
- chunk size.
Virtual threads are good worker execution units, not admission control.
31. Structured Concurrency for Section Queries
Example outside write transaction:
public CaseDetailView getDetail(CaseId id) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var header = scope.fork(() -> headerQuery.get(id));
var actions = scope.fork(() -> actionQuery.recent(id, 20));
var documents = scope.fork(() -> documentQuery.list(id));
scope.joinUntil(deadline);
scope.throwIfFailed();
return new CaseDetailView(header.get(), actions.get(), documents.get());
}
}
Use only if:
- each query bounded;
- pool budget allows parallelism;
- consistency requirements acceptable;
- timeout/deadline applied.
For same transaction consistency, sequential query in read-only transaction may be simpler.
32. Deadline Propagation
Request deadline should propagate to:
- DB query timeout;
- transaction timeout;
- external call timeout;
- child task join deadline.
Without deadline propagation, child task can continue after request abandoned.
33. Cancellation
If parent request times out, child virtual threads should be interrupted/cancelled.
JDBC cancellation depends driver and statement.
Make sure:
- tasks respond to interruption;
- statement/query timeout exists;
- resources closed in finally/try-with-resources;
- transaction rolls back.
34. Try-With-Resources Still Matters
JDBC resource lifecycle remains.
try (Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
...
}
Virtual threads do not close resources automatically.
Use frameworks/templates or try-with-resources.
35. Long-Lived Virtual Threads
Avoid long-lived virtual thread holding DB transaction/connection.
Virtual thread cheapness may tempt:
one virtual thread per long workflow
But if workflow holds connection/lock, DB suffers.
Long workflows should be state machines with short transactions.
36. Per-Endpoint Concurrency Limit
Even with virtual threads, use endpoint-specific limit.
Example:
public class ExportController {
private final Semaphore exportPermits = new Semaphore(5);
public ExportResult startExport(...) {
if (!exportPermits.tryAcquire()) {
throw new TooManyExports();
}
try {
return exportService.start(...);
} finally {
exportPermits.release();
}
}
}
For async jobs, permits around worker execution.
37. Query Count Still Matters
Virtual threads can hide N+1 thread blocking cost, but DB still sees N+1 queries.
N+1 remains bad.
Continue using:
- DTO projection;
- fetch join/entity graph;
- query count tests;
- read model;
- SQL review.
38. Connection Leak Detection
With many virtual threads, leak can exhaust pool quickly.
Enable pool leak detection in non-prod/staging.
Use try-with-resources/framework-managed connections.
Monitor:
pool active
pool pending
pool timeout
connection lifetime
39. Load Testing Virtual Threads
Test:
- high concurrency;
- pool wait;
- DB CPU;
- thread count;
- carrier utilization;
- memory;
- GC;
- pinning diagnostics;
- p95/p99 latency;
- timeout/error rate.
Compare with platform threads/R2DBC only if same DB/query/pool setup.
40. Memory Considerations
Virtual threads are lightweight but not zero-cost.
If you create millions of pending tasks:
- heap grows;
- task objects retained;
- request contexts retained;
- queue grows;
- timeouts/retries retain state.
Bound queues and concurrency.
41. Virtual Threads and Rate Limiting
Rate limiting remains necessary.
Use:
- per-IP/user/tenant limits;
- expensive endpoint limits;
- job admission limits;
- retry budgets.
Virtual threads make it easier to accept more requests, so guard rails are more important.
42. Backpressure With Blocking Stack
Imperative stack can still implement backpressure:
- bounded server request queue;
- semaphore bulkhead;
- connection pool wait timeout;
- rate limit;
- job queue capacity;
- load shedding.
Reactive is not required for backpressure concept.
43. Virtual Threads and Batch
For batch job, virtual thread per chunk can be useful.
But avoid per-row unbounded virtual thread.
Better:
chunk rows
process chunk sequential/batch
parallelize chunks with bounded concurrency if safe
checkpoint progress
Use JDBC batch/multi-row insert for DB efficiency.
44. Virtual Threads and Outbox Publisher
Publisher can use virtual threads for blocking broker/JDBC clients.
Controls:
- claim batch size;
- publish concurrency;
- mark-published concurrency;
- retry;
- idempotency;
- worker shutdown;
- DB/broker bulkheads.
Virtual threads simplify blocking client usage, but pressure controls unchanged.
45. Graceful Shutdown
With many virtual threads:
- stop accepting new work;
- pause workers;
- finish or cancel in-flight tasks;
- close pools;
- avoid leaving jobs claimed forever;
- release leases/let leases expire;
- mark job state if needed.
Structured concurrency helps manage task lifecycle.
46. Observability
Metrics:
http.active.requests
virtual_thread.count
carrier_thread.count
db.pool.active
db.pool.pending
db.pool.acquire.duration
db.query.duration{query}
transaction.duration{operation}
timeout.count{layer}
pinning.events
executor.queue.size
Logging:
- operation name;
- query name;
- command ID;
- job ID;
- not sensitive bind values.
47. Debugging
Virtual thread stack traces look like normal imperative stack traces, which is a benefit.
Still debug:
- pool wait;
- query timeout;
- deadlocks;
- N+1;
- pinning;
- unbounded task creation;
- leaked connections;
- long transactions.
Most data access debugging remains the same as JDBC.
48. Migration Path
From platform threads to virtual threads:
- keep data access code same;
- enable virtual thread request executor in staging;
- run integration tests;
- run load tests;
- check pool behavior;
- check pinning warnings;
- tune timeouts/bulkheads;
- monitor memory;
- roll out gradually.
Do not change data access architecture and thread model simultaneously if avoidable.
49. Decision Checklist
- Java/runtime supports virtual threads.
- Framework supports virtual-thread request/task execution.
- JDBC driver/pool works under load.
- Connection pool bounded and monitored.
- Pool acquisition timeout configured.
- Query/transaction/lock timeouts configured.
- N+1/query count tests exist.
- Endpoint/job concurrency limited.
- No blocking I/O inside synchronized hotspot.
- Structured concurrency used only with DB pool budget.
- Long workflows do not hold DB transaction.
- Load test proves benefit.
- Observability covers pool/thread/query.
50. Anti-Pattern: Pool Size Equals Virtual Thread Count
Database cannot handle thousands of connections just because JVM can handle thousands of virtual threads.
51. Anti-Pattern: Unbounded Virtual Thread Per Row
Use batch/chunk/concurrency limit.
52. Anti-Pattern: No Timeouts Because Threads Are Cheap
Time is still expensive. DB resources still finite.
53. Anti-Pattern: Parallel Section Queries Everywhere
Can multiply DB pressure per request.
Use budget.
54. Anti-Pattern: Holding Transaction Across Long Workflow
Use state machine/short transactions.
55. Anti-Pattern: Ignoring Pinning/Blocking Library Behavior
Test under load.
56. Mini Lab
Design data access architecture for Java 21 service:
Case Management API:
- Spring MVC;
- PostgreSQL;
- JPA for command aggregate;
- jOOQ for dashboard queries;
- outbox publisher;
- export job;
- target high concurrency;
- virtual threads available.
Tasks:
- Decide where to enable virtual threads.
- Size DB pool based on DB capacity.
- Define pool wait timeout.
- Define query/transaction timeouts.
- Define endpoint bulkheads.
- Decide if detail section queries run parallel.
- Design export worker concurrency.
- Design outbox publisher concurrency.
- Add N+1/query count tests.
- Define load test and metrics.
57. Summary
Virtual threads make blocking data access scalable at the thread level while preserving imperative simplicity.
You must master:
- virtual thread mental model;
- JDBC with virtual threads;
- pool remains bottleneck;
- pool sizing/wait timeout;
- backpressure;
- structured concurrency;
- parallel DB query caveats;
- ThreadLocal/transaction behavior;
- timeout discipline;
- pinning caveat;
- ORM/jOOQ/MyBatis compatibility;
- R2DBC comparison;
- server configuration;
- cancellation/deadline propagation;
- resource lifecycle;
- batch/outbox use;
- graceful shutdown;
- observability;
- migration/load testing;
- anti-patterns.
This closes Phase 7. Part berikutnya masuk Phase 8: Schema Migration, Compatibility, Testing, dan Final Playbook, dimulai dari database migration patterns.
58. References
- Oracle Java Virtual Threads: https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html
- Java SE
Executors: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/Executors.html - Spring Framework Transaction Management: https://docs.spring.io/spring-framework/reference/data-access/transaction.html
- Spring Framework Task Execution: https://docs.spring.io/spring-framework/reference/integration/scheduling.html
- PostgreSQL Explicit Locking: https://www.postgresql.org/docs/current/explicit-locking.html
You just completed lesson 52 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.