A Tomcat instance that was steady for hours or days starts pausing. Latency p99 climbs, request throughput drops in bursts, and the JVM is alive but barely making progress. The thread pool is not the bottleneck: the wall clock is being eaten by garbage collection. In the GC log you see “Pause Full” lines arriving every few seconds, each stopping the world for hundreds of milliseconds to multiple seconds.
On G1GC, the default collector since JDK 9, this is not a tuning quirk. G1 does its old-generation work concurrently. A Full GC in G1 is a fallback, not a scheduled event, and it means the concurrent machinery failed to keep up with allocation or live-data growth. One Full GC during a traffic spike is worth a glance. Multiple per minute is a crisis that ends in OutOfMemoryError or effective livelock unless you intervene.
What this means
GC overhead ratio is cumulative GC time divided by wall clock over the same interval. The operator convention used in this guide: under 5% is healthy, over 10% is concerning, over 20% is critical. At 20% the JVM spends one fifth of every minute paused or collecting, and request processing is squeezed into the gaps between cycles. This is stricter than the JVM’s own GCTimeRatio=12 default, which targets roughly 8% of wall clock in GC. The 5% line is the threshold for user-facing services where pauses are user-visible.
Two signals matter more than the ratio alone:
- Full GC frequency on G1. In a healthy Tomcat, Full GCs are rare (hours apart) or absent. Multiple per minute means concurrent collection has lost the race against live-data growth or allocation rate.
- Individual pause duration. A single pause over 1 second on a user-facing service is usually page-worthy regardless of the overhead ratio, because every in-flight request during that pause stalls at once.
Together, these describe the GC death spiral: live data grows, the heap fills, GC runs more often, frees less each cycle, and application thread time between cycles collapses to near zero. The process stays alive, the connector keeps accepting connections, and from the outside the service looks “up but unresponsive.” This is the pattern most easily confused with a blocked backend, except CPU is high (GC threads are working) rather than low (threads are waiting on I/O). For the distinction, see the related guide on Tomcat threads busy but CPU idle: telling a blocked backend from a GC spiral.
Common causes
| Cause | What it looks like | First thing to check |
|---|---|---|
| Live-data growth (memory leak) | Post-GC old-gen baseline rising over hours; Full GC frees almost nothing | G1 Old Gen pool usage after GC |
| Evacuation failure | Concurrent cycle cannot copy live objects, falls back to Full GC | GC log tags around the Full GC line |
| Humongous allocation | Repeated large object allocation thrashes G1 regions | GC log for humongous allocation events |
| Promotion failure | Survivor space insufficient, objects promoted prematurely to old gen | Young GC frequency and survivor usage |
| Container CPU throttling | GC log real time much larger than user+sys time | cgroup CPU throttling counter |
| Heap too small for workload | Full GC starts soon after warmup, post-GC baseline near max | -Xmx vs live-data size at steady state |
| Session or cache accumulation | activeSessions or app cache grows monotonically with heap | Session count vs heap correlation |
Quick checks
These are safe, read-only, and can run during the incident without making things worse. If multiple Tomcat JVMs run on the host, identify the correct PID first rather than relying on the first pgrep match.
# Confirm the Tomcat PID (multiple matches mean multiple instances)
pgrep -f 'org.apache.catalina.startup.Bootstrap'
# Live GC counters, refreshed every second
# Columns: YGC YGCT FGC FGCT GCT
# FGC = Full GC count, FGCT = Full GC time (seconds), GCT = total GC time
jstat -gcutil <pid> 1000
# Cumulative collection count and time from JMX for the old generation
java -jar jmxterm.jar -l localhost:9090 -n -v silent -e \
"get -b java.lang:type=GarbageCollector,name=\"G1 Old Generation\" CollectionCount CollectionTime"
# Heap pool breakdown including G1 Old Gen
jcmd <pid> GC.heap_info
# Check for OutOfMemoryError in catalina.out (any occurrence is critical)
grep -c "OutOfMemoryError" /var/log/tomcat/catalina.out
# Container CPU throttling (cgroup v2 path; v1 uses cpuacct/cpu.stat)
cat /sys/fs/cgroup/cpu.stat 2>/dev/null
# Process RSS vs configured heap, to spot native memory pressure
ps -p <pid> -o rss,vsz,etime
If GC logging is not yet enabled, enable it now (see Fixes). JMX counters give you cumulative counts and times only. The log gives you per-event pause duration, cause tags, and the sequence of events leading into each Full GC.
How to diagnose it
flowchart TD
A["Full GC appears in G1 log"] --> B{"Post-GC old-gen baseline rising?"}
B -- Yes --> C["Live-data leak: capture heap dump"]
B -- No --> D{"Evacuation failure before Full GC?"}
D -- Yes --> E["Humongous alloc or region pressure"]
D -- No --> F{"Real time much larger than user+sys?"}
F -- Yes --> G["Container CPU throttling"]
F -- No --> H["Heap too small for live workload"]- Confirm G1 is actually falling back to Full GC. Watch
jstat -gcutilfor a few intervals. IfFGCincrements more than once per minute, orFGCTgrows faster than the sampling interval, you have frequent Full GCs. Cross-check with the JMXG1 Old Generationcollector bean. - Compute the GC overhead ratio. Over a 5-minute window, take the delta of total GC time (
GCTinjstat, or the sum ofCollectionTimeacross both G1 beans) and divide by 300 seconds. Over 5% is a real problem. Over 20% is the death spiral. - Read the GC log for the cause. Look at the lines immediately before each “Pause Full” event. Concurrent cycle tags that fail to complete, evacuation failure indicators, or humongous allocation events each point to a different root cause.
- Check the post-GC old-gen baseline. If “G1 Old Gen” pool usage after each Full GC is rising over hours and the Full GC frees almost nothing, live data is growing. This is a memory leak, not a tuning problem.
- Check container CPU throttling. If the GC log shows
realwall-clock time much larger thanuser+sysCPU time for the same event, GC threads are being descheduled by the cgroup CPU limit. The JVM thinks the pause took 200ms; the application experienced 2 seconds. - Take a heap dump before restarting. If the service is failing and a restart is inevitable, capture the heap first. After restart, the evidence is gone.
Metrics and signals to monitor
| Signal | Why it matters | Warning sign |
|---|---|---|
| GC overhead ratio | Total GC pressure vs wall clock | Over 5% sustained, over 20% critical |
| Full GC count (FGC) | G1 fallback events that should not happen | Any sustained increase on G1 |
| Full GC time (FGCT) | Wall clock lost to stop-the-world | Multiple seconds per event |
| Post-GC old-gen baseline | Live-data trajectory | Rising over hours or days |
| Request processing time | User-visible latency | Spikes coinciding with GC events |
| JVM CPU utilization | GC threads competing with app | Over 30% of CPU in GC means a memory problem |
| Container CPU throttling | Stretched pauses under cgroup limits | nr_throttled rising during GC |
| Active session count | Sessions are a classic heap consumer | Monotonic growth without plateau |
For a broader inventory of the signals every production Tomcat needs, see the Tomcat monitoring checklist and the Tomcat monitoring maturity model. GC overhead ratio tracking appears at Level 3 (Mature).
Fixes
Enable GC logging if you have not already
Without the log, you are limited to cumulative JMX counters and cannot see per-event pause duration or cause tags. Add the unified logging flag on JDK 9+:
-Xlog:gc*:file=/var/log/tomcat/gc.log:time,uptime:filecount=5,filesize=10M
On JDK 8, use -verbose:gc -Xloggc:/var/log/tomcat/gc.log. The old -XX:+PrintGCDetails and -Xloggc flags are deprecated since JDK 9 and removed in JDK 11+, so on modern Tomcat only -Xlog works. The rotation keeps five 10MB files, enough for post-incident analysis without filling the disk.
Live-data growth (memory leak)
If the post-GC old-gen baseline rises and Full GC frees nothing, you have a leak. Do not tune around it.
- Capture a heap dump before restart:
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>. WARNING: theliveoption triggers a Full GC and the dump can take minutes on a large heap. Run it only if the service is already effectively down. - Restart the JVM to restore service.
- Analyze the heap dump offline with Eclipse MAT or VisualVM to find the dominant retainer.
Common Tomcat-specific causes: unbounded session accumulation (especially from bots that do not send cookies, creating a new session per request), static collections that are never cleared, caches without eviction, and large response object graphs held in memory. For the heap-exhaustion end state, see Tomcat java.lang.OutOfMemoryError: Java heap space. For the GC-overhead-limit variant, see Tomcat OutOfMemoryError: GC overhead limit exceeded, which fires when the JVM spends over 98% of time in GC while recovering less than 2% of heap.
Humongous allocation
G1 treats any object larger than half a region as humongous, which forces special allocation and can trigger repeated mixed collections or Full GCs. Look for humongous allocation events in the GC log and check whether your workload allocates large byte arrays, big response buffers, or large serialized blobs. Fixes are application-level: stream large responses, avoid buffering entire payloads in memory, or size the G1 region larger so that humongous allocations become normal ones.
Container CPU throttling
If GC real time exceeds user+sys time, the cgroup CPU limit is descheduling GC threads mid-collection. The JVM’s -XX:MaxGCPauseMillis=200 target is unachievable when GC threads cannot get CPU. Options:
- Raise or remove the container CPU limit so GC threads are not throttled.
- Raise
-XX:ParallelGCThreadsor-XX:ConcGCThreadsonly if CPU is actually available; otherwise this makes throttling worse. - Verify the JVM detected the container CPU limit correctly. On single-core containers, the JVM may default to SerialGC instead of G1GC, producing much longer stop-the-world pauses. Explicitly set
-XX:+UseG1GCif needed.
Heap too small for the workload
If Full GCs start soon after warmup and the post-GC baseline sits near max, -Xmx is simply too small for the live-data set. Increase -Xmx, but only after confirming there is no leak. Increasing heap on a leak only delays the cliff. In containers, confirm that -Xmx plus native memory (Metaspace, thread stacks, JNI, direct buffers) fits inside the cgroup memory limit, or the OS OOM killer will terminate the JVM before it ever throws OutOfMemoryError.
Prevention
- Enable GC logging from day one. The log is the only source of per-event detail. JMX cumulative counters cannot reconstruct it retroactively.
- Track the post-GC old-gen baseline, not instantaneous heap. Instantaneous heap follows a sawtooth and is supposed to fill before GC runs. Alerting on raw heap over 80% produces constant false positives. The valleys are what reveal a leak.
- Track GC overhead ratio as a first-class metric. Under 5% healthy, over 10% concerning, over 20% critical. Treat any Full GC on G1 as an event worth investigating, even if the ratio is still low.
- Set
-XX:MaxMetaspaceSize. Without it, Metaspace grows silently until the OS kills the process with no JVM-level error. This is separate from heap GC but produces symptoms that look similar from the outside. - Bound sessions, caches, and outbound timeouts. Most heap pressure in Tomcat traces back to unbounded sessions, caches without eviction, or backend calls with infinite default timeouts that hold request-scoped state alive.
- Size the heap to live data with headroom, not to the container limit. Post-GC heap should sit under 75% of
-Xmxat peak load so G1 has room to operate. - Monitor container CPU throttling alongside GC. If throttling rises during GC events, the pause time target is unreachable regardless of JVM tuning.
How Netdata helps
- Per-second GC collection count and time from the JMX
GarbageCollectorbeans, so the overhead ratio is computed from high-resolution data rather than coarse polling. Sustained Full GC increments are visible within seconds. - Heap pool breakdown including
G1 Old Gen, so the post-GC baseline trajectory is a first-class chart rather than a manualjstatsession. - Correlation of GC events with request latency, throughput, and error rate on the same timeline, the fastest way to confirm that latency spikes are GC-induced and not backend-induced.
- JVM CPU versus system CPU, so you can tell whether CPU saturation is GC-dominated (a memory problem) or application-dominated.
- Anomaly detection on GC frequency, which catches the slow drift toward the death spiral before the overhead ratio crosses 5%.
- Container CPU throttling counters from cgroups, so stretched GC pauses under CPU limits are diagnosable without SSH access to the node.
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 maxThreads and minSpareThreads: sizing the executor correctly
- Tomcat monitoring checklist: the signals every production instance needs
- Tomcat monitoring maturity model: from survival to expert
- Tomcat OutOfMemoryError: GC overhead limit exceeded: GC running but freeing nothing
- Tomcat java.lang.OutOfMemoryError: Java heap space: the heap is genuinely full
- Tomcat thread pool exhaustion: currentThreadsBusy at maxThreads and requests hanging
- Tomcat threads busy but CPU idle: telling a blocked backend from a GC spiral






