Java Virtual Threads: Project Loom, Pinning Hazards, and Production Migration
Lesson 1 of 6
Twenty-five containers hitting 50 thousand requests per second, and the CPU never leaves 10 percent. A 200-thread executor, each request ~100 ms of I/O (one database round-trip plus one payment gateway call), caps at ~2 thousand requests per second per container. So you scale horizontally to 25 containers to reach 50 thousand requests per second — paying for a service that is 90 percent idle.
The Thread Pool Exhaustion Problem
The bottleneck in that Spring Boot service is the thread pool, not the CPU — and the fix is virtual threads[1]. Same code, same blocking calls, no reactive rewrite. Throughput jumps to 50K+ RPS on a fraction of the containers. Then the staging crashes start: a synchronized block in a connection-pool wrapper is pinning carrier threads — silently converting virtual threads back into platform threads under load. The p99 latency spikes from 12 ms to 4 s during the pinning episodes, and there's no monitoring to detect it.
JEP 491[2] (Java 24, March 2025) removed the synchronized pinning limitation described in this article. If you're running
Java 24 or later, virtual threads can hold object monitors without pinning to carrier threads — the ReentrantLock
migration below is no longer required. The guidance in this article remains relevant for Java 21–23 deployments.
Virtual threads[1] apply M:N scheduling to Java: thousands of lightweight threads share a small carrier pool, unmounting during I/O to free carriers for other work. Enable with one Spring Boot property[3]. Defeat with pinning (synchronized blocks during I/O on Java 21–23) and carrier starvation; detect both via JFR events.
- Replace every
synchronizedblock during I/O withReentrantLockbefore migration (required for Java 21–23; unnecessary on Java 24+ per JEP 491[2]) - Enable
jdk.VirtualThreadPinnedJFR event to detect pinning under load - Expect to consolidate containers on I/O-bound services once a single instance can hold thousands of concurrent blocking calls — size the win by measuring your own per-instance concurrency before and after
What Virtual Threads Actually Are
Virtual threads are user-space threads managed by the JVM scheduler rather than the operating system. A platform thread maps 1:1 to an OS thread and costs 1–2 MB of stack memory. A virtual thread starts with a few kilobytes (grown dynamically) and schedules onto a small pool of carrier threads — typically one per CPU core.
The mount-unmount-remount cycle is the entire model in one picture:
sequenceDiagram
participant V as Virtual thread<br/>app code
participant C as Carrier thread<br/>OS thread
participant IO as Blocking I/O<br/>DB, HTTP, file
Note over V,C: Step 1 — mount
V->>C: schedule onto carrier
C->>C: run app code
Note over V,C: Step 2 — unmount on block
C->>IO: blocking call (e.g. JDBC)
C->>V: park virtual thread<br/>save continuation
Note over C: Carrier free —<br/>picks up another virtual thread
Note over V,IO: Step 3 — I/O completes
IO-->>V: response ready
Note over V,C: Step 4 — remount on any available carrier
V->>C: reschedule
C->>C: resume continuation
Note over V,C: From the dev's view, the thread<br/>"blocked" — but no OS thread waited.
The diagram is the entire payoff: blocking calls feel synchronous from app code, but the OS-level thread is never held idle[1].
The key behaviour: when a virtual thread calls blocking I/O (database query, HTTP call, file read), the JVM automatically unmounts it from its carrier thread. The carrier thread immediately picks up another virtual thread. When the I/O completes, the virtual thread reschedules onto an available carrier. The thread appears to block from the developer's perspective, but no OS thread is held idle.
This is the M:N threading model — many virtual threads scheduled onto N carrier threads — that languages like Go (goroutines) and Erlang (processes) have used for years. Java 21 brings it to the JVM without requiring a rewrite to a different programming model[1].
Platform threads are limited by OS resources — each carries a full OS thread stack (commonly ~1 MB), so the practical ceiling is in the low thousands before memory and scheduler overhead dominate. Virtual threads operate in a fundamentally different regime: spawning 100,000 of them is routine, because most are parked in the JVM heap waiting for I/O rather than holding OS thread stacks.
Workload Classification & the Quick Start
Virtual threads are not a universal improvement. Their benefit depends entirely on workload profile:
| Workload Type | Virtual Thread Benefit | Why |
|---|---|---|
| I/O-bound, high concurrency | High | Threads spend most time blocked on network/disk; VTs unmount during waits |
| I/O-bound, low concurrency | Low | Thread pool is not the bottleneck |
| Mixed I/O + CPU | Moderate | Benefits proportional to I/O ratio |
| CPU-bound | None or negative | VTs add scheduling overhead; no concurrency gain |
| Reactive (WebFlux) | None | Already non-blocking; VTs cannot improve optimal |
Decision framework: If your service is bottlenecked by thread pool exhaustion (requests queuing for a free thread while CPU sits at 10–20%), virtual threads will help. If the bottleneck is CPU[1], database connection limits, or downstream service latency, they won't.
Migrate with three parallel work streams: (1) enable virtual threads, (2) audit and replace pinning sources, (3) address ThreadLocal usage. Skip any and you'll have virtual threads enabled but no benefits.
Migration: From Pools to Per-Task Threads
The configuration change is minimal. Spring Boot 3.2+ handles everything. The framework detects the virtual threads setting and automatically applies it to web requests (Tomcat connector), async tasks (@Async), scheduled tasks (@Scheduled), and message listeners.[3]
spring:
threads:
virtual:
enabled: true
datasource:
hikari:
maximum-pool-size: 50 # Reduce from 200—VTs share efficiently
minimum-idle: 10In code, replace ThreadPoolTaskExecutor with virtual threads:
// Old way — thread pool exhaustion bottleneck
@Configuration
public class OldThreadConfig {
@Bean
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(200);
executor.setMaxPoolSize(400);
executor.setQueueCapacity(1000);
executor.initialize();
return executor;
}
}
// New way — one line
@Configuration
public class VirtualThreadConfig {
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(
Executors.newVirtualThreadPerTaskExecutor()
);
}
}Start with a non-critical service — a recommendation engine or search feature where degradation doesn't cause outages. This gives you a safe canary before rolling out to critical paths.
What to expect on a thread-pool-bound I/O service: the latency win comes primarily from eliminating thread-pool queuing — requests get their own virtual thread immediately instead of waiting for a pooled platform thread to free up, so the queuing-delay component of P99 collapses. Throughput per instance rises with concurrency (which is what enables container consolidation), while memory per container stays roughly flat. Measure all three before and after on your canary rather than assuming a fixed percentage — the size of the win depends entirely on how thread-pool-starved the service was to begin with.
The Three Pinning Hazards
Virtual threads introduce three silent failure modes that eliminate benefits under load. The pinning failure mode in one picture — every carrier thread held hostage by a synchronized lock on Java 21–23, leaving the rest of the workload starved:
graph TB
subgraph Healthy["Healthy carrier pool — virtual threads unmount during I/O"]
C1A["Carrier 1"] --> V1A["VT #1<br/>idle"]
C2A["Carrier 2"] --> V2A["VT #2<br/>running"]
C3A["Carrier 3"] --> V3A["VT #3<br/>idle"]
Q1["Queue: VT #4..N<br/>ready to mount"]
end
subgraph Pinned["Pinning episode — Java 21–23, synchronized + I/O"]
C1B["Carrier 1"] -->|"PINNED<br/>blocked I/O<br/>inside synchronized"| V1B["VT A"]
C2B["Carrier 2"] -->|"PINNED"| V2B["VT B"]
C3B["Carrier 3"] -->|"PINNED"| V3B["VT C"]
Q2["Queue: VT D..Z<br/>cannot mount<br/>p99 latency spikes"]
end
Healthy -->|"add synchronized<br/>around blocking call"| Pinned
style V1B fill:#fdd
style V2B fill:#fdd
style V3B fill:#fdd
style Q2 fill:#fdd
JEP 491 (Java 24) eliminates this — synchronized monitors no longer pin. The diagram is the failure mode every Java 21–23 codebase has to audit before flipping the virtual-threads switch.
Gotcha #1: Synchronized Blocks Pin Carrier Threads
When a virtual thread holds a synchronized block or monitor during blocking I/O, the JVM cannot unmount it[1]. The carrier thread is held for the entire operation, eliminating concurrency. With a default carrier pool of 4–16 threads, even a handful of pinned carriers can starve the entire system.
Note: This limitation was eliminated in Java 24 via JEP 491[2]. The guidance below is required for Java 21–23; unnecessary on Java 24+.
// BAD on Java 21–23 — pins the carrier thread
private synchronized void updateCache(String key, Object value) {
cache.put(key, value); // If this blocks (unlikely but possible with some cache implementations)
}
// GOOD for Java 21–23 — use ReentrantLock instead (not needed on Java 24+)
private final Lock lock = new ReentrantLock();
private void updateCache(String key, Object value) {
lock.lock();
try {
cache.put(key, value);
} finally {
lock.unlock();
}
}For Java 21–23, a full codebase audit for synchronized is non-negotiable. Expect dozens to hundreds of occurrences. Many will be in third-party libraries you cannot modify. For libraries you cannot change, either pin calls to a bounded platform thread pool or wait for library updates. On Java 24+, this becomes optional.
Library compatibility — what actually pins, on Java 21–23:
| Library | VT-Safe Guidance | Notes |
|---|---|---|
| HikariCP | Upgrade to 7.1.0+ | Not a synchronized pin: through 7.0.2 the connection-return path (ConcurrentBag.requite()/unreserve()) yield-spins instead of parking on most iterations, which can saturate every carrier under sustained VT load (issue #2398). 7.1.0 stops the spin for virtual threads (#2402) [4][5] |
| PostgreSQL JDBC (pgjdbc) | 42.6.0+, but skip 42.7.8 | 42.6.0 replaced most synchronized usages with ReentrantLock (#2635). A locking change (#3703) reintroduced carrier pinning in 42.7.8, and 42.7.9 removed it (#3849) — pin 42.7.9+ deliberately rather than assuming "recent" is safe [6][7] |
| MySQL Connector/J | Fixed in 9.0.0+ | 9.0.0 replaced the driver's synchronized blocks with ReentrantLock so carriers can unmount virtual threads during I/O waits; earlier releases pin on the query path [8] |
| Oracle JDBC | Thin 21c+; OCI unsupported | Oracle lists virtual-thread support for the Thin driver from 21c and none for OCI [9]. OCI reaches the database through native methods, and a blocking native frame holds the carrier on every JDK, 24+ included [1][2] |
| Apache HttpClient 5 | Fixed in 5.4+ | Through 5.3.x the LeaseRequest.get() returned by PoolingHttpClientConnectionManager.lease() is synchronized and blocks waiting for a pooled connection; 5.4 replaced synchronized blocks with ReentrantLock for virtual-thread compatibility [10] |
| Jackson | databind 2.17.1+, core 2.18.0+ | databind 2.17.1 swapped synchronized in DeserializerCache for ReentrantLock after it deadlocked under virtual-thread pinning on JDK 21 (#4430); 2.18.0 reworked further synchronized blocks in databind and in core (InternCache, ThreadLocalBufferManager) [11][12]. Separately, the 2.x default ThreadLocal buffer recycler gets little reuse when every request runs on a fresh virtual thread — an allocation-tuning concern, not pinning; RecyclerPool is pluggable since core 2.16 [12] |
| Hibernate ORM | 6.2.8+ | The core flush path (AbstractFlushingEventListener, ActionQueue, SessionImpl, JdbcCoordinatorImpl) has no synchronized, so a flush pins only if the driver or pool beneath it does — fix the rows above. Before 6.2.8/6.3.0, the pooled and hi/lo sequence optimizers fetched the next sequence block inside synchronized (HHH-17005) [13] |
| BouncyCastle | Depends on the artifact | bcprov (crypto primitives) does no network I/O. The bctls JSSE provider runs the TLS handshake inside synchronized startHandshake()/handshakeIfNecessary() and writes records while holding a monitor, so on Java 21–23 it pins during handshakes and socket writes (still true in 1.86) [14][15] |
For Java 24+: JEP 491 lets a virtual thread unmount while holding a monitor[2], so every synchronized-based row above stops mattering. It does not fix HikariCP's yield-spin before 7.1.0 (a scheduling problem, not a pinning one) or native calls through Oracle's OCI driver; both still need the version or driver change noted above regardless of JDK.
Run mvn dependency:tree and cross-reference every library doing I/O against the table above — pin driver versions deliberately rather than trusting "recent enough." On Java 21–23, a single unpatched synchronized block on the hot path can negate the entire migration; on Java 24+, the JVM-level fix covers it regardless of library version.
Gotcha #2: ThreadLocal Memory Explosion
ThreadLocals are everywhere: security contexts, transaction managers, request IDs. With virtual threads, you might create millions in a request lifecycle. Each one gets its own ThreadLocal copy. Result: memory explosion.
// Disaster with virtual threads — 100,000 threads × 1KB ThreadLocal = 100MB
private static final ThreadLocal<UserContext> USER_CONTEXT = new ThreadLocal<>();
// Use ScopedValue instead (preview since Java 21; final in JDK 25, JEP 506)
private static final ScopedValue<UserContext> USER_CONTEXT =
ScopedValue.newInstance();
public void handleRequest(HttpServletRequest request) {
var userContext = extractUserContext(request);
ScopedValue.where(USER_CONTEXT, userContext)
.run(() -> processRequest());
}Scoped values are immutable, lightweight, and designed for virtual threads. Migrating an established codebase from ThreadLocal is a separate project — plan it as a future work stream.
Gotcha #3: Carrier Starvation
If all carrier threads are pinned by synchronized blocks during slow I/O, new virtual threads cannot mount. The system appears deadlocked even though no actual deadlock exists. In production, this manifests as a sudden throughput cliff under load, with CPU idle but requests timing out.
Enable JFR to detect this:
# Java 21–23 only — prints a stack trace whenever a virtual thread blocks while
# pinned (no duration threshold). JDK 24 removed this property (JEP 491).
java -Djdk.tracePinnedThreads=full -jar Application.jar
# Or via JFR flight recording in production (any JDK 21+)
java -XX:StartFlightRecording=filename=pinning.jfr,settings=pinning.jfc -jar Application.jarCreate a pinning.jfc profile:
<?xml version="1.0" encoding="UTF-8"?>
<configuration version="2.0">
<event name="jdk.VirtualThreadPinned">
<setting name="enabled">true</setting>
<setting name="stackTrace">true</setting>
<setting name="threshold">20 ms</setting>
</event>
</configuration>Analyze with jfr print --events VirtualThreadPinned pinning.jfr | head -100. Each event shows the stack trace. Any frame containing synchronized is your fix target.
For continuous production monitoring, stream the same JFR event into a metric instead of shipping recordings around:
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jdk.jfr.consumer.RecordingStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicLong;
@Component
public class VirtualThreadPinningMetrics {
private static final Logger log = LoggerFactory.getLogger(VirtualThreadPinningMetrics.class);
private final MeterRegistry registry;
private final AtomicLong pinnedCount = new AtomicLong();
private RecordingStream rs;
public VirtualThreadPinningMetrics(MeterRegistry registry) {
this.registry = registry;
}
@PostConstruct
public void startPinningMonitor() {
rs = new RecordingStream();
rs.enable("jdk.VirtualThreadPinned").withThreshold(Duration.ofMillis(20));
rs.onEvent("jdk.VirtualThreadPinned", event -> {
pinnedCount.incrementAndGet();
log.warn("Pinned {}ms at {}", event.getDuration().toMillis(),
event.getStackTrace());
});
rs.startAsync();
registry.gauge("jvm.virtual_thread.pinned.count", pinnedCount);
}
@PreDestroy
public void stopPinningMonitor() {
rs.close();
}
}Alert when pinning frequency exceeds baseline. A spike after a library upgrade is usually a synchronized block added in the new version.
Structured Concurrency for Fan-Out
Structured concurrency (StructuredTaskScope, previewed as JEP 505 in JDK 25, the September 2025 LTS, then again as JEP 525 in JDK 26[16] — still a preview API, so compile with --enable-preview) complements virtual threads — it gives concurrent fan-out operations the same lifecycle discipline as sequential code. It is not final yet: JEP 525 was the sixth preview, and a seventh (JEP 533) has already shipped in JDK 27 (GA September 2026)[17] with further Joiner API changes[18] — expect the shape below to keep shifting release to release until a finalization JEP lands. Pin the JDK you compile against.
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Joiner;
// Order processing: fan out to three services concurrently, fail if any fails
public OrderConfirmation processOrder(Order order) throws InterruptedException {
// JDK 25 form: open(...) + a Joiner policy. The ShutdownOnFailure /
// ShutdownOnSuccess public constructors were removed in this preview round.
try (var scope = StructuredTaskScope.open(Joiner.<Object>awaitAllSuccessfulOrThrow())) {
var inventory = scope.fork(() -> inventoryService.checkStock(order));
var payment = scope.fork(() -> paymentService.charge(order));
var shipping = scope.fork(() -> shippingService.getQuote(order));
// join() blocks until all succeed, throwing FailedException (and cancelling
// the survivors) on the first failure — the old throwIfFailed() call is gone.
scope.join();
return new OrderConfirmation(
inventory.get(),
payment.get(),
shipping.get()
);
}
// Scope closed = all child threads are guaranteed done
}If payment fails, the awaitAllSuccessfulOrThrow() Joiner immediately cancels inventory and shipping — no manual cleanup needed. This replaces CompletableFuture.allOf() chains and is much cleaner.
Production Checklist
Before enabling virtual threads:
- Audit codebase for
synchronized— classify each as safe (no blocking I/O inside) or hazardous (required for Java 21–23; optional on Java 24+ due to JEP 491) - Replace hazardous
synchronizedblocks withReentrantLock(Java 21–23 only; see JEP 491 note above) - Check every I/O library against the compatibility table's minimum versions (HikariCP 7.1.0+, pgjdbc 42.7.9+, Connector/J 9.0.0+, HttpClient 5.4+) — the
synchronizedrows matter less on Java 24+, the HikariCP yield-spin does not - Reduce database and HTTP connection pool sizes — the ceiling is now the pool, not thread count
- Add JFR
jdk.VirtualThreadPinnedevent recording to load test environment (Java 24+ removessynchronizedpinning, not native-frame pinning)
When enabling virtual threads:
- Set
spring.threads.virtual.enabled=true - Verify
@Asyncmethods run on virtual threads viaThread.currentThread().isVirtual() - Run load tests and collect JFR recording for pinning events
- Analyse pinning events — fix any with >20ms duration
- Monitor P50/P99 latency under load — validate improvement
- Monitor container memory — watch for ThreadLocal growth
After enabling virtual threads:
- Export carrier utilization and virtual thread count as metrics
- Alert on carrier utilization above ~95% as a starvation signal — a practical starting threshold; tune it to your own carrier pool size and workload
- Alert on
jdk.VirtualThreadSubmitFailedevents - Plan ThreadLocal → ScopedValue migration as separate project
- Document any pinning sources that cannot be fixed (library constraints)
Detect pinning in CI before it ships to production
The diagnostic gap: pinning episodes only show up under realistic concurrency, and JFR recordings are bulky to ship around. Below is a JFR-event-tap that fails the build if any virtual-thread pinning episode exceeds the threshold — paste it into your load-test runner so a synchronized-during-I/O regression is caught at PR time, not 3am:
import jdk.jfr.consumer.RecordingStream;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
public final class PinningGuard implements AutoCloseable {
private static final long PIN_THRESHOLD_NANOS = Duration.ofMillis(20).toNanos();
private static final long FAIL_THRESHOLD = 5;
private final RecordingStream stream;
private final LongAdder pinCount = new LongAdder();
private final AtomicLong worstPinNanos = new AtomicLong();
public PinningGuard() {
this.stream = new RecordingStream();
stream.enable("jdk.VirtualThreadPinned").withThreshold(Duration.ofMillis(1));
stream.onEvent("jdk.VirtualThreadPinned", event -> {
long durationNanos = event.getDuration().toNanos();
if (durationNanos > PIN_THRESHOLD_NANOS) {
pinCount.increment();
worstPinNanos.accumulateAndGet(durationNanos, Math::max);
System.err.printf(
"PIN: %dms in thread=%s stack=%s%n",
Duration.ofNanos(durationNanos).toMillis(),
event.getThread() == null ? "?" : event.getThread().getJavaName(),
event.getStackTrace());
}
});
stream.startAsync();
}
public void assertNoPinningRegression() {
long count = pinCount.sum();
if (count > FAIL_THRESHOLD) {
throw new AssertionError(String.format(
"Pinning regression: %d events over %dms (worst=%dms). " +
"Audit recent synchronized changes or move to ReentrantLock.",
count,
Duration.ofNanos(PIN_THRESHOLD_NANOS).toMillis(),
Duration.ofNanos(worstPinNanos.get()).toMillis()));
}
}
@Override public void close() { stream.close(); }
}
// Usage in your load test or integration test:
// try (PinningGuard guard = new PinningGuard()) {
// runLoadFor(Duration.ofMinutes(2));
// guard.assertNoPinningRegression();
// }The only configuration knob is PIN_THRESHOLD_NANOS (default 20ms — anything shorter is rarely actionable) and FAIL_THRESHOLD (5 episodes per test run). On Java 24+ this guard becomes optional but still costs nothing to leave in — JEP 491 eliminated synchronized-driven pinning, but third-party native code can still pin if it parks the carrier.
Frequently Asked Questions
What is thread pinning in Java virtual threads?
Thread pinning occurs when a virtual thread holds a synchronized block or monitor during a blocking I/O call, preventing it from unmounting from its carrier thread. The carrier thread is blocked for the duration, eliminating the concurrency benefit. Replace synchronized with ReentrantLock to avoid pinning.
When should I use virtual threads vs reactive (WebFlux)?
Use virtual threads for I/O-bound services where thread pool exhaustion is the bottleneck — you get the concurrency of reactive with sequential, debuggable code. Use reactive if you already have a mature WebFlux codebase, need built-in backpressure, or run pure non-blocking pipelines where reactive has a slight performance edge.
Do virtual threads improve CPU-bound workload performance?
No. Virtual threads add scheduling overhead for CPU-bound work and provide no benefit since the bottleneck is computation, not thread blocking. They are designed for I/O-bound workloads where threads spend most time waiting on network or disk.
How do I detect virtual thread pinning in production?
Use Java Flight Recorder (JFR) with the jdk.VirtualThreadPinned event enabled. JFR captures the stack trace where pinning occurs, showing the exact synchronized block causing the issue. On Java 21–23 you can also set -Djdk.tracePinnedThreads=short for development diagnostics; JDK 24 removed that property.
Keep Reading
- Java CompletableFuture: Async Orchestration Patterns for Production — The async patterns virtual threads aim to replace, and when CompletableFuture is still the better tool
- Go vs Java in 2026: An Honest Performance Comparison for Backend Services — How Java's virtual threads stack up against Go's goroutines in real benchmarks
- Spring Boot REST: JPA, Validation, Exception Handling, and Testing — The Spring Boot patterns that benefit most from enabling virtual threads
- Java Streams: Pipeline Internals, Performance Traps, and Production Patterns — When
parallelStreamis the wrong tool: virtual threads handle blocking I/O without ForkJoinPool starvation - GraalVM Native Images in Production — Virtual threads + GraalVM native: 80 ms cold start with 50k-thread concurrency on a tiny container
Coming Next
Virtual threads bridge the concurrency gap between Java and Go, but how do they compare in real-world benchmarks under heavy load? In our next deep dive, we pit Go 1.26 (with Goroutines) against Java 21 (with Virtual Threads) to evaluate peak throughput, memory overhead, and tail-latency profiles. Read the Go vs Java Performance Showdown.
Sources
- 1.JEP 444: Virtual Threads — OpenJDK, 2023
- 2.JEP 491: Synchronize Virtual Threads without Pinning — OpenJDK, 2025
- 3.Spring Boot 3.2+ — Virtual Threads support and configuration — Spring Documentation, 2026
- 4.HikariCP 7.0.2 — ConcurrentBag.requite() yield-spin still saturates all carrier threads under virtual thread load (issue #2398) — GitHub — brettwooldridge/HikariCP, 2026
- 5.HikariCP CHANGES (release changelog) — GitHub — brettwooldridge/HikariCP, 2026
- 6.PostgreSQL JDBC Driver CHANGELOG — GitHub — pgjdbc/pgjdbc, 2026
- 7.Virtual Thread pinned in PostgreSQL JDBC Driver 42.7.8 (issue #3835) — GitHub — pgjdbc/pgjdbc, 2025
- 8.MySQL Connector/J CHANGES (release/9.x) — Oracle — GitHub mysql/mysql-connector-j, 2024
- 9.JDBC Developer's Guide: Introducing JDBC (Table 1-2 Feature List) — Oracle, 2025
- 10.Apache HttpComponents Client 5.x RELEASE_NOTES — Apache Software Foundation, 2024
- 11.jackson-databind release-notes/VERSION-2.x — FasterXML, 2024
- 12.jackson-core release-notes/VERSION-2.x — FasterXML, 2024
- 13.HHH-17005: Loom: remove pinning in Id generation optimizers — Hibernate ORM issue tracker (Jira), 2023
- 14.bc-java: tls/.../jsse/provider/ProvSSLSocketDirect.java at tag r1rv86 (Bouncy Castle 1.86) — Legion of the Bouncy Castle — GitHub bcgit/bc-java, 2026
- 15.bc-java: tls/.../tls/TlsProtocol.java at tag r1rv86 (Bouncy Castle 1.86) — Legion of the Bouncy Castle — GitHub bcgit/bc-java, 2026
- 16.JEP 525: Structured Concurrency (Sixth Preview) — OpenJDK, 2025
- 17.JDK 27 (OpenJDK project page) — OpenJDK, 2026
- 18.JEP 533: Structured Concurrency (Seventh Preview) — OpenJDK, 2026
Engineering Team
An independent engineering publication covering distributed systems, databases, and production infrastructure. Every factual claim is cited to a primary source or removed.
Read Next
Modern Java Features: A Practical Guide from Java 8 to 21
The features that changed Java: lambdas, streams, records, sealed classes, pattern matching, and virtual threads. Java 8 to 21.
JVM Cold Start in 2026: Leyden vs GraalVM Native Image vs CRaC
Slow JVM cold start breaks scale-to-zero and autoscaling. Three ways to fix it in 2026 — Project Leyden's AOT cache, GraalVM Native Image, and CRaC — with the exact commands, the trade-offs, and a decision framework.
GraalVM Native Images in Production: From Seconds to Milliseconds
From multi-second Spring Boot cold starts to millisecond startup with GraalVM native images. The real gotchas, wins, and whether it's worth it.