skip to content
everything.deepDives.classloaderLeaks

class loader leaks

how redeploying an app leaks an entire copy of it.

chapter
chapter 17.14
reading time
9 min read

a class can only be unloaded together with its class loader, and a loader can only be collected when nothing reaches it: none of its classes, and none of the objects of any of its classes. so one stray reference from something long-lived (a static map in the server, a ThreadLocal on a pool thread, a thread the app never stopped, a JDBC driver in DriverManager) keeps the entire old copy of the app alive.

redeploy enough times and the copies pile up until OutOfMemoryError: Metaspace. measured here: 301 classes and about 0.75 MB of metaspace per redeploy, dead at deploy 42 with a 32 MB cap.

the picture

you know how app servers (and some frameworks' dev modes) can reload your app without restarting the JVM? they do it with class loaders: each deploy gets a fresh loader that loads your classes, and undeploying means dropping that loader so the GC can throw the old version away. class loaders has how loaders work; this chapter is about the dropping part, which goes wrong a lot.

think of a hotel. a guest (your app) checks out, and housekeeping is supposed to clear the whole room. but the guest left a coat in the lobby cloakroom, and the room key is in its pocket. as long as the cloakroom (owned by the hotel, not the guest) holds that coat, the room counts as occupied. nobody can clean it, and the next guest gets a new room. check in and out enough times and the hotel is full of empty, occupied rooms.

why one reference holds everything

the references point in a direction that makes this inevitable: every object knows its class, every class knows its loader, and every loader keeps a list of every class it defined. so from any single object of your app, the GC can reach the loader, and from the loader, everything else.

  • a static field: a GC root for as long as the server runs
  • └──
  • └──
  • └──
  • └──
  • ├── Page0 ... Page299300 more classes, in metaspace
  • └── App.CACHEits static fields: 1.5 MB of heap
what one leaked App object keeps alive. the root belongs to the server, so it never dies. everything below it belongs to the old deploy: class metadata in metaspace, static fields on the heap.

the important direction: a reference from the app into the server is fine (the app dies, the reference dies with it). a reference from the server into the app is the leak, because the server outlives every deploy.

quick check
which of these does NOT keep an undeployed app's class loader alive?

redeploy, ten times

a tiny "app server" that deploys a 301-class webapp in a fresh URLClassLoader, undeploys it (closes the loader, drops every reference it knows about), runs a GC, and repeats. the webapp is innocent except for one line, which depends on the mode:

deploys so far: 3
GC rootServer.REGISTRY (a static field)
webapp-v1leaked
301 classes
CACHE 1.5 MB
held via Server.REGISTRY
webapp-v2leaked
301 classes
CACHE 1.5 MB
held via Server.REGISTRY
webapp-v3running
301 classes
CACHE 1.5 MB
classes loaded by webapps: 903metaspace: 3.2 MBold loaders alive: 2
REGISTRY → HashMap → App → App.class → loader

the app put itself into a map owned by the server (or a shared library) and nothing took it out. remove registrations on undeploy, or don't let parent code hold child objects at all.

pick a leak, then press redeploy a few times. undeployed copies either get leaked or collected; the counters are measured. all four leaks behave the same, because they all end at the same loader.

the heart of the server (trimmed to the registry case):

Server.java
static WeakReference<ClassLoader> deployAndUndeploy(int n, String mode, URL webapp) throws Exception {
    URLClassLoader loader = new URLClassLoader("webapp-v" + n, new URL[] { webapp }, Server.class.getClassLoader());
    Runnable app = (Runnable) loader.loadClass("App").getConstructor().newInstance();
    app.run();                                                    // deploy: loads 300 Page classes
    if (mode.equals("registry")) REGISTRY.put("listener-" + n, app);   // the one bad line
    loader.close();                                               // undeploy
    return new WeakReference<>(loader);                           // so we can ask: did it die?
}

and what it printed, with and without the bad line (jdk 25, G1):

text
no leak:   deploy  10: old loaders alive   0, classes loaded  1035, metaspace   1.0 MB
registry:  deploy  10: old loaders alive  10, classes loaded  4045, metaspace   8.3 MB
no leak1 MB
static registry8.3 MB
ThreadLocal on a pool thread8.4 MB
a thread the app started8.4 MB
JDBC driver in DriverManager8.4 MB
metaspace after 10 deploys and undeploys, one bar per leak root, measured with the Metaspace memory pool MXBean. every leak costs the same, about 0.75 MB of class metadata and 1.5 MB of heap per redeploy.

a real app is thousands of classes, not 301, so each leaked redeploy is tens of megabytes. and metaspace has no limit by default, so instead of an error you get a process that grows by that much every deploy. cap it, and you get the error instead:

terminal
$ java -XX:MaxMetaspaceSize=32m Server registry webapp/ 500
...
deploy  41: old loaders alive  41, classes loaded 13377, metaspace  31.0 MB
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
	...
	at webapp-v42//App.run(App.java:12)

note the webapp-v42// in the stack trace: since java 9, a frame names the loader its class came from, if the loader has a name. naming your loaders is free and makes this kind of bug much easier to read.

finding it

the JVM will tell you how many loaders it has, by name, while it runs:

jcmd <pid> VM.classloaders
+-- <bootstrap>
      +-- "platform", jdk.internal.loader.ClassLoaders$PlatformClassLoader
            +-- "app", jdk.internal.loader.ClassLoaders$AppClassLoader
                  +-- "webapp-v1", java.net.URLClassLoader
                  +-- "webapp-v2", java.net.URLClassLoader
                  +-- "webapp-v3", java.net.URLClassLoader

three copies of the webapp, after three deploys and three undeploys. that's the smoking gun. jcmd <pid> VM.classloader_stats adds the class count and metaspace per loader (301 classes and about 0.75 MB each, here). to find who holds an old one, take a heap dump, find the old loader object (Eclipse MAT has a "duplicate classes" report that lists classes loaded by more than one loader), and ask for its path to the GC roots. the first object on that path that doesn't belong to the app is the thing to fix.

the usual fixes, matching the usual roots:

  • registrations: anything the app registers with something outside itself (listeners, MBeans, shutdown hooks, DriverManager, a logging framework's appenders) gets unregistered on undeploy.
  • threads and executors: every thread the app starts, it stops. every executor it creates, it shuts down.
  • ThreadLocals: remove() in a finally, every time, especially on threads the app doesn't own (see ThreadLocal internals).
  • libraries in the right place: drivers and libraries that keep global state belong in the server's classpath, not inside each app.
one level deeper:why permgen made this famous+

before java 8, class metadata lived in the permanent generation, a fixed slice of the heap sized by -XX:MaxPermSize, with a small default. so a class loader leak in an app server took only a handful of redeploys to end in OutOfMemoryError: PermGen space, which is why every java developer of a certain age knows the error by heart. java 8 replaced permgen with metaspace in native memory (JEP 122), with no default limit. the leak didn't go away, it just stopped crashing quickly. now it grows the process instead, which can be worse: you find out from the container's memory limit (see native memory), not from a clear error.

keylines
  • a class is unloaded only with its loader, and a loader only dies when none of its objects or classes are reachable.
  • every object knows its class, every class its loader: one reference pins the whole old app.
  • references from the server into the app leak. references from the app into the server don't.
  • usual roots: server-side statics, ThreadLocals on pool threads, threads the app started, DriverManager.
  • measured: 301 classes and ~0.75 MB of metaspace per leaked redeploy. dead at deploy 42 with a 32 MB cap.
  • jcmd VM.classloaders shows the copies; a heap dump's path to GC roots shows who holds them.