Skip to content

Java Virtual Threads: Project Loom, Pinning Hazards, and Production Migration

1
Part of Series: Java in Production 2026

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.

Java 24+ Update: Synchronized Pinning Eliminated

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.

TL;DR

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 synchronized block during I/O with ReentrantLock before migration (required for Java 21–23; unnecessary on Java 24+ per JEP 491⁠[2])
  • Enable jdk.VirtualThreadPinned JFR 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].

Java Virtual Threads M:N Model

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 TypeVirtual Thread BenefitWhy
I/O-bound, high concurrencyHighThreads spend most time blocked on network/disk; VTs unmount during waits
I/O-bound, low concurrencyLowThread pool is not the bottleneck
Mixed I/O + CPUModerateBenefits proportional to I/O ratio
CPU-boundNone or negativeVTs add scheduling overhead; no concurrency gain
Reactive (WebFlux)NoneAlready 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: 10

In 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:

LibraryVT-Safe GuidanceNotes
HikariCPUpgrade 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.842.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/JFixed 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 JDBCThin 21c+; OCI unsupportedOracle 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 5Fixed 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]
Jacksondatabind 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 ORM6.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]
BouncyCastleDepends on the artifactbcprov (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.jar

Create 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 synchronized blocks with ReentrantLock (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 synchronized rows 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.VirtualThreadPinned event recording to load test environment (Java 24+ removes synchronized pinning, not native-frame pinning)

When enabling virtual threads:

  • Set spring.threads.virtual.enabled=true
  • Verify @Async methods run on virtual threads via Thread.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.VirtualThreadSubmitFailed events
  • 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


Coming Next

Coming Next: Go vs Java in 2026: An Honest Performance Comparison for Backend Services

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. 1.JEP 444: Virtual Threads — OpenJDK, 2023
  2. 2.JEP 491: Synchronize Virtual Threads without Pinning — OpenJDK, 2025
  3. 3.Spring Boot 3.2+ — Virtual Threads support and configuration — Spring Documentation, 2026
  4. 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. 5.HikariCP CHANGES (release changelog) — GitHub — brettwooldridge/HikariCP, 2026
  6. 6.PostgreSQL JDBC Driver CHANGELOG — GitHub — pgjdbc/pgjdbc, 2026
  7. 7.Virtual Thread pinned in PostgreSQL JDBC Driver 42.7.8 (issue #3835) — GitHub — pgjdbc/pgjdbc, 2025
  8. 8.MySQL Connector/J CHANGES (release/9.x) — Oracle — GitHub mysql/mysql-connector-j, 2024
  9. 9.JDBC Developer's Guide: Introducing JDBC (Table 1-2 Feature List) — Oracle, 2025
  10. 10.Apache HttpComponents Client 5.x RELEASE_NOTES — Apache Software Foundation, 2024
  11. 11.jackson-databind release-notes/VERSION-2.x — FasterXML, 2024
  12. 12.jackson-core release-notes/VERSION-2.x — FasterXML, 2024
  13. 13.HHH-17005: Loom: remove pinning in Id generation optimizers — Hibernate ORM issue tracker (Jira), 2023
  14. 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. 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. 16.JEP 525: Structured Concurrency (Sixth Preview) — OpenJDK, 2025
  17. 17.JDK 27 (OpenJDK project page) — OpenJDK, 2026
  18. 18.JEP 533: Structured Concurrency (Seventh Preview) — OpenJDK, 2026
BackendBytes Engineering Team
BackendBytes

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