Java class-loader retention: why an undeployed app can remain in memory
Trace plugin and application class loaders from long-lived roots before blaming metaspace.
On this page
Key takeaways
- A class loader is collectible only when its classes and every path back to it are unreachable.
- Thread context loaders, callbacks, and parent-owned registries often outlive a deployment.
First principles
An application server can stop routing traffic to a deployment and still retain that deployment's classes. A class is associated with the ClassLoader that defined it. As long as the loader, one of its classes, or an object whose class belongs to that loader is reachable from a live root, the runtime cannot unload that class. “Undeploy completed” is an application lifecycle event; it is not proof that the garbage collector can reclaim the old generation of classes.
The most important distinction is between a cycle and a root. A class loader referring to one of its own classes is normally just a cycle; tracing garbage collection can reclaim an unreachable cycle. A parent-loaded registry that stores a plugin object is different. The registry is reachable from a long-lived server, and its entry points down into the plugin loader. The same shape occurs when a parent-owned thread's context class loader is set to a child loader, or a shared executor retains a task instance loaded by a web application.
Several symptoms can look like a class-loader leak. Heap use may rise because live objects are retained. Metaspace may rise as classes from each deployment remain loaded. Native memory can also rise through JNI or direct buffers. These are related but distinct measurements. A rising metaspace graph is evidence to investigate, not proof that class unloading is the only cause. Compare loader counts, loaded class counts, heap dominators, and deployment generations over repeated deploy/undeploy cycles.
Runnable example
The host registry below models a parent-loaded object that owns a plugin instance. The registry must remove its reference when the plugin is stopped. If the plugin also registers callbacks with a host event bus, those registrations need matching removal. This small example demonstrates the ownership edge; it does not create a second class loader by itself.
import java.util.HashMap;
import java.util.Map;
public class LoaderRegistryDemo {
interface Plugin {
void stop();
}
static final class HostRegistry {
private final Map<String, Plugin> active = new HashMap<>();
void start(String name, Plugin plugin) {
if (active.putIfAbsent(name, plugin) != null) {
throw new IllegalStateException("plugin already registered");
}
}
void stop(String name) {
Plugin plugin = active.remove(name);
if (plugin != null) plugin.stop();
}
}
static final class DemoPlugin implements Plugin {
public void stop() {
System.out.println("plugin stopped");
}
}
public static void main(String[] args) {
HostRegistry registry = new HostRegistry();
registry.start("demo", new DemoPlugin());
registry.stop("demo");
}
}
In a container, HostPluginRegistry would be loaded by a loader that survives redeployment, while the concrete Plugin class could be loaded by the application loader. The map value then keeps the concrete class and its defining loader reachable. The removal operation is necessary but not sufficient: stop() must also close owned resources, cancel scheduled work, remove listeners, and release other host registrations. Lifecycle cleanup should be idempotent so partial startup can use the same path safely.
Contextual failure case
A team redeploys a service every few minutes while debugging. Requests begin using the new version, but resident memory grows after each deployment. A thread dump shows no obvious runaway request. The actual retention path is a parent-owned scheduler: each deployment registers a task whose lambda captures a service object. Cancelling future executions does not necessarily remove the task from the scheduler's delayed queue immediately, and a completed task may still remain until its queue policy removes it. The scheduler's worker thread is long-lived, so the captured object keeps the old loader reachable.
Another common path is ThreadLocal. A library stores an application object on a pooled server thread and fails to call remove() in a finally block. Reusing the thread for another application does not erase the old value. A thread context class loader can also remain set to the application loader after request processing. These patterns are particularly subtle because the owner is a container thread rather than an object visibly stored in application code.
Avoid treating System.gc() as a repair. It may request collection, but it cannot reclaim reachable objects, and class unloading policy is runtime-dependent. A forced collection that temporarily changes the graph can be useful in a controlled diagnostic; it does not prove the application has a correct shutdown protocol. The fix is to identify and remove the retaining edge, then verify that later collections permit old loaders to disappear.
Verification
Capture the same measurements before deployment, after deployment, and after undeployment. jcmd <pid> VM.classloader_stats can show loader counts and class counts on supported HotSpot builds. A heap dump lets you inspect paths from GC roots to an old application loader; look for thread locals, context class loaders, executor queues, static registries, listener lists, and thread stacks. jcmd <pid> GC.class_histogram is a useful comparison but is not a dominator tree and may have runtime cost. Do not infer a leak from one snapshot: loader generations should be compared across repeated cycles.
After finding a root, write a test or lifecycle probe that registers a plugin, stops it, and verifies the host no longer returns it from registries or listener collections. For an integration environment, repeat deploy/undeploy without restarting the container and track old loader identities. Use Java runtime troubleshooting to organize the evidence, but distinguish heap reachability from native-memory accounting.
Exercise
Extend the registry so each plugin can register a callback. Design a Registration handle with an idempotent close() operation, and make plugin shutdown close every handle even if one callback throws. Then draw the reference graph before and after shutdown. Add a second path through a scheduled task and specify who cancels it, who removes it from the queue, and who restores the thread context class loader. The exercise is complete only when every long-lived host-owned root has an explicit release path.
Safety and caveats
Class unloading details differ across JVM implementations, collector settings, and container configurations. Do not run heap dumps or class histograms casually on a latency-sensitive production process; they can consume I/O, memory, and CPU. Dump files often contain credentials, personal data, request payloads, and proprietary state, so protect and delete them under the same controls as production databases. Loader statistics explain counts, while heap analysis explains reachability; neither alone proves that a particular cleanup change is safe.