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:

  1. 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.
  2. The JVM itself cannot give Tomcat a clean answer. java.lang.management.ThreadMXBean aggregates 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.

CauseWhat it looks likeFirst thing to check
Virtual threads actually enabledcurrentThreadsBusy == -1 on one connector, normal values on othersuseVirtualThreads flag or <Executor> class in server.xml
NIO2 protocol in usecurrentThreadsBusy == -1 AND keepAliveCount == -1Connector protocol attribute (Http11Nio2Protocol)
Virtual threads requested but not dispatchedcurrentThreadsBusy is a normal number despite useVirtualThreads="true"Whether you are on Nio2Endpoint with an unfixed version (Bugzilla 68312)
Spring Boot threads.max ignoredYou set server.tomcat.threads.max, it has no effect, pool is unboundedWhether spring.threads.virtual.enabled=true is set
Pre-virtual-threads confusion-1 on an older Tomcat or JDKJDK 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

  1. Confirm -1 is the sentinel, not a transient error. Read currentThreadsBusy twice 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).
  2. Confirm virtual threads are actually enabled. Look for either <Executor name="..." class="org.apache.catalina.core.StandardVirtualThreadExecutor"/> referenced by the connector, or useVirtualThreads="true" on the connector itself. On Tomcat 10.1.x from around May 2025, the useVirtualThreads flag is exposed on the connector MBean, which is the most reliable confirmation.
  3. 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 with useVirtualThreads="true" set. The connector looked configured for virtual threads but ran on platform threads. If you see normal currentThreadsBusy numbers despite enabling virtual threads, you may be hitting this. Switch to NIO (Http11NioProtocol) or verify your version includes the fix (Bugzilla 68312).
  1. Check the protocol for keepAliveCount availability. If you intend to use connectionCount - keepAliveCount as your proxy, NIO2 returns keepAliveCount == -1 because it is not tracked. Only NIO exposes a usable keepAliveCount. On NIO2 you must switch to NIO to use this proxy, or fall back to a different signal.
  2. Verify you are not double-configuring. On Tomcat 10.1.33+, declaring <Executor class="...StandardVirtualThreadExecutor"> is redundant: when useVirtualThreads="true" is set, the connector creates its own internal VirtualThreadExecutor and the explicit Executor element is ignored. Remove the redundant declaration to avoid confusion.

Metrics and signals to monitor

SignalWhy it mattersWarning sign
connectionCount - keepAliveCountBest available proxy for active requests on virtual threadsTrending toward maxConnections with throughput dropping
connectionCount / maxConnectionsReplaces the pool-utilization ratio as your saturation signalSustained above 0.80 means the poller is filling
requestCount rateConfirms requests are flowing, independent of thread modelThroughput dropping while connections accumulate
processingTime / requestCountAverage processing time; rises when requests are stuckTrending upward indicates backend slowdown
errorCount rate503s from the connector itself indicate connection-layer saturationSpike correlates with connectionCount near max
OS accept queue (Recv-Q from ss -ltn)Last buffer before connection refusal; invisible to JMXNon-zero sustained Recv-Q means Tomcat cannot accept fast enough
maxConnectionsThe new hard ceiling that mattersNIO/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 makes keepAliveCount unavailable.
  • Set explicit maxConnections. With threads unbounded, maxConnections becomes 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, and currentThreadsBusy reads -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 == -1 the 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 ThreadMXBean is 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.