How to Supercharge Tomcat 11 on JDK 21: Real‑World JVM, Connection Pool, and Virtual Thread Tuning

This guide walks you through practical performance tuning of Tomcat 11 on JDK 21, covering JVM memory settings, GC selection, connector and thread‑pool optimization, virtual‑thread integration, benchmark scripts, security hardening, high‑availability deployment, and comprehensive monitoring and troubleshooting techniques.

Ops Community
Ops Community
Ops Community
How to Supercharge Tomcat 11 on JDK 21: Real‑World JVM, Connection Pool, and Virtual Thread Tuning

Overview

Tomcat 11.x runs on Jakarta EE 11 and supports JDK 21 virtual threads (Project Loom). It can be tuned for high‑concurrency Java web services by adjusting JVM memory, GC, connector, thread pool, and database pool settings, then validating the changes with load‑testing tools such as wrk or JMeter.

Environment Requirements

OS: CentOS Stream 9, Ubuntu 22.04+, or Rocky Linux 9 (Linux 5.15+ kernel with io_uring support)

JDK: OpenJDK 21.0.4+ or Oracle JDK 21 (LTS, required for virtual threads)

Tomcat: 11.0.x (current 11.0.6)

Memory: ≥ 4 GB (recommended ≥ 8 GB) – allocate heap, metaspace, direct memory and leave OS reserve

CPU: ≥ 2 cores (recommended ≥ 4 cores) – GC thread count is based on cores

Load‑test tools: JMeter 5.6+ or wrk 4.2+

Preparation

System Check

# Verify OS version
cat /etc/os-release
# CPU cores
nproc
# Physical memory
free -h
# Disk space
df -h
# Kernel version
uname -r

Install JDK 21

# Ubuntu/Debian
sudo apt update && sudo apt install -y openjdk-21-jdk
# CentOS/Rocky
sudo dnf install -y java-21-openjdk java-21-openjdk-devel
# Verify
java -version

Install Tomcat 11

# Download
cd /opt
wget https://dlcdn.apache.org/tomcat/tomcat-11/v11.0.6/bin/apache-tomcat-11.0.6.tar.gz
# Extract and link
tar -xzf apache-tomcat-11.0.6.tar.gz
ln -s /opt/apache-tomcat-11.0.6 /opt/tomcat
# Create dedicated user
useradd -r -s /sbin/nologin tomcat
chown -R tomcat:tomcat /opt/apache-tomcat-11.0.6
# Verify installation
/opt/tomcat/bin/version.sh

Baseline Benchmark

Run a quick load test before any tuning to capture QPS, latency, and error rate.

# Start Tomcat with default config
sudo -u tomcat /opt/tomcat/bin/startup.sh
# 30‑second wrk test (4 threads, 200 connections)
wrk -t4 -c200 -d30s http://localhost:8080/
# Stop Tomcat
/opt/tomcat/bin/shutdown.sh

Core Configuration

JVM Parameter Tuning (setenv.sh)

Create /opt/tomcat/bin/setenv.sh (executable) and add the following. Adjust values to match your hardware and workload.

#!/bin/bash
# Example for an 8 GB heap on an 8 GB+ server
CATALINA_OPTS="-Xms8g -Xmx8g \
 -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m \
 -XX:MaxDirectMemorySize=2g \
 -XX:+UseG1GC -XX:MaxGCPauseMillis=150 -XX:G1HeapRegionSize=8m \
 -XX:InitiatingHeapOccupancyPercent=45 -XX:ParallelGCThreads=6 -XX:ConcGCThreads=2 \
 -XX:+UseStringDeduplication -XX:+DisableExplicitGC \
 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/tomcat/logs/heapdump.hprof \
 -Xlog:gc*,gc+age=trace,gc+heap=debug:file=/opt/tomcat/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50m"
export CATALINA_OPTS

If you need sub‑millisecond pause times or have a heap larger than 16 GB, replace the G1 block with ZGC:

# ZGC (generational by default in JDK 21)
CATALINA_OPTS="$CATALINA_OPTS -XX:+UseZGC -XX:SoftMaxHeapSize=3g -XX:ConcGCThreads=2"
export CATALINA_OPTS

Connector Configuration (server.xml)

<Connector port="8080"
           protocol="org.apache.coyote.http11.Http11Nio2Protocol"
           maxThreads="500"
           minSpareThreads="50"
           acceptCount="300"
           connectionTimeout="20000"
           maxConnections="10000"
           maxKeepAliveRequests="200"
           keepAliveTimeout="30000"
           compression="on"
           compressionMinSize="2048"
           compressibleMimeType="text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml"
           URIEncoding="UTF-8"
           server="WebServer"
           maxHttpHeaderSize="16384"
           maxPostSize="10485760" />

Key points: protocol set to Http11Nio2Protocol gives 10‑20 % higher throughput under high concurrency. maxThreads should be 2‑4 × CPU cores for I/O‑bound services; maxConnections can be larger because a single NIO thread handles many connections.

Enable GZIP compression but keep compressionMinSize at 2048 B to avoid compressing tiny payloads.

Thread‑Pool (Executor) Configuration

<Executor name="tomcatThreadPool"
          namePrefix="catalina-exec-"
          maxThreads="500"
          minSpareThreads="50"
          maxIdleTime="60000"
          prestartminSpareThreads="true"
          maxQueueSize="200" />

<Connector port="8080"
           protocol="org.apache.coyote.http11.Http11Nio2Protocol"
           executor="tomcatThreadPool"
           ... />

Virtual‑Thread Executor (Tomcat 11 + JDK 21)

<Executor name="virtualThreadExecutor"
          className="org.apache.catalina.core.StandardVirtualThreadExecutor"
          namePrefix="vt-exec-" />

<Connector port="8080"
           protocol="org.apache.coyote.http11.Http11Nio2Protocol"
           executor="virtualThreadExecutor"
           maxConnections="20000"
           connectionTimeout="20000"
           acceptCount="500" />

Virtual threads remove the need to size maxThreads. They are ideal for I/O‑heavy workloads (HTTP calls, DB queries, file I/O) but not for CPU‑bound computation.

Startup and Verification

# Ensure setenv.sh is executable
chmod +x /opt/tomcat/bin/setenv.sh
# Start Tomcat
sudo -u tomcat /opt/tomcat/bin/startup.sh
# Verify JVM flags
jcmd $(pgrep -f catalina) VM.flags
# Verify heap info
jcmd $(pgrep -f catalina) GC.heap_info
# Quick HTTP check (requires manager credentials)
curl -I -u admin:pwd http://localhost:8080/manager/status

Post‑Tuning Benchmark

# Same wrk parameters as baseline
wrk -t4 -c200 -d30s http://localhost:8080/
# Expected improvement: QPS +30‑100 %, P99 latency –40‑60 %

Sample Configurations

Full setenv.sh Template (production)

#!/bin/bash
# Memory regions
HEAP_OPTS="-Xms8g -Xmx8g"
META_OPTS="-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m"
DIRECT_OPTS="-XX:MaxDirectMemorySize=2g"
# G1 GC (replace with ZGC block if needed)
GC_OPTS="-XX:+UseG1GC -XX:MaxGCPauseMillis=150 -XX:G1HeapRegionSize=16m -XX:InitiatingHeapOccupancyPercent=45 -XX:ParallelGCThreads=6 -XX:ConcGCThreads=2 -XX:+UseStringDeduplication"
# GC logging (JDK 21 unified format)
GC_LOG="-Xlog:gc*,gc+age=trace,gc+heap=debug:file=/opt/tomcat/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=100m"
# OOM handling
OOM_OPTS="-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/tomcat/logs/heapdump-%t.hprof -XX:+ExitOnOutOfMemoryError"
# JMX (enable only if remote monitoring is required)
JMX_OPTS="-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9090 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=true -Dcom.sun.management.jmxremote.password.file=/opt/tomcat/conf/jmxremote.password -Dcom.sun.management.jmxremote.access.file=/opt/tomcat/conf/jmxremote.access"
# Miscellaneous
MISC_OPTS="-XX:+DisableExplicitGC -Djava.security.egd=file:/dev/./urandom -Dfile.encoding=UTF-8 -Djava.net.preferIPv4Stack=true"
# Combine all
export CATALINA_OPTS="$HEAP_OPTS $META_OPTS $DIRECT_OPTS $GC_OPTS $GC_LOG $OOM_OPTS $JMX_OPTS $MISC_OPTS"

server.xml Core Snippet

<Server port="-1" shutdown="DISABLED">
  <GlobalNamingResources>
    <Resource name="UserDatabase" auth="Container" type="org.apache.catalina.UserDatabase" description="User database" factory="org.apache.catalina.users.MemoryUserDatabaseFactory" pathname="conf/tomcat-users.xml" />
  </GlobalNamingResources>
  <Service name="Catalina">
    <Executor name="tomcatThreadPool" namePrefix="catalina-exec-" maxThreads="500" minSpareThreads="50" maxIdleTime="60000" prestartminSpareThreads="true" maxQueueSize="200" />
    <Connector port="8080" protocol="org.apache.coyote.http11.Http11Nio2Protocol" executor="tomcatThreadPool" maxConnections="10000" acceptCount="300" connectionTimeout="20000" maxKeepAliveRequests="200" keepAliveTimeout="30000" compression="on" compressionMinSize="2048" compressibleMimeType="text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml" URIEncoding="UTF-8" server="WebServer" maxHttpHeaderSize="16384" maxPostSize="10485760" />
    <Connector port="8443" protocol="org.apache.coyote.http11.Http11Nio2Protocol" executor="tomcatThreadPool" maxConnections="10000" SSLEnabled="true" scheme="https" secure="true">
      <SSLHostConfig protocols="TLSv1.3,TLSv1.2" ciphers="TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256">
        <Certificate certificateKeystoreFile="conf/keystore.p12" certificateKeystorePassword="changeit" type="RSA" />
      </SSLHostConfig>
    </Connector>
    <Engine name="Catalina" defaultHost="localhost">
      <Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="false">
        <Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs" prefix="access_log" suffix=".log" pattern="%h %l %u %t \"%r\" %s %b %D" rotatable="true" maxDays="30" />
      </Host>
    </Engine>
  </Service>
</Server>

HikariCP DataSource (Spring Boot)

spring:
  datasource:
    type: com.zaxxer.hikari.HikariDataSource
    url: jdbc:mysql://db-host:3306/mydb?useSSL=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
    username: app_user
    password: ${DB_PASSWORD}
    hikari:
      pool-name: HikariPool-Main
      maximum-pool-size: 20
      minimum-idle: 20
      connection-timeout: 3000
      idle-timeout: 600000
      max-lifetime: 1800000
      connection-test-query: SELECT 1
      validation-timeout: 2000
      leak-detection-threshold: 30000
      connection-init-sql: SET NAMES utf8mb4

Best Practices & Security Hardening

Performance Tips

Set -Xms and -Xmx to the same value to avoid runtime heap resizing.

Size thread pool based on workload: CPU‑bound ≈ cores + 1; I/O‑bound ≈ 2‑4 × cores. Use virtual threads for pure I/O workloads.

Prefer Http11Nio2Protocol over classic NIO for ~10‑20 % throughput gain.

Enable GZIP compression but keep compressionMinSize at 2048 B.

Pre‑warm HikariCP by setting minimum-idle equal to maximum-pool-size.

Disable autoDeploy in production to stop periodic WAR scanning.

Security Measures

Disable remote shutdown: set port="-1" shutdown="DISABLED" in server.xml.

Remove default webapps (ROOT, docs, examples, manager, host‑manager) from /opt/tomcat/webapps.

Hide Tomcat version by setting server="WebServer" and providing custom error pages in conf/web.xml.

If JMX is enabled, configure jmxremote.password and jmxremote.access with strict permissions.

Consider enabling the (deprecated) Java Security Manager only for legacy multi‑tenant scenarios; prefer container isolation for new deployments.

High Availability

Deploy at least two Tomcat instances behind a load balancer (NGINX, HAProxy).

Externalize HTTP session state to Redis (e.g., RedisSessionHandlerValve) or Spring Session.

Use graceful shutdown ( /opt/tomcat/bin/shutdown.sh 30) to allow in‑flight requests to finish.

Version‑control the conf/ directory with Git and back up webapps/ regularly.

Troubleshooting & Monitoring

Log Inspection

# Core logs
tail -f /opt/tomcat/logs/catalina.out
# Date‑rotated catalina log
tail -f /opt/tomcat/logs/catalina.$(date +%Y-%m-%d).log
# Access log (contains response time)
tail -f /opt/tomcat/logs/access_log.$(date +%Y-%m-%d).log
# GC log
tail -f /opt/tomcat/logs/gc.log
# Search for errors
grep -i "ERROR\|SEVERE\|Exception\|OutOfMemory" /opt/tomcat/logs/catalina.out | tail -50
# Find slow requests (>5 s)
awk -F'"' '{print $0}' /opt/tomcat/logs/access_log.*.log | awk '{if($NF>5000) print $0}' | tail -20

Common Issues

java.lang.OutOfMemoryError: Java heap space

– increase -Xmx or analyze heap dump with MAT. java.lang.OutOfMemoryError: Metaspace – increase -XX:MaxMetaspaceSize and avoid hot‑deploy in production. java.lang.OutOfMemoryError: Direct buffer memory – increase -XX:MaxDirectMemorySize or close unused ByteBuffer s. TaskQueue: maxThreads reached – raise maxThreads or fix slow requests. Connection pool exhausted – enlarge HikariCP pool, fix slow SQL, enable leak detection. Too many open files – raise ulimit -n (e.g., 65535).

GC Log Quick Analysis (bash)

#!/bin/bash
GC_LOG=${1:-/opt/tomcat/logs/gc.log}
if [ ! -f "$GC_LOG" ]; then
  echo "GC log not found: $GC_LOG"
  exit 1
fi

echo "===== GC LOG SUMMARY ====="
# Count events
echo "Young GC count: $(grep -c 'Pause Young' "$GC_LOG")"
echo "Full GC count: $(grep -c 'Pause Full' "$GC_LOG")"
# Average/Max Young pause
grep -oP 'Pause Young.*?([0-9]+\.[0-9]+)ms' "$GC_LOG" | grep -oP '[0-9]+\.[0-9]+' |
awk '{sum+=$1; cnt++; if($1>max) max=$1} END {print "Young GC avg pause: " sum/cnt " ms, max: " max " ms"}'
# Warn on Full GC
FULL=$(grep -c 'Pause Full' "$GC_LOG")
if [ $FULL -gt 0 ]; then
  echo "!!! Detected $FULL Full GC events !!!"
  tail -3 "$GC_LOG" | grep 'Pause Full'
fi

echo "===== END OF SUMMARY ====="

Prometheus + Grafana Monitoring (JMX Exporter)

Download the JMX Exporter agent and add it to setenv.sh:

wget https://repo1.maven.org/maven2/io/prometheus/jmx/jmx_prometheus_javaagent/1.0.1/jmx_prometheus_javaagent-1.0.1.jar -O /opt/tomcat/lib/jmx_exporter.jar

export CATALINA_OPTS="$CATALINA_OPTS -javaagent:/opt/tomcat/lib/jmx_exporter.jar=9404:/opt/tomcat/conf/jmx_exporter_config.yaml"

Sample jmx_exporter_config.yaml (kept as plain text):

startDelaySeconds: 0
lowercaseOutputName: true
lowercaseOutputLabelNames: true
rules:
  - pattern: 'Catalina<type=ThreadPool, name="(.*)">(.*)'
    name: tomcat_threadpool_$2
    labels:
      connector: "$1"
    type: GAUGE
  - pattern: 'Catalina<type=GlobalRequestProcessor, name="(.*)">(.*)'
    name: tomcat_request_$2
    labels:
      connector: "$1"
    type: COUNTER
  - pattern: 'Catalina<type=Manager, host=(.*), context=(.*)><>activeSessions'
    name: tomcat_sessions_active
    labels:
      host: "$1"
      context: "$2"
    type: GAUGE
  - pattern: 'java.lang<type=Memory><HeapMemoryUsage>(.*)'
    name: jvm_heap_memory_$1
    type: GAUGE
  - pattern: 'com.zaxxer.hikari<type=Pool \((.*)\)>(.*)'
    name: hikaricp_$2
    labels:
      pool: "$1"
    type: GAUGE

Prometheus scrape config snippet (plain text):

scrape_configs:
  - job_name: 'tomcat'
    scrape_interval: 15s
    static_configs:
      - targets: ['tomcat-host:9404']
        labels:
          instance: 'tomcat-prod-01'

Backup & Restore

Backup Script

#!/bin/bash
TOMCAT_HOME="/opt/tomcat"
BACKUP_DIR="/data/backup/tomcat"
DATE=$(date +%Y%m%d_%H%M%S)
RETAIN_DAYS=30
mkdir -p "$BACKUP_DIR"
# Config backup
tar -czf "$BACKUP_DIR/tomcat_conf_${DATE}.tar.gz" -C "$TOMCAT_HOME" conf/
# Webapps backup
tar -czf "$BACKUP_DIR/tomcat_webapps_${DATE}.tar.gz" -C "$TOMCAT_HOME" webapps/
# setenv.sh backup
cp "$TOMCAT_HOME/bin/setenv.sh" "$BACKUP_DIR/setenv_${DATE}.sh"
# systemd service backup (optional)
cp /etc/systemd/system/tomcat.service "$BACKUP_DIR/tomcat_service_${DATE}" 2>/dev/null
# Clean old backups
find "$BACKUP_DIR" -type f -mtime +$RETAIN_DAYS -delete

echo "[$(date)] Backup completed: $BACKUP_DIR"
ls -lh "$BACKUP_DIR"/*${DATE}*

Restore Procedure

# Stop service
sudo systemctl stop tomcat
# Backup current config (just in case)
cp -r /opt/tomcat/conf /opt/tomcat/conf.bak.$(date +%s)
# Restore config
tar -xzf /data/backup/tomcat/tomcat_conf_20260225_100000.tar.gz -C /opt/tomcat/
# Restore webapps (if needed)
tar -xzf /data/backup/tomcat/tomcat_webapps_20260225_100000.tar.gz -C /opt/tomcat/
# Fix permissions
chown -R tomcat:tomcat /opt/tomcat/conf /opt/tomcat/webapps
# Verify syntax (optional)
/opt/tomcat/bin/configtest.sh
# Start service and health check
sudo systemctl start tomcat
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/
# Expected 200

Conclusion

Key Takeaways

Align -Xms and -Xmx to lock heap size and avoid runtime resizing.

Select GC based on heap size: G1 for typical workloads, ZGC for ultra‑low latency or > 16 GB heaps.

Connector and thread‑pool tuning (NIO2, maxThreads, maxConnections) directly affect throughput; virtual threads eliminate manual sizing for I/O‑bound services.

Size HikariCP pool using the formula (CPU × 2) + disk and keep minimum-idle equal to maximum-pool-size to avoid runtime scaling overhead.

Enable structured GC logging ( -Xlog:gc*) and use tools like GCEasy or GCViewer for quick analysis.

Always benchmark before and after tuning; use wrk for quick tests and JMeter for full‑scenario validation.

Further Learning

Deep dive into JVM performance engineering with Java Flight Recorder (JFR) and Java Mission Control (JMC).

Container‑aware tuning: -XX:ActiveProcessorCount, -XX:MaxRAMPercentage, and cgroup memory limits.

Explore GraalVM native images for ultra‑fast startup and low memory footprints in micro‑service environments.

References

Apache Tomcat 11 Official Documentation

Oracle JDK 21 GC Tuning Guide

HikariCP GitHub Repository

Prometheus JMX Exporter GitHub

GCEasy Online GC Log Analyzer

JDK 21 Release Notes

JVMDockerzgcPerformance TuningVirtual ThreadstomcatJDK21G1 GC
Ops Community
Written by

Ops Community

A leading IT operations community where professionals share and grow together.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.