When you deploy a new WAR, Tomcat gives you two paths. Let autoDeploy watch webapps/ and swap the application in place, or stop the JVM and start a clean one. Both deliver the new code. They differ in what they leave behind, measured in Metaspace.
The root cause is the WebappClassLoader. Each Context gets its own. On a hot redeploy the old one is supposed to be garbage collected. Often it is not. A lingering ThreadLocal, a DriverManager registration, a timer thread, or a static field can pin the old classloader and every class it loaded. On a clean restart the JVM dies, and every classloader dies with it. There is nothing to pin.
For the failure pattern in depth, see the companion guide on the WebappClassLoader that never dies.
What it is and why it matters
Tomcat isolates applications by giving each Context its own classloader. When the application loads a class, the WebappClassLoader reads it from the WAR’s WEB-INF/classes and WEB-INF/lib. All of that class metadata, method bytecode, constant pools, and field layout lives in Metaspace on JDK 8 and later (PermGen on JDK 7 and earlier). The classloader object itself sits on the heap, but the classes it defined live in Metaspace, and they are reachable only through the classloader that defined them.
The garbage collection rule is strict and asymmetric. A WebappClassLoader can be collected only when nothing references it and nothing references any class it loaded. A single live reference to a single application class pins the classloader, and through it, every class it loaded. This is why a classloader leak is expensive: not one class leaks, but the whole application’s class set on every redeploy.
The operational concern is that Metaspace is not heap. Most teams watch heap and set -Xmx. Far fewer set -XX:MaxMetaspaceSize. With no cap, Metaspace grows until the operating system kills the process. The JVM raises no OutOfMemoryError, writes no clean shutdown, and leaves only a kernel OOM log line. Heap looks fine, but RSS climbs and the JVM dies.
That is the asymmetry. Heap leaks surface slowly as a rising post-GC baseline. Classloader leaks surface as a staircase in Metaspace that only a clean restart can flatten.
How it works
Two deployment paths. Both end with the new application running. Only one inherits the old application’s ghosts.
flowchart TD
subgraph Hot[Hot redeploy - autoDeploy true]
HA[Drop WAR into webapps] --> HB[Tomcat stops old Context]
HB --> HC[Old WebappClassLoader queued for GC]
HC --> HD{Reference still held?}
HD -- yes --> HE[Classloader retained]
HE --> HF[Metaspace grows one step]
HD -- no --> HG[Classloader collected]
end
subgraph Clean[Clean JVM restart]
CA[Stop the JVM] --> CB[Process exits]
CB --> CC[All classloaders die]
CC --> CD[Start fresh JVM]
CD --> CE[Metaspace at baseline]
endThe hot redeploy path
autoDeploy defaults to true on the Host element in current Tomcat releases. On a periodic check of appBase (the webapps/ directory), Tomcat notices a new or updated WAR. It stops the old Context, creates a new WebappClassLoader, loads the new classes, and starts the new Context. The old classloader is queued for collection. If nothing pins it, it is collected and Metaspace returns to roughly its previous level. If anything pins it, the old classes stay, and the next redeploy adds another copy on top.
Tomcat has accumulated defenses against common pins, and they do real work. The JreMemoryLeakPreventionListener forces certain JRE threads to start at container startup so they inherit the system classloader rather than the first webapp classloader. On stop, Tomcat deregisters JDBC drivers the webapp registered, flushes the java.beans.Introspector cache, and renews the thread pool so ThreadLocal values held by pooled threads do not pin the old classloader. It also logs warnings when it detects threads the application started but did not stop.
These defenses reduce the leak rate. They do not eliminate it. The remaining pins live in application or library code: a ThreadLocal whose value indirectly references a webapp class, a logging appender that captured the classloader, a Timer or ScheduledExecutorService the app never cancelled, a JMX MBean registered but not unregistered, or a static field in a shared library that references an application class. Tomcat’s stop-time detection does not catch indirect references. The Manager app’s “Find Leaks” analysis does, but that button invokes System.gc(), which is disruptive on a loaded production JVM.
The result is a staircase. Each hot redeploy adds a step in Metaspace. The degradation curve is “staircase to cliff.” If MaxMetaspaceSize is set, you eventually hit it and get OutOfMemoryError: Metaspace. If it is not set, you eventually hit the container or host memory limit and the kernel kills the process.
The clean restart path
A clean restart is the trivial case. The JVM shuts down. The process exits. Every classloader, pinned or not, is destroyed because the address space is gone. When the JVM starts again, it builds a fresh classloader hierarchy from zero. Metaspace starts at baseline. There is nothing to pin because there is nothing left.
This is why a full restart is the reliable fix and why containerized environments that replace the whole JVM on deploy rarely see classloader leaks. The leak is a property of the hot redeploy path. A clean restart has no old classloader to pin.
There is a third knob worth naming so you do not confuse it with autoDeploy. The Context attribute reloadable defaults to false. When true, Tomcat watches WEB-INF/classes and WEB-INF/lib for changes and reloads the Context automatically. The Tomcat documentation explicitly discourages it for deployed production applications. autoDeploy on the Host and reloadable on the Context are different mechanisms, but both take the hot redeploy path and both can leak.
Where it shows up in production
The failure pattern has a signature shape, and it almost always involves an environment mismatch.
- The classic trap. A team hot-deploys all day in CI and staging but restarts cleanly in production. The leak never surfaces in those environments because they are short-lived or get restarted nightly. It appears the first time someone hot-deploys in production “just this once,” and Metaspace starts climbing in steps that never come back down.
- Spring Boot WAR on standalone Tomcat. A Spring Boot application packaged as a WAR and deployed to a standalone Tomcat has a well-documented Metaspace cost per redeploy. Each redeploy builds a new
WebappClassLoaderand reinitializes the Spring context, and the previous one is frequently retained. - Long-lived shared dev or staging boxes. A Tomcat that absorbs dozens of redeployed builds a day is the fastest way to hit the staircase. These boxes look fine until they do not.
- The first hot redeploy after a long clean-run stretch. Production Tomcats that have been restarted on every deploy for years can absorb one accidental hot deploy without incident, which lulls teams into thinking hot deploy is safe. It is not the first one that kills you, it is the cumulative step count.
The common thread: hot redeploy only looks safe when you rarely do it. The cost is paid per redeploy, and it accumulates.
When to use each
The decision is simpler than teams make it.
Prefer a clean JVM restart in production. Treat each production deploy as an immutable replacement of the JVM, not an in-place swap. In containerized environments this is the natural model. Outside containers, a deploy script that stops Tomcat, replaces the WAR, and starts Tomcat avoids the leak entirely. You lose a few seconds of cold start and JIT warmup, which is almost always cheaper than a Metaspace incident.
If you must hot-deploy, budget Metaspace per redeploy. Measure how much Metaspace climbs on a single undeploy and redeploy cycle. Divide remaining headroom by that number to estimate how many hot redeploys you have before trouble. Then schedule a clean restart before you get there, or set -XX:MaxMetaspaceSize so the JVM fails loudly with OutOfMemoryError: Metaspace instead of dying silently to the kernel OOM killer.
Set -XX:MaxMetaspaceSize regardless of path. Without it, Metaspace is unbounded and the only termination is an OS kill with no JVM-level error. A cap converts a silent catastrophe into a loud, diagnosable one.
Keep reloadable=false in production. It defaults to false and should stay that way. Use autoDeploy only where you have explicitly budgeted for the leak, and prefer it never in production.
Use the Manager “Find Leaks” function carefully. It is a diagnostic, not a routine. It invokes System.gc() and reports webapps whose classloader failed to collect. Run it on a staging box that mirrors production, not under live production load.
Signals to watch in production
| Signal | Why it matters | Warning sign |
|---|---|---|
| Metaspace usage | Class metadata lives here. Hot redeploy leaks show up as steps that do not drop. | Step increase after each redeploy that never returns to baseline |
LoadedClassCount | Corroborates Metaspace growth with class loading activity. | Monotonic growth after an undeploy and redeploy cycle |
catalina.out leak warnings | Tomcat logs detected pins at stop time. | “appears to have started a thread … but has failed to stop it” |
| Process RSS | Includes non-heap memory the heap graphs hide. | RSS climbing while heap baseline is flat |
| Deploy events vs Metaspace | Confirms causation, not just correlation. | Each deploy event lines up with a Metaspace step |
The diagnostic rule is concrete: after a full undeploy and redeploy cycle, Metaspace should return to within roughly 10 percent of its pre-undeploy value. Any persistent growth indicates a leak. If you see growth, the fix is not to tune around it but to switch that environment to clean restarts.
How Netdata helps
- Per-second Metaspace tracking from the JVM memory pools exposes the staircase pattern that daily or hourly polling smooths over. A single hot redeploy that adds a permanent step is visible immediately, not after the process dies.
- Correlating deploy events with Metaspace steps turns “memory keeps growing” into “every deploy adds 80 MB that never comes back,” which is the difference between a leak you act on and one you live with until it pages you.
LoadedClassCountalongside Metaspace confirms whether the growth is class metadata (classloader leak) rather than something else pushing native memory.- Process RSS next to heap utilization catches the case where heap looks healthy but the process is walking toward the OOM killer through non-heap growth.
- Heap and GC context keeps the diagnosis honest. If you also see rising post-GC heap baseline, you may have a heap leak in parallel, which needs a different fix than a classloader leak.
Related guides
- Tomcat classloader leak on redeploy: why the old WebappClassLoader never dies
- Tomcat frequent Full GC: pause time, G1, and the 5% overhead rule
- Tomcat GC death spiral: full GCs dominating and throughput collapsing
- Tomcat file descriptor usage: OpenFileDescriptorCount vs the ulimit
- Tomcat 5xx error rate: separating server failures from crawler 404s






