You upgraded to JDK 21, enabled virtual threads, and the one Tomcat gauge every dashboard and every alert leans on (currentThreadsBusy) now reads -1. maxThreads still shows 200 but is meaningless. The thread pool utilization panel that used to be your primary saturation signal is blank or broken, and your SLO alerts are either firing constantly or silently dead.
This is expected, by-design behavior, not a bug. When a connector runs on virtual threads (useVirtualThreads="true" on NioEndpoint, or StandardVirtualThreadExecutor, on Tomcat 10.1.x/11.0.x with JDK 21+, or Tomcat 9.0.84+ with useVirtualThreads), Tomcat’s classic bounded-thread-pool model stops applying. There is no fixed pool to measure. currentThreadsBusy returns -1, maxThreads is inherited from defaults and not honored, and the connection layer becomes your only proxy for active request load.
This article explains exactly why the gauge goes to -1, how to confirm virtual threads are actually in use (and not silently disabled by a protocol mismatch), and which signals replace the classic pool gauge so your monitoring keeps working after the switch.
What this means
A -1 from currentThreadsBusy is a sentinel meaning “this connector does not have a measurable bounded thread pool.” It is not an error state. It shows up in JMX, in the Manager Status XML, and in any collector (Netdata, Micrometer, Jolokia) that reads the Catalina:type=ThreadPool,name="http-nio-8080" MBean.
What it does NOT mean:
- Tomcat is broken.
- Requests are failing.
- The thread pool is exhausted.
- A collector bug produced the value.
What it DOES mean is that every alert, SLO, and capacity-planning assumption built on currentThreadsBusy / maxThreads is now invalid for that connector. Those need to be rebuilt around connection-based signals before you lose your primary saturation visibility.
flowchart LR
A[Request arrives] --> B{Thread model}
B -->|Classic pool| C[Acquire platform thread]
C --> D[Fixed maxThreads]
D --> E[currentThreadsBusy measurable]
E --> F[Alert on busy / max ratio]
B -->|Virtual threads| G[Spawn virtual thread per request]
G --> H[currentThreadsBusy = -1]
H --> I[Alert on connectionCount / maxConnections]Why currentThreadsBusy reports -1
The mechanism lives in AbstractEndpoint.getCurrentThreadsBusy(). The method uses a pattern-matching switch that checks whether the endpoint’s executor implements one of three interfaces: Tomcat’s own ThreadPoolExecutor, java.util.concurrent.ThreadPoolExecutor, or ResizableExecutor. If the executor matches one of those, it can report active vs. max threads. If it matches none of them, the default branch returns -1.
The virtual-thread executors (VirtualThreadExecutor, used internally, and StandardVirtualThreadExecutor, configurable via <Executor>) do not implement any of those interfaces. They also do not track in-flight tasks: VirtualThreadExecutor explicitly does not maintain a count of running tasks, and shutdownNow() returns an empty list because there is nothing to enumerate. So even if the switch tried to ask the executor for a busy count, the executor has no answer to give.
Two consequences follow:
- There is no per-connector “active virtual thread” number to expose. The executor spawns a new virtual thread per request and they terminate on completion; there is no fixed set to measure utilization against.
- The JVM itself cannot give Tomcat a clean answer.
java.lang.management.ThreadMXBeanaggregates virtual threads across the entire JVM. Tomcat cannot isolate its own virtual threads because the JVM does not expose a mechanism to partition virtual threads into pools or groups.
This is why the canonical guidance is definitive: there are no plans to add a virtual-thread count metric, and the recommended proxy is connectionCount - keepAliveCount from the connector.
Common causes of a -1 (or related broken readings)
| Cause | What it looks like | First thing to check |
|---|---|---|
| Virtual threads actually enabled | currentThreadsBusy == -1 on one connector, normal values on others | useVirtualThreads flag or <Executor> class in server.xml |
| NIO2 protocol in use | currentThreadsBusy == -1 AND keepAliveCount == -1 | Connector protocol attribute (Http11Nio2Protocol) |
| Virtual threads requested but not dispatched | currentThreadsBusy is a normal number despite useVirtualThreads="true" | Whether you are on Nio2Endpoint with an unfixed version (Bugzilla 68312) |
Spring Boot threads.max ignored | You set server.tomcat.threads.max, it has no effect, pool is unbounded | Whether spring.threads.virtual.enabled=true is set |
| Pre-virtual-threads confusion | -1 on an older Tomcat or JDK | JDK version (needs 21+) and Tomcat version |
Quick checks
# Confirm JDK version supports virtual threads
java -version
# Confirm Tomcat version
$CATALINA_HOME/bin/version.sh
# Confirm the JVM process is the Tomcat one
pgrep -f 'org.apache.catalina.startup.Bootstrap'
# Check executor and connector config (read-only)
grep -E 'Executor|Connector' $CATALINA_BASE/conf/server.xml
# Read currentThreadsBusy and maxThreads from JMX
java -jar jmxterm.jar -l localhost:9090 -n -v silent -e \
"get -b Catalina:type=ThreadPool,name=\"http-nio-8080\" currentThreadsBusy currentThreadCount maxThreads"
# Confirm the connector's useVirtualThreads flag (added in later 10.1.x)
java -jar jmxterm.jar -l localhost:9090 -n -v silent -e \
"get -b Catalina:type=ProtocolHandler,name=\"http-nio-8080\" useVirtualThreads"
How to diagnose it
- Confirm -1 is the sentinel, not a transient error. Read
currentThreadsBusytwice over a few seconds. A stable -1 means the connector has no measurable pool. A number that flips to -1 intermittently is a different problem (collector restart, MBean reregistration). - Confirm virtual threads are actually enabled. Look for either
<Executor name="..." class="org.apache.catalina.core.StandardVirtualThreadExecutor"/>referenced by the connector, oruseVirtualThreads="true"on the connector itself. On Tomcat 10.1.x from around May 2025, theuseVirtualThreadsflag is exposed on the connector MBean, which is the most reliable confirmation. - Confirm requests are actually running on virtual threads. This is the trap. On NIO2 (
Http11Nio2Protocol), some Tomcat 10.1.x builds did NOT dispatch request code to virtual threads even withuseVirtualThreads="true"set. The connector looked configured for virtual threads but ran on platform threads. If you see normalcurrentThreadsBusynumbers despite enabling virtual threads, you may be hitting this. Switch to NIO (Http11NioProtocol) or verify your version includes the fix (Bugzilla 68312).
- Check the protocol for keepAliveCount availability. If you intend to use
connectionCount - keepAliveCountas your proxy, NIO2 returnskeepAliveCount == -1because it is not tracked. Only NIO exposes a usablekeepAliveCount. On NIO2 you must switch to NIO to use this proxy, or fall back to a different signal. - Verify you are not double-configuring. On Tomcat 10.1.33+, declaring
<Executor class="...StandardVirtualThreadExecutor">is redundant: whenuseVirtualThreads="true"is set, the connector creates its own internalVirtualThreadExecutorand the explicit Executor element is ignored. Remove the redundant declaration to avoid confusion.
Metrics and signals to monitor
| Signal | Why it matters | Warning sign |
|---|---|---|
connectionCount - keepAliveCount | Best available proxy for active requests on virtual threads | Trending toward maxConnections with throughput dropping |
connectionCount / maxConnections | Replaces the pool-utilization ratio as your saturation signal | Sustained above 0.80 means the poller is filling |
requestCount rate | Confirms requests are flowing, independent of thread model | Throughput dropping while connections accumulate |
processingTime / requestCount | Average processing time; rises when requests are stuck | Trending upward indicates backend slowdown |
errorCount rate | 503s from the connector itself indicate connection-layer saturation | Spike correlates with connectionCount near max |
OS accept queue (Recv-Q from ss -ltn) | Last buffer before connection refusal; invisible to JMX | Non-zero sustained Recv-Q means Tomcat cannot accept fast enough |
maxConnections | The new hard ceiling that matters | NIO/NIO2 default 10000; verify your deployment’s value |
Note the off-by-one: connectionCount may report one more than the actual number of live connections on some Tomcat versions, so a value of 1 can mean zero current connections. Account for this in any threshold you compute.
Fixes: rebuilding your monitoring around connections
Once virtual threads are confirmed in use, the classic thread-pool alerts and dashboards are dead for that connector. Replace them.
Replace pool-utilization alerts with connection-utilization alerts
Build your primary saturation alert on connectionCount / maxConnections, not on currentThreadsBusy / maxThreads. The thresholds translate loosely: sustained above 0.80 is the warning band, sustained near 1.0 means new connections will queue in the OS accept queue and then be refused. The cascade pattern is the same one described in Tomcat thread pool exhaustion, except it now happens at the connection layer instead of the thread layer.
Use connectionCount minus keepAliveCount for active load
For an estimate of how many requests are actively being processed (the closest analogue to the old “busy threads” concept), compute connectionCount - keepAliveCount. This requires NIO, not NIO2. On older 10.1.x builds, verify keepAliveCount returns a real number, not -1 and not the pre-fix wrong value for NIO that was corrected in later 10.1.x.
Drop StuckThreadDetectionValve expectations
StuckThreadDetectionValve tracks threads by name and assumes a pooled, long-lived thread identity. Virtual threads are ephemeral and not pooled, so the valve’s behavior with virtual threads is unreliable. Do not depend on it as your stuck-request detector after the switch. Prefer per-request latency from the access log (%D pattern, milliseconds in Tomcat 9.x/10.x) for tail-latency and stuck-request detection.
Remove the redundant Executor element
On 10.1.33+, the <Executor class="...StandardVirtualThreadExecutor"> declaration is ignored when the connector has useVirtualThreads="true". Leaving it in misleads operators into thinking it is doing something. Remove it and rely on the connector’s internal executor. Note that StandardVirtualThreadExecutor only honors namePrefix; it does not honor maxThreads, minSpareThreads, maxIdleTime, or maxQueueSize.
Handle the Spring Boot threads.max trap
If you run embedded Tomcat via Spring Boot 3.2+, server.tomcat.threads.max is silently ignored when virtual threads are enabled. Do not rely on it for sizing. There is no fixed pool to size.
Prevention
- Update dashboards before enabling virtual threads. Build the connection-based panels first so you do not lose saturation visibility the moment the connector switches.
- Decide NIO vs NIO2 deliberately. If you intend to use
connectionCount - keepAliveCount, you need NIO. NIO2 makeskeepAliveCountunavailable. - Set explicit maxConnections. With threads unbounded,
maxConnectionsbecomes the real ceiling. Confirm it is set intentionally for your deployment, not left at a default. - Verify dispatch, not just configuration. After enabling
useVirtualThreads, confirm requests actually run on virtual threads (a thread dump shows virtual threads, andcurrentThreadsBusyreads -1). Configuration that does not dispatch is the silent-failure mode. - Rebuild SLOs on latency, not pool ratio. With no pool ratio to alert on, p95/p99 request latency from the access log becomes the primary user-impact signal.
How Netdata helps
- Per-second collection surfaces
currentThreadsBusy == -1the moment the connector restarts with virtual threads, before stale alerts fire on the old ratio. - Correlation of
connectionCount,maxConnections, and request rate on one dashboard replaces the pool-utilization panel. Per-second resolution matters because the connection layer can fill between coarser polls. - JVM thread count from
ThreadMXBeanis the only signal that catches a runaway virtual-thread leak. The JVM aggregates virtual threads without partitioning by source, so total count is the ceiling indicator. - OS-level accept queue and file descriptor metrics cover the kernel layer where connection refusal actually happens, invisible to Tomcat’s MBeans.
Related guides
- Tomcat java.net.BindException: Address already in use: the connector never starts
- Tomcat accepts connections but never responds: the TCP-connect trap
- How Tomcat actually works in production: a mental model for operators
- Tomcat HTTP Status 503 Service Unavailable: the connector is out of threads
- Tomcat process not running: crashes, OOM-kills, and failed restarts
- Tomcat monitoring checklist: the signals every production instance needs
- Tomcat monitoring maturity model: from survival to expert
- Tomcat thread pool exhaustion: currentThreadsBusy at maxThreads and requests hanging






