← Writing

Virtual threads and DB semaphores: controlling admission before the connection pool

On this page

Using virtual threads does not mean you must add a semaphore in front of the database. The connection pool already limits connection count. An additional semaphore is useful when you want to reject DB work earlier, bound the number of admitted operations, or allocate concurrency budgets to particular workloads. Oracle’s virtual-thread guide also explains that a database connection pool itself acts as a semaphore. Oracle virtual-thread guide

This article follows the JDK 21–25 virtual-thread comparison into database connections. It makes the DB-work boundary concrete within the broader admission control discussed in the 10k RPS architecture. The scope is Java 21 or later, with one synchronous JDBC transaction using one connection. Share the semaphore across an application instance; do not create a new one for every request.

What should you limit when you already have a connection pool?

Virtual threads let you retain many waiting tasks with relatively little thread overhead. They do not increase the database’s query capacity or reduce lock contention by themselves. Requests waiting for a connection from a full pool still retain memory and request state.

Situation Control to apply first
Connection limits and the pool’s wait limit are sufficient Keep the connection-pool configuration
You want to reject overloaded requests before they enter the pool Add admission control
A batch workload or expensive queries monopolize connections Set workload-specific budgets; separate pools if needed
Slow SQL or locks hold connections for too long Check queries, indexes, transactions and database limits first

HikariCP’s maximumPoolSize limits the number of connections in the pool. If none is available, getConnection() waits up to connectionTimeout and then throws an exception. Its minimum value is 250ms. A 20ms semaphore wait does not make the pool wait end within 20ms as well. HikariCP configuration

Bound both execution and total admission

The example uses two semaphores.

  • B execution permits: limit operations in progress from connection acquisition through transaction and connection cleanup.
  • B+Q admission permits: limit all operations inside this gate, including those waiting for an execution permit. Reject immediately when no admission permit remains.

When all B execution permits are in use, at most Q additional admitted operations can remain. However, tasks may also be scheduled before acquiring an execution permit, so this does not mean the number of waiters is always exactly bounded by Q at every instant. A single semaphore that only limits waiting time does not bound the number of admitted waiters.

Acquire admission and execution permits in order, then release only acquired permits after the DB transaction and connection cleanup

The DB-resource box summarizes internal exception paths. If connection acquisition fails, skip the work and proceed to permit release. If transaction setup fails, attempt to clean up the acquired connection. If the work or commit fails, attempt a rollback.

If admission fails, there is no permit to release. If execution acquisition fails or the waiting thread is interrupted, release only the admission permit. After both permits have been acquired, release them after connection acquisition, transaction processing and cleanup have unwound normally or exceptionally. Do not release permits from another thread merely because the caller timed out. If the actual DB work continues, doing so can exceed the configured execution limit.

How should you choose B, Q and pool size?

B counts concurrent DB operations, not requests per second. It includes connection-acquisition waits, commit and cleanup, so actual SQL concurrency may be lower than B. Choose it by measuring database saturation, connection holding time and the budgets of other workloads. Q is the number of additional operations to admit during overload; choose it with both the waiting budget and memory in mind.

For example, with pool P=24 and gate B=16, at most 16 operations can be in progress through this gate. The remaining 8 connections are not reserved for other work. Callers outside the gate that share the pool could occupy every connection. Even if you separate pools for isolation, add their connection budgets together at the database level.

When budgeting connection limits, check peak simultaneous instances × pool size per instance + batch, operations and other client connections ≤ DB connection budget. The instance peak includes autoscaling and overlapping old and new instances during a rolling deployment. instance count × B bounds only work passing through this gate. These numbers illustrate the calculation; they are neither recommended settings nor performance measurements.

A runnable JDBC example

The example gate owns its transaction and connection. The callback must execute synchronously and return only a fully materialized value. Do not commit, roll back or change auto-commit settings inside the callback. Returning a Connection, ResultSet or lazy query stream, or starting separate asynchronous DB work inside the callback, escapes the protection of this boundary.

DbBulkhead.java

import java.sql.Connection;
import java.sql.SQLException;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import javax.sql.DataSource;

public final class DbBulkhead {
    @FunctionalInterface
    public interface SqlWork<T> {
        T run(Connection connection) throws SQLException;
    }

    private final DataSource source;
    private final Semaphore slots;
    private final Semaphore running;

    public DbBulkhead(DataSource source, int concurrent, int extraSlots) {
        this.source = Objects.requireNonNull(source);
        if (concurrent < 1 || extraSlots < 0) {
            throw new IllegalArgumentException("invalid capacity");
        }
        slots = new Semaphore(Math.addExact(concurrent, extraSlots));
        running = new Semaphore(concurrent, true);
    }

    public <T> T transaction(Duration queueWait, SqlWork<T> work)
            throws SQLException, InterruptedException {
        Objects.requireNonNull(work);
        long nanos = queueWait.toNanos();
        if (nanos < 0) throw new IllegalArgumentException("negative wait");
        if (!slots.tryAcquire()) {
            throw new RejectedExecutionException("db admission full");
        }
        boolean acquired = false;
        try {
            acquired = running.tryAcquire(nanos, TimeUnit.NANOSECONDS);
            if (!acquired) {
                throw new RejectedExecutionException("db admission timed out");
            }
            try (Connection connection = source.getConnection()) {
                connection.setAutoCommit(false);
                try {
                    T value = work.run(connection);
                    connection.commit();
                    return value;
                } catch (SQLException | RuntimeException | Error failure) {
                    try {
                        connection.rollback();
                    } catch (SQLException rollbackFailure) {
                        failure.addSuppressed(rollbackFailure);
                    }
                    throw failure;
                }
            }
        } finally {
            if (acquired) running.release();
            slots.release();
        }
    }

    int available() { return running.availablePermits(); }
    int availableSlots() { return slots.availablePermits(); }
}

The code releases running only if it was acquired, so a failed wait or interruption cannot inflate permit count. SQL exceptions, runtime exceptions and errors trigger a rollback attempt; if rollback fails, that failure is attached to the original exception as a suppressed exception. The caller receives the return value only after commit and connection close() complete. If commit fails, or connection cleanup fails after commit, a write may have taken effect even though the caller receives an exception. Do not retry writes unconditionally.

Even a fair semaphore does not guarantee end-to-end ordering for every request. The example uses timed acquisition on a fair execution semaphore, but immediate acquisition for admission. Untimed tryAcquire() does not honor the fairness setting. The available() methods are for observing the lab; reading their values and deciding admission separately creates a race. Semaphore API

The demo configures pool size 2, B=1 and Q=0. It checks that a second operation is rejected while the first transaction holds its permit. Holding the transaction briefly with a latch coordinates the reproduction; it is not a recommended service-processing pattern.

Demo.java

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.SQLException;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;

public class Demo {
    static HikariDataSource pool() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(System.getenv("DB_URL"));
        config.setUsername(System.getenv("DB_USER"));
        config.setPassword(System.getenv("DB_PASSWORD"));
        config.setMaximumPoolSize(2);
        config.setMinimumIdle(0);
        config.setConnectionTimeout(500);
        config.setValidationTimeout(250);
        return new HikariDataSource(config);
    }

    public static void main(String[] args) throws Exception {
        try (var pool = pool(); var tasks = Executors.newVirtualThreadPerTaskExecutor()) {
            var gate = new DbBulkhead(pool, 1, 0);
            var entered = new CountDownLatch(1);
            var release = new CountDownLatch(1);
            var first = tasks.submit(() -> gate.transaction(Duration.ZERO, connection -> {
                if (!Thread.currentThread().isVirtual()) throw new AssertionError();
                try (var statement = connection.createStatement()) {
                    statement.setQueryTimeout(2);
                    try (var rows = statement.executeQuery("SELECT 42")) {
                        rows.next();
                        int value = rows.getInt(1);
                        entered.countDown();
                        try {
                            if (!release.await(5, TimeUnit.SECONDS)) {
                                throw new SQLException("demo coordination timed out");
                            }
                        } catch (InterruptedException interrupted) {
                            Thread.currentThread().interrupt();
                            throw new SQLException("demo interrupted", interrupted);
                        }
                        return value;
                    }
                }
            }));
            try {
                if (!entered.await(5, TimeUnit.SECONDS)) throw new AssertionError("not entered");
                var second = tasks.submit(() -> gate.transaction(Duration.ZERO, c -> 0));
                try {
                    second.get(5, TimeUnit.SECONDS);
                    throw new AssertionError("must reject");
                } catch (ExecutionException failure) {
                    if (!(failure.getCause() instanceof RejectedExecutionException)) throw failure;
                    System.out.println("overload=rejected");
                }
            } finally {
                release.countDown();
            }
            System.out.println("query=" + first.get(5, TimeUnit.SECONDS));
            System.out.println("available=" + gate.available());
        }
    }
}

build.gradle

plugins { id 'application' }
repositories { mavenCentral() }
dependencies {
    implementation 'com.zaxxer:HikariCP:7.1.0'
    runtimeOnly 'org.postgresql:postgresql:42.7.14'
    runtimeOnly 'org.slf4j:slf4j-simple:2.0.20'
}
sourceSets.main.java {
    srcDirs = ['.']
    include 'DbBulkhead.java', 'Demo.java', 'DbBulkheadChecks.java'
}
tasks.withType(JavaCompile).configureEach { options.release = 21 }
application { mainClass = 'Demo' }
tasks.register('copyDependencies', Copy) {
    from configurations.runtimeClasspath
    into layout.buildDirectory.dir('deps')
}
tasks.register('contracts', JavaExec) {
    classpath = sourceSets.main.runtimeClasspath
    mainClass = 'DbBulkheadChecks'
}

settings.gradle

rootProject.name = 'virtual-threads-db-semaphore'

Run locally

Save the four files above in a separate lab directory with Docker, JDK 21 and Gradle 8.14.3 installed. Gradle runs on JDK 21 to download dependencies. Compilation and execution then use the selected JDK’s javac and java directly, so testing JDK 25 does not require running Gradle 8.14.3 on JDK 25. Running Gradle itself on JDK 21 requires version 8.5 or later; this lab uses 8.14.3. Gradle’s Java 21 runtime support

Run the following commands in a POSIX shell. JAVA_HOME must point to the JDK to execute; select JDK 21 for the dependency-download step. The example password is only for the temporary local database. If port 55432 is already in use, choose a different port.

(
  set -eu
  gradle --no-daemon -q copyDependencies
  db_container=$(docker run -d --rm -p 127.0.0.1:55432:5432 \
    -e POSTGRES_USER=demo -e POSTGRES_DB=demo \
    -e POSTGRES_PASSWORD=example-only \
    postgres@sha256:839fbb5a4942a7758dd93e18b42ea5649807491a0545b5eeeb8f0b8bb11857a5)
  trap 'docker stop "$db_container" >/dev/null' EXIT
  ready=false
  for attempt in $(seq 1 30); do
    if docker exec "$db_container" pg_isready -h 127.0.0.1 -U demo -d demo >/dev/null 2>&1; then
      ready=true
      break
    fi
    sleep 1
  done
  if [ "$ready" != true ]; then exit 1; fi
  mkdir -p build/classes
  "$JAVA_HOME/bin/javac" --release 21 -cp 'build/deps/*' \
    -d build/classes DbBulkhead.java Demo.java
  DB_URL='jdbc:postgresql://127.0.0.1:55432/demo?connectTimeout=2&socketTimeout=5' \
    DB_USER=demo DB_PASSWORD=example-only \
    "$JAVA_HOME/bin/java" -cp 'build/classes:build/deps/*' Demo
)

The standard output observed locally was as follows. HikariCP startup and shutdown logs may appear separately.

overload=rejected
query=42
available=1

The verification environment was Temurin 21.0.13+9 and 25.0.5+8, PostgreSQL 17.11, HikariCP 7.1.0 and pgJDBC 42.7.14. Supplementary checks verified 45 conditions on each JDK: commit and rollback, excess admission, timed-out and interrupted waits, work continuing after cancellation, recovery after a server-side statement timeout, pool exhaustion and permit recovery after repeated failures. No HTTP load test or production throughput measurement was performed.

Boundaries to preserve with timeouts and Spring transactions

Separate waiting timeouts from work timeouts. The example’s queueWait limits only the wait for an execution permit. Pool acquisition, SQL and lock waits, networking and cleanup have separate budgets. An overall request deadline requires additional design to propagate remaining time to each stage. This example does not implement that end-to-end deadline propagation.

PostgreSQL’s statement_timeout limits statement execution time on the server, while lock_timeout limits waits to acquire locks. The lab set SET LOCAL statement_timeout = '100ms' inside a transaction, checked that pg_sleep(2) failed with SQLState 57014, and verified that subsequent work could proceed. Choose actual values to suit your queries and deadlines. PostgreSQL 17 timeout settings

Neither Future.cancel(true) nor ending an HTTP request guarantees immediate JDBC-query termination. pgJDBC has separate limits for queries, cancellation-signal transmission and socket reads. Closing a connection after a socket timeout is also different from confirming that server-side work has stopped. The executing boundary retains permit ownership during cancellation and cleanup. When the outcome of a write is uncertain, use an idempotency key or query the result to decide whether to retry. pgJDBC connection properties

With Spring, acquire admission before starting the transaction. A nontransactional entry method in a separate bean can pass through the gate before calling a transactional proxy, or you can run a TransactionTemplate inside the admitted scope. Release permits after the proxy call or template has completed commit and cleanup and returned. Waiting inside a method whose transaction has already started can retain resources belonging to the outer transaction. Do not mix the JDBC-ownership code above directly into an existing Spring transaction.

REQUIRES_NEW may need another connection while retaining the outer transaction’s connection. That breaks this example’s assumption of one connection per operation and can cause pool exhaustion or deadlock waits. The gate is also not reentrant; do not acquire the same gate recursively. Spring transaction propagation and pool-exhaustion warning

In production, count admission-full rejections separately from wait timeouts, and observe gate waiting time, pool waiting time, connection holding time, SQL duration and lock waits together. When mapping rejections to HTTP 503 or another response, define client retry budgets so unbounded retries do not amplify load. Do not raise B based only on rejection counts; use database latency and saturation to decide.

Discussion

View on GitHub ↗