On This Page
I was sure the cap was too low. That's the first thing everyone thinks when a plugin host dies with OutOfMemoryError: Metaspace after a few hot redeploys: 32 MB, 64 MB, it all sounds stingy for an app that loads thousands of classes. So I built a lab plugin host and did the obvious thing. I doubled the cap. Then I doubled it again.
Here's what happened, every time:
cap 32m -> dies at redeploy 350
cap 64m -> dies at redeploy 725
cap 128m -> dies at redeploy 1450
cap 256m -> dies at redeploy 2950
Linear. A bigger limit doesn't fix a loader that never dies. It buys you a longer week.
The trace that points at the wrong place
This is what the lab host printed (OpenJDK 21.0.12.1, G1, -Xmx256m -XX:MaxMetaspaceSize=32m, x86_64, 2 cores, no container):
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
at java.base/java.lang.ClassLoader.defineClass1(Native Method)
at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1027)
at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:889)
at Lab$PluginLoader.deploy(Lab.java:29)
at Lab.main(Lab.java:58)
That's the whole message, and the whole trace. It names the thread that was defining a class when the limit hit. It doesn't name whoever kept the old classes alive. The frame at the top (defineClass1) is just where the JVM tried to put one more class into Metaspace, the native memory area where HotSpot keeps class metadata: method bytecode, constant pools, field layouts. Since JDK 8 it replaced PermGen and lives outside the Java heap.
There's a second wording, and it matters:
java.lang.OutOfMemoryError: Compressed class space
Same cause family, different counter. Part of Metaspace (the class-pointer part) has its own cap. I got that second message in my lab with -XX:CompressedClassSpaceSize=8m while the total was nowhere near MaxMetaspaceSize. It has its own article (outofmemoryerror-compressed-class-space); this one is about the plain Metaspace message. On JDK 7 and earlier you'd have seen PermGen space and -XX:MaxPermSize. If you still have that flag in a launch script, delete it.
It usually shows up after hours or after the Nth deploy, almost never at startup. That timing alone tells you something: this isn't a program that needs a lot of classes. It's a program that keeps adding them.
Before you touch MaxMetaspaceSize
Two minutes, in this order. First, confirm it's Metaspace and not heap or the kernel: the message says so, and the OutOfMemoryError map covers the other messages. Second, if the JVM is still alive, ask it.
jcmd <pid> VM.metaspace # used / committed / reserved, class and non-class
jcmd <pid> VM.classloader_stats # one row per class loader, with class count
jcmd <pid> GC.class_histogram # instances per class; look for your loader class
jcmd <pid> VM.classloaders # the loader hierarchy
All four need the JVM running; none needs a startup flag. After the fact, you'd want -Xlog:class+unload and the GC log, or a heap dump, so set those up before you need them (more in the heap dump section below). GC.class_stats isn't in jcmd help on 21.0.12.1, so don't build a runbook around it.
This is VM.classloader_stats on my host after 60 redeploys with nothing unloaded:
ClassLoader Parent CLD* Classes ChunkSz BlockSz Type
0x00007fc4a0000a08 0x00007fc49f068770 0x00007fc4d88a6010 100 90112 77312 Lab$PluginLoader
0x00007fc4a0000a08 0x00007fc49f068770 0x00007fc4d88607f0 100 90112 77312 Lab$PluginLoader
... (60 rows, one per redeploy)
Sixty loaders of the same type, each holding the same number of classes. In a healthy host there's one live loader per deployed plugin version, not one per deploy that ever happened. And GC.class_histogram agreed: Lab$PluginLoader, 60 instances.
One thing that surprised me: the Java heap was fine. In the dump I took, 60 loader objects and a few thousand Class objects added up to a 12.4 MB .hprof. The retained cost lives in native memory, behind those objects. Your heap graph will look innocent and Metaspace will climb.
Redeploy a plugin 350 times on your laptop
You need a JDK 21 (I used OpenJDK 21.0.12.1) and nothing else. The lab generates tiny class files by hand, so there's no jar and no dependency to pin. A "redeploy" creates a fresh class loader and defines 100 classes in it, which is what a servlet container or plugin host does when you drop in a new version.
import java.io.*;
import java.lang.management.*;
import java.util.*;
public class Lab {
// pins plugin loaders for the lifetime of the host: the leak
static final List<Object> REGISTRY = new ArrayList<>();
static byte[] classBytes(String internalName) throws IOException {
ByteArrayOutputStream bo = new ByteArrayOutputStream();
DataOutputStream o = new DataOutputStream(bo);
o.writeInt(0xCAFEBABE); o.writeShort(0); o.writeShort(65);
o.writeShort(5);
o.writeByte(1); o.writeUTF(internalName); // #1
o.writeByte(7); o.writeShort(1); // #2 Class
o.writeByte(1); o.writeUTF("java/lang/Object"); // #3
o.writeByte(7); o.writeShort(3); // #4 Class
o.writeShort(0x0021); o.writeShort(2); o.writeShort(4);
o.writeShort(0); o.writeShort(0); o.writeShort(0); o.writeShort(0);
return bo.toByteArray();
}
static class PluginLoader extends ClassLoader {
PluginLoader() { super(Lab.class.getClassLoader()); }
void deploy(int version, int classes) throws Exception {
for (int i = 0; i < classes; i++) {
String n = "plugin/v" + version + "/Handler" + i;
byte[] b = classBytes(n);
Class<?> c = defineClass(n.replace('/', '.'), b, 0, b.length);
Class.forName(c.getName(), true, this);
}
}
}
static MemoryPoolMXBean pool(String name) {
for (MemoryPoolMXBean p : ManagementFactory.getMemoryPoolMXBeans())
if (p.getName().equals(name)) return p;
return null;
}
static void status(String tag, int i) {
ClassLoadingMXBean cl = ManagementFactory.getClassLoadingMXBean();
System.out.printf("%s #%d metaspace=%dKB classSpace=%dKB loaded=%d unloaded=%d%n", tag, i,
pool("Metaspace").getUsage().getUsed() / 1024,
pool("Compressed Class Space").getUsage().getUsed() / 1024,
cl.getLoadedClassCount(), cl.getUnloadedClassCount());
}
public static void main(String[] a) throws Exception {
String mode = a[0];
int per = Integer.parseInt(a[1]);
int max = Integer.parseInt(a[2]);
for (int v = 1; v <= max; v++) {
PluginLoader l = new PluginLoader();
l.deploy(v, per);
if (!mode.equals("clean")) REGISTRY.add(l); // nothing ever removes this: the leak
if (v % 25 == 0) status(mode + " redeploy", v);
}
status("done", 0);
}
}
Save it as Lab.java, then:
javac -d out Lab.java
java -Xmx256m -XX:MaxMetaspaceSize=32m -cp out Lab leak 100 100000
It dies in under a second with the error above, after output like this:
leak redeploy #300 metaspace=23823KB classSpace=15726KB loaded=31020 unloaded=0
leak redeploy #325 metaspace=25713KB classSpace=17028KB loaded=33520 unloaded=0
leak redeploy #350 metaspace=27600KB classSpace=18329KB loaded=36020 unloaded=0
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
The tell is unloaded=0 while loaded climbs by 100 per redeploy. Now the control, same flags, with the registry line skipped:
java -Xmx256m -XX:MaxMetaspaceSize=32m -cp out Lab clean 100 3000
clean redeploy #725 metaspace=26221KB classSpace=17335KB loaded=34120 unloaded=39400
clean redeploy #1475 metaspace=23749KB classSpace=15565KB loaded=30720 unloaded=117800
clean redeploy #2975 metaspace=24467KB classSpace=16033KB loaded=31620 unloaded=266900
Three thousand redeploys, 300,000 classes defined, 266,900 unloaded by the end, and the host sits between 23 and 27 MB of its 32 MB cap forever. That sawtooth looked suspicious to me at first, because 26 MB of 32 MB is not a comfortable number. It isn't a leak. The unloaded counter is the discriminator. Look at that, not at "used".
Limits I forced: -Xmx256m (production would be several GB), MaxMetaspaceSize=32m (production: unset or a few hundred MB). Flip two other switches to see the rest of the family:
# class unloading off: the clean host now dies too
java -Xmx256m -XX:MaxMetaspaceSize=32m -XX:-ClassUnloading -cp out Lab clean 100 3000
# every class in one long-lived loader (scripting engine / codegen), no leak registry at all
java -Xmx256m -XX:MaxMetaspaceSize=32m -cp out Lab onegen 100
Both end in OutOfMemoryError: Metaspace (the second one at around 42,500 classes, with unloaded=23). onegen is a small extra case in main: one PluginLoader, and for (v = 1; v <= max; v++) l.deploy(v, per) against it, so nothing can ever be unloaded.
If you see nothing: a JDK 8-era JVM prints PermGen; and a JDK with -XX:MaxMetaspaceSize unset will run for a long time before anything fails. I let the uncapped version run for 15 seconds before killing it: about 10,100 redeploys, 1.01 million classes, 764 MB of Metaspace and still going. Don't run that on a shared box.
Teardown: nothing on disk unless you added -XX:+HeapDumpOnOutOfMemoryError. If you did, delete the .hprof (mine was 12.4 MB, yours depends on heap).
Start here if you're in a hurry
Class metadata is freed only when its class loader becomes unreachable, and then only at a GC. Something in your app still points at an old loader, so every redeploy leaves a whole generation of classes behind. A bigger cap postpones the error. That's all.
Where the message comes from, and why a GC runs first
This took me longer than the lab did. I wanted to know why the error arrives when it does and why the GC log has a strange look just before it.
I read metaspace.cpp and collectedHeap.cpp at jdk-21+35 through a fetch tool rather than a clone, so treat the following as my reading of those files, backed by the logs from my own runs. When a class loader asks for Metaspace memory and the allocator says no, Metaspace::allocate doesn't throw. It calls CollectedHeap::satisfy_failed_metadata_allocation, which retries the allocation and runs a VM operation (VM_CollectForMetadataAllocation) that does a full collection. Only if that still fails does Metaspace::report_metadata_oome run and throw. In report_metadata_oome the choice of message is a one-liner: the string is "Compressed class space" when the allocation was for the class space and committed class bytes plus the request rounded up to 4 MB exceed CompressedClassSpaceSize; otherwise it's "Metaspace". MaxMetaspaceSize isn't checked there at all; it's enforced earlier, in the code that raises the GC trigger point.
That trigger point is the part nobody explains. MetaspaceSize (21 MiB on this JDK: 22020096 bytes) is not a limit. It's the first high-water mark. When Metaspace commits past the mark, the JVM runs a GC so that dead loaders can be unloaded, then recomputes the mark from how much is still in use, using MinMetaspaceFreeRatio (40) and MaxMetaspaceFreeRatio (70). So a healthy host sees a steady drip of GCs whose cause is Metadata GC Threshold. My clean run logged 20 of them across 3,000 redeploys.
The leaking host shows the same GCs doing nothing. This is the tail of the GC log from the failing run:
GC(7) Pause Young (Concurrent Start) (Metadata GC Threshold) 8M->8M(27M) 1.528ms
GC(8) Pause Full (Metadata GC Threshold) 8M->7M(27M) 17.038ms
GC(10) Pause Full (Metadata GC Clear Soft References) 7M->7M(27M) 12.229ms
Young, then Full, then Full again with soft references cleared, and then the error. Heap before and after is 8M to 7M, which is why the JVM gave up: nothing came back, because the loaders were reachable. With -Xlog:gc+metaspace,gc+metaspace+freelist+oom you'll also get a line like Metaspace (class) allocation failed for size 65 just before the throw. Note the word "(class)": the allocation that failed was in the class space, but the exception still said Metaspace. That fits the 4 MB rounding heuristic in the source: the check compared against CompressedClassSpaceSize, which the log shows was reserved at 32 MB here, and didn't trip.
Two other results from the lab that I didn't expect, both on 21.0.12.1:
-XX:+HeapDumpOnOutOfMemoryErrordoes fire for a Metaspace OOM (a 12,991,920-byte dump in 0.043 s). I'd assumed it wouldn't because the heap isn't the problem. The dump is small and still useful; it holds the loaders and who references them.-XX:+ExitOnOutOfMemoryErrorfires too: exit code 3. The fail-fast article has the full matrix of which OOMs trigger it.
I also expected jcmd VM.metaspace to show reserved space tracking the cap. It doesn't. With MaxMetaspaceSize=64m it reported 128 MB reserved (64 non-class plus 64 class) and 6.4 MB committed. Reserved address space is not a limit, and committed is what the cap counts. So don't alert on "reserved".
The honest scope of this: working out that the retry sequence, the 4 MB rounding and the unloading counters all line up took me well over a day, mostly because my first theory (the cap) was wrong in a way the numbers hid until I doubled it four times.
From a pile of loaders to the line that pins them
The chain you're hunting is always the same shape:
GC root (static field, thread, JNI handle, shutdown hook)
-> something in the *parent* loader (a registry, a list, a ThreadLocal map, a driver)
-> object whose class came from the plugin loader
-> PluginLoader
-> every Class it defined
-> their Metaspace
One reference at the top is enough to keep the whole generation alive. In the lab it's Lab.REGISTRY, a static list in the application loader.
Workflow, in the order I'd do it:
- Count loaders over time.
jcmd <pid> VM.classloader_statstwice, an hour apart or one redeploy apart. If the number of loaders of your plugin type grows by one per deploy, you're done diagnosing the what. - Take a heap dump while you still have it:
jcmd <pid> GC.heap_dump /var/dumps/host.hprof(see the dump setup article for paths, pauses and the secrets-in-dumps problem). - Open it in a tool with a path-to-GC-roots view (Eclipse MAT's "Path to GC Roots, exclude weak/soft references" on one of the stale loader instances). I didn't run MAT for this article; the lab dump is tiny enough to do it with a histogram and
VM.classloaders, but on a real dump that's the click that ends the hunt. The walkthrough is in the heap dump leak-hunting article. - Read the first non-JDK frame of the path. Whatever sits between the GC root and the loader is the culprit: a static map, a
ThreadLocalin a pooled thread, a shutdown hook, a JDBC driver registered withDriverManager, a thread the plugin started and never stopped, a JMX MBean. The class-loader-specific patterns live in a forward article (java-classloader-leak).
Once you've fixed it, prove it with a test that doesn't need a heap dump at all. This ran in the lab, and it's the one I'd keep:
WeakReference<ClassLoader> ref = deploy(register); // creates a loader, optionally pins it
for (int i = 0; i < 5 && ref.get() != null; i++) { System.gc(); Thread.sleep(50); }
System.out.println("registered=" + register + " loaderCollected=" + (ref.get() == null));
registered=true loaderCollected=false
registered=false loaderCollected=true
Fixes, and what each costs
| Fix | When it's right | Cost |
|---|---|---|
| Remove the reference (deregister on undeploy, stop threads, clear thread locals) | Always, if there's a leak | Needs the root-path evidence first; sometimes the pinning code is in a library you don't own |
| Restart the host every N redeploys | You can't fix the library this quarter | A bounded outage, and the leak is still there |
Raise -XX:MaxMetaspaceSize |
After unloaded is climbing and used-after-GC is flat, i.e. no leak, just a live set bigger than the cap |
Native memory the container must have; in Kubernetes it counts toward the limit |
Lower MetaspaceSize/tune free ratios |
Startup churn of Metadata GC Threshold GCs |
Rarely matters; not a fix for this error |
-XX:+ClassUnloading off |
Never | Turns the clean lab host into the leaking one |
Not fixes: doubling the cap over a leak (see the table at the top) and leaving the cap unset to "avoid the error". Unset just moves the failure into the class space's 1 GiB default or, in a container, into the kernel's OOM killer, with no Java error at all. That one is a different article (outofmemoryerror-vs-oomkilled).
Design the plugin host so loaders can die
I'd stop designing for "load code at runtime" and start designing for "unload it". In practice:
- One owner per plugin version, with an explicit
undeploy()that is the only place the host's registries hold the plugin. If the registry has to exist, it holdsWeakReferences or an id, never the loader or a plugin object. - Anything a plugin starts, the plugin stops: threads, timers, executors, MBeans, shutdown hooks. The host kills what's left, by name.
- Dynamic code generation (proxies, scripts, expression engines) goes into a loader you can throw away and rebuild, not into the application loader. If you have a codegen cache, bound it.
- The unload test above, in CI, per plugin type.
The "after" for the lab is one line less than the "before": the registry add is gone.
PluginLoader l = new PluginLoader();
l.deploy(v, per);
// no REGISTRY.add(l): the host holds nothing, so the loader and its classes are collectable
The test that would have caught this
Alert on the slope, not the level. For a host that redeploys, the useful signals are loader count (VM.classloader_stats or the loaded-vs-unloaded class counters over JMX), Metaspace used after a collection, and unloaded growing at all. The rule I'd write: if loaded classes minus unloaded classes rises across three consecutive redeploys with no new plugin versions installed, page someone. Bigger picture in the forward article java-memory-alerting-before-oom.
Production flags: -XX:MaxMetaspaceSize set to a measured live set times roughly two (my lab's clean host peaked at 26 MB, so a 32 MB cap is too tight even without a leak), -XX:+HeapDumpOnOutOfMemoryError with a real path, and -XX:+ExitOnOutOfMemoryError so the pod restarts instead of limping.
Version safety, from what I ran and what I'm not sure about:
- Verified on OpenJDK 21.0.12.1 only (G1, Linux x86_64, no Docker): the messages, the retry sequence in the GC log, the dump and exit behaviour,
MetaspaceSize= 22020096,CompressedClassSpaceSize= 1 GiB,MaxMetaspaceSizeunlimited. - Not run: JDK 8/11/17/25, Parallel/ZGC/Shenandoah, Kubernetes, MAT. Before PermGen's removal (JDK 8) the message and flags were different. JDK 25's compact object headers change class-pointer sizing; re-check the class-space numbers there.
- Re-check
jcmd helpafter an upgrade:GC.class_statsis not on 21.0.12.1.
Takeaways
- The error names the thread that was defining a class, not the code that leaked. Look at who holds the old loaders.
- Raising
MaxMetaspaceSizebought me exactly as many redeploys as the cap grew. Test with theunloadedcounter andVM.classloader_statsbefore touching it. - A Metaspace that sawtooths up to 80% of its cap isn't automatically a leak. If
unloadedkeeps climbing, it's a live set that's close to a too-small cap. - Unset
MaxMetaspaceSizedoesn't make the problem go away. It moves the failure to the class space or to the kernel. - Prove a fix with a
WeakReferenceto the loader and aSystem.gc(). If the loader is still there, so is the leak.
Questions that end up in my search history
How do I fix java.lang.OutOfMemoryError: Metaspace?
Find what keeps old class loaders reachable (jcmd VM.classloader_stats, then a heap dump and a path to GC roots) and remove that reference. Raise MaxMetaspaceSize only after you've shown that classes are being unloaded. Verified on OpenJDK 21.0.12.1.
Is increasing MaxMetaspaceSize a good fix?
Only when there's no leak and the live set really is bigger than the cap. With a leak, my lab died at redeploy 350, 725, 1450 and 2950 for caps of 32, 64, 128 and 256 MB: each doubling bought the same slope again.
What's the default MaxMetaspaceSize?
Unlimited on 21.0.12.1 (the flag prints as the maximum 64-bit value). MetaspaceSize (21 MiB here) is only the first GC trigger, and CompressedClassSpaceSize defaults to 1 GiB. See metaspace-vs-permgen for the mechanics.
Does a Metaspace OutOfMemoryError produce a heap dump?
Yes, with -XX:+HeapDumpOnOutOfMemoryError on 21.0.12.1 (12,991,920 bytes in my lab). Details on paths and pitfalls in the dump setup article.