java.lang.OutOfMemoryError: GC overhead limit exceeded means the heap is functionally full. GC has been running almost continuously and recovering almost nothing. This is the terminal stage of a GC death spiral, thrown just before a hard Java heap space OOM.
The exact rule the JVM applies: more than 98% of CPU time spent in GC and less than 2% of heap recovered, sustained across five consecutive collections. When both conditions hold, the JVM aborts rather than burn CPU indefinitely. Instantaneous heap usage can still read under -Xmx when this fires. By that point, the live set is effectively pinned to the ceiling.
On Tomcat, the root cause is almost always one of two things: an application or session leak that grows the live set, or a heap sized too small for the deployed apps’ working set. The fix is different for each. Suppressing the check with -XX:-UseGCOverheadLimit is not a fix. It removes the early warning, makes the JVM unresponsive on CPU for longer, and still ends in a hard OOM.
What this means
The GC overhead limit is a self-protection mechanism. When the JVM detects that GC is consuming the process and not reclaiming memory, it aborts. The error is thrown by the JVM, not by Tomcat. You will usually see it in catalina.out or in your application’s log framework, often as an unhandled exception bubbling up through a request thread.
The behaviour depends on the collector in use:
- Parallel GC (default through JDK 8): throws
GC overhead limit exceededwhen the 98%/2%/five-collection conditions are met. - G1 GC (default since JDK 9): historically did not honour
-XX:+UseGCOverheadLimit. OpenJDK bug JDK-8212084 implemented the check for G1 and was backported to JDK 17.0.19, JDK 21.0.11, JDK 11.0.33, and JDK 25.0.3. On older G1 builds, the same heap state produces a hardJava heap spaceOOM instead. - ZGC: does not implement the overhead limit at all. The same underlying leak will eventually produce
Java heap space. - CMS: removed in JDK 14, but threw the overhead limit while it was still shipped.
Key diagnostic point: if you are running an older G1 build and you only ever see Java heap space, the same root cause applies. The error string is just different.
flowchart TD
A[Heap live set grows] --> B[GC runs more often]
B --> C[Post-GC baseline climbs]
C --> D[GC dominates wall clock]
D --> E{Recovers 2% heap?}
E -- No, 5 cycles --> F[GC overhead limit exceeded]
E -- Yes --> B
F --> G[No heap dump configured means no evidence]Common causes
| Cause | What it looks like | First thing to check |
|---|---|---|
| Application memory leak | Post-GC old gen climbs monotonically over hours/days; heap dump shows one dominator (cache, collection, queue) | Dominator tree in MAT |
| HTTP session accumulation | activeSessions grows without plateau; sessions promoted to old gen; bot or crawler traffic | JMX Catalina:type=Manager activeSessions |
Undersized -Xmx | Death spiral triggers under peak load but heap returns to baseline after restart; no obvious dominator | -Xmx vs peak live set after GC |
| Hot-redeploy classloader leak | OutOfMemoryError: Metaspace typically, but heap can fill with classloader-retained objects; occurs after redeploy cycles | Metaspace pool; WebappClassLoader count in heap dump |
| HTTP/2 priority header leak (CVE-2025-31650) | Heap grows under HTTP/2 traffic; affects Tomcat 9.0.76 to 9.0.102, 10.1.10 to 10.1.39, 11.0.0-M2 to 11.0.5 | Tomcat version; upgrade to 9.0.104+, 10.1.40+, 11.0.6+ |
Quick checks
These are read-only. None will worsen the situation.
# Confirm the error is in the logs and capture the timestamp window
grep -n "GC overhead limit exceeded" $CATALINA_BASE/logs/catalina.out
# Check OS OOM killer involvement (orthogonal but worth ruling out)
dmesg -T | grep -iE "oom|killed process"
# Look at the JVM flags actually in effect, including GC algorithm
jcmd $(pgrep -f 'catalina.startup.Bootstrap') VM.flags
# Heap occupancy snapshot via jstat (1000ms refresh)
jstat -gcutil $(pgrep -f 'catalina.startup.Bootstrap') 1000
# Per-pool breakdown; old gen column is the one that matters
jstat -gc $(pgrep -f 'catalina.startup.Bootstrap')
# Confirm HeapDumpOnOutOfMemoryError is set (so future OOMs leave evidence)
jcmd $(pgrep -f 'catalina.startup.Bootstrap') VM.flags | grep -i heapdump
# Active sessions per context (frequent culprit)
java -jar jmxterm.jar -l localhost:9090 -n -v silent -e \
"get -b Catalina:type=Manager,host=localhost,context=/ activeSessions"
# Tomcat version (relevant to CVE-2025-31650 check)
$CATALINA_HOME/bin/version.sh
If multiple Tomcat instances run on the host, replace the $(pgrep ...) subshell with the specific PID.
If the JVM is still alive but in the spiral, capture a heap dump now, before you restart. The dump is the only durable evidence of what was on the heap.
How to diagnose it
Capture a heap dump from the live process. Use
jcmd <pid> GC.heap_dump /tmp/tomcat-$(date +%s).hproforjmap -dump:format=b,file=/tmp/tomcat.hprof <pid>. Both pause the JVM while the dump is written.jcmd GC.heap_dumpwithout-allandjmap -dump:live,...additionally trigger a Full GC. If the process is already thrashing, the dump will take longer. If the process has already died, you can only rely on whatever-XX:+HeapDumpOnOutOfMemoryErrorproduced.Restart the JVM. Once the dump is captured, restart to restore service. Do not restart before capturing the dump unless the process is dead.
Confirm
HeapDumpOnOutOfMemoryErroris configured going forward. If it was not set on the dying instance, set it now. Add toCATALINA_OPTSinsetenv.sh:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/tomcat/dumps/The dump path must be on a volume with enough free space. A full heap dump for a 4GB heap is roughly 4GB on disk.
Load the dump in Eclipse MAT. Run the Leak Suspects report. The dominator tree and “incoming references” views identify the single object graph retaining most of the heap.
Map the dominator to a cause. Common patterns:
- A
ConcurrentHashMaporHashMapfield growing without bound: application cache without eviction. - A large
Object[]under a session manager or context attribute: session accumulation. - Multiple
WebappClassLoaderinstances withstarted=false: hot-redeploy leak. Look for extra classloaders wherestartedis false, then trace their GC roots.
- A
If no clear dominator, suspect undersized heap. Compare peak post-GC old gen against
-Xmx. If the working set genuinely needs more headroom than the configured heap provides, sizing is the fix.Cross-check session count. Use the JMX
Catalina:type=ManagerMBean. IfactiveSessionsis climbing without plateau, sessions are the leak.Cross-check Metaspace. If the error string was
Java heap spacebut the live set is largely classloader metadata, the real failure may be classloader-driven. Look at theMetaspacememory pool. Set-XX:MaxMetaspaceSizeif it is unset.
Metrics and signals to monitor
| Signal | Why it matters | Warning sign |
|---|---|---|
| Post-GC old gen utilization | Tracks the live set after collection. The only heap metric that predicts OOM. | Valleys rising over hours or days |
| GC overhead ratio (GC time / wall clock) | Approaches the 98% threshold before the error fires | Sustained >20% |
| Full GC frequency | On G1, any Full GC is abnormal; concurrent collection should keep up | Multiple per minute |
| GC pause duration | Directly injects latency into requests in flight | Pauses >1s |
| Active session count | Sessions are the largest heap consumer in most web apps | Monotonic growth without plateau |
| Process RSS | Captures native memory (thread stacks, metaspace, direct buffers) the heap metric misses | RSS growing while heap is stable |
| Metaspace pool | Detects classloader leaks on hot-redeploy | Step up after each redeploy that does not fall |
catalina.out for OutOfMemoryError | Binary canary that the spiral has already fired | Any occurrence |
Fixes
Application memory leak
The durable fix is in code: bound the cache, evict on size or TTL, close resources in finally or try-with-resources. There is no JVM flag that substitutes. After the code fix, watch the post-GC old gen baseline for several days to confirm it has stopped climbing.
If you cannot ship a code fix immediately, scheduling periodic rolling restarts is a stopgap. It does not address the leak. It bounds the blast radius.
HTTP session accumulation
If sessions are the leak, the application is creating sessions faster than they expire. Common patterns:
- Bots and crawlers that ignore cookies force a new session per request when the app calls
getSession(true). - JSP pages create a session by default. Add
<%@ page session="false" %>where sessions are not needed. - Default session timeout is 30 minutes. Long timeouts with sustained traffic fill old gen.
maxActiveSessions=-1by default, so there is no backstop.
Set maxActiveSessions on the Manager element to enforce a ceiling, and reduce session timeout where the application allows it. If the leak is bot-driven, avoid calling getSession() for bot user-agents, or front the app with a bot filter that prevents session creation.
Undersized -Xmx
If the heap dump shows no abnormal dominator and the post-GC old gen baseline genuinely exceeds about 75% of -Xmx at peak, increase -Xmx. Before doing so, confirm the host or container has the headroom. If actual heap usage plus native memory (thread stacks, metaspace, direct buffers) exceeds the cgroup memory limit, the kernel OOM-kills the process before the JVM reports heap pressure.
When increasing heap, also revisit GC tuning. Larger heaps with Parallel GC mean longer pauses. G1 handles multi-GB heaps better. ZGC avoids long pauses but does not throw GC overhead limit exceeded at all, so the same leak eventually surfaces as Java heap space.
Hot-redeploy classloader leak
The reliable fix is to stop hot redeploying. Use full JVM restarts for deployment. Containerised deployments that replace pods rather than redeploy into a running JVM avoid this entirely.
If hot redeploy is required, the JreMemoryLeakPreventionListener (enabled by default in server.xml) mitigates some JRE singleton leaks but cannot fix application code that retains references through ThreadLocals, JDBC driver registration, or unclosed log appenders. Tomcat’s “Find Leaks” feature in the Manager app identifies leaked contexts, but it may trigger stop-the-world pauses, so avoid running it during production traffic.
CVE-2025-31650 (HTTP/2 priority header leak)
If your Tomcat version falls in the affected ranges (9.0.76 to 9.0.102, 10.1.10 to 10.1.39, 11.0.0-M2 to 11.0.5) and heap pressure correlates with HTTP/2 traffic, upgrade. The fix shipped in 9.0.104, 10.1.40, and 11.0.6. The vulnerability is a memory leak from improper cleanup of failed requests with invalid HTTP priority headers.
Prevention
- Set
-XX:+HeapDumpOnOutOfMemoryErrorandHeapDumpPathon every Tomcat JVM. Without it, an OOM leaves zero evidence. The dump path must be on a volume with enough free space. - Alert on post-GC old gen, not instantaneous heap. Instantaneous heap follows a sawtooth and is supposed to fill before GC. The valley is the signal. Track the post-GC baseline as a trend. Alert when it crosses about 75% of
-Xmx. - Alert on the GC overhead ratio. Trend GC time as a fraction of wall clock. Sustained >10% is concerning. Sustained >20% means the death spiral is starting.
- Set
-XX:MaxMetaspaceSize. Without it, metaspace grows until the OS kills the process with no JVM-level error. Pick a value that leaves headroom for normal classloading. - Monitor
activeSessionsper context. Growing sessions without a plateau is the leading indicator of session-driven OOM. - Disable hot redeploy in production. Use full restarts or pod replacement.
- Patch to a fixed Tomcat version if you are in the CVE-2025-31650 range.
- Do not suppress the check.
-XX:-UseGCOverheadLimitis occasionally suggested on forums. It removes the early warning and still ends in a hard OOM. Do not use it as a fix.
How Netdata helps
Netdata surfaces the signals that precede GC overhead limit exceeded at per-second resolution, so you can catch the spiral before the error fires.
- JVM memory pools per second (
java.lang:type=MemoryPool): the G1 Old Gen or PS Old Gen trend is the early warning. Rising post-GC valleys over hours are the leak signature. - GC collection count and time (
java.lang:type=GarbageCollector): shows Full GC frequency and the GC time / wall clock ratio. A spike here correlates directly with request latency spikes. - ML-based anomaly detection on heap and GC signals flags subtle baseline drift that static thresholds miss. Useful for slow leaks that take days to manifest.
- Active HTTP session count (
Catalina:type=Manager): the leading indicator for session-driven OOM. - Correlation between GC pause time and request processing time (
Catalina:type=GlobalRequestProcessorprocessingTime): confirms latency spikes are GC-induced rather than backend-induced. - CPU utilization split by process: if GC threads are dominating CPU, the memory problem is masquerading as a CPU problem.
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 thread pool exhaustion: currentThreadsBusy at maxThreads and requests hanging
- Tomcat threads busy but CPU idle: telling a blocked backend from a GC spiral






