Why architecture matters here

Metaspace matters architecturally because it is the JVM's native-memory blind spot. Every instinct a Java developer has for diagnosing memory problems points at the heap: take a heap dump, open it in a profiler, find the retained set. None of that finds a Metaspace problem, because the metadata is not in the heap and does not appear in a heap dump. Your service is using 6GB of RAM with a 2GB heap that looks perfectly healthy, and the standard toolchain has nothing to say about the other 4GB. Teams routinely lose days to this before someone thinks to look outside the heap.

The second reason is the reclamation granularity, which is the single most important fact in this article. Metaspace is not freed per class — it is freed per classloader. When a classloader becomes unreachable, its entire arena is returned in one operation, which is fast and clean. But the converse is brutal: if one single class loaded by that loader is still reachable from anywhere — a static field in a parent-loaded class, a thread-local, a JDBC driver registered in a global registry, a shutdown hook, a lingering thread whose context classloader points at it — then the loader is reachable, and every class it ever loaded stays in memory. There is no partial reclamation. This all-or-nothing structure is why classloader leaks are so catastrophic and so hard to reason about: a single stray reference retains megabytes of metadata that have nothing to do with it.

Third, the workloads that stress Metaspace are exactly the ones that generate classes at runtime, and they are everywhere in modern Java. Dynamic proxies, CGLIB and ByteBuddy instrumentation, Groovy and JRuby and Nashorn scripts, Java's own lambda call sites, JSP compilation, hot redeploys, and per-tenant classloader isolation all mint classes after startup. In an application whose class count is fixed at startup, Metaspace grows to a plateau and is boring forever. In an application that generates a class per script evaluation or per redeploy, Metaspace is a live resource with a leak surface — and the difference between those two worlds is not visible in any configuration file. It is a property of what your libraries do at runtime.

Advertisement

The architecture: every piece explained

What is actually stored. Metaspace holds the runtime representation of loaded classes: the Klass structure (field layout, vtable and itable, superclass pointers, a pointer to the java.lang.Class mirror), method metadata (Method and ConstMethod objects carrying bytecode, line-number tables, exception tables), the runtime constant pool with its resolution cache, and annotation data. Note the split: the Class object — the thing you get from getClass() — lives on the heap; the Klass metadata it points at lives in Metaspace. Static fields moved to the heap (onto the mirror) in Java 8, which is why PermGen tuning advice from the Java 7 era is not merely outdated but actively misleading.

Two spaces, not one. When compressed class pointers are enabled (the default under roughly 32GB of heap), the JVM reserves a single contiguous region — the class space — so that Klass pointers can be encoded in 32 bits instead of 64. Its size is fixed at reservation time by -XX:CompressedClassSpaceSize, default 1GB, and reserved means virtual address space, not committed RAM. Everything else — methods, constant pools, annotations — lives in non-class metaspace, which has no such constraint. These exhaust independently, which is why OutOfMemoryError: Compressed class space is a distinct error from OutOfMemoryError: Metaspace and requires a different fix: the former means too many classes for the reserved class space, and raising MaxMetaspaceSize will not help it at all.

The allocator. Reserved virtual space is tracked as VirtualSpaceNodes and carved into chunks. Each classloader gets a ClassLoaderMetaspace — its own arena — and allocates by taking chunks and bump-allocating within them. Arenas start with small chunks and graduate to larger ones as a loader proves it needs space, which keeps a loader that loads three classes from reserving a megabyte. This per-loader arena structure is not an implementation detail; it is the mechanism that makes whole-arena reclamation possible in one step.

The two size flags, both misleadingly named. -XX:MetaspaceSize is not an initial or minimum size — it is the high-water mark that triggers the first metadata GC, defaulting to about 21MB. Set it too low and a startup that legitimately loads 80MB of classes triggers a series of full GCs on its way up, each one scanning for unloadable loaders and finding none. Setting it to your steady-state footprint is a standard startup optimization. -XX:MaxMetaspaceSize is the real ceiling, and its default is unlimited. MinMetaspaceFreeRatio and MaxMetaspaceFreeRatio govern how the high-water mark moves after each collection.

Elastic Metaspace (JDK 16, JEP 387). The pre-16 allocator had a real fragmentation problem: chunks came in coarse size classes and freed chunks often could not be recombined, so a workload that churned loaders would see Metaspace grow even though live metadata did not — and committed memory was essentially never returned to the OS. JEP 387 replaced it with a buddy allocator that splits and coalesces chunks in powers of two, and switched to committing memory in small granules on demand rather than per chunk. The headline consequence is that Metaspace can now uncommit — memory released by a dying classloader can go back to the operating system, so RSS actually falls. On JDK 11 it generally does not, which is why 'Metaspace never shrinks' is folklore that was true, is version-dependent, and is worth re-checking on your JDK.

JVM Metaspace — class metadata in native memory, not the Java heapfreed per classloader, not per classKlass structureslayout, vtable, mirror ptrMethod metadatabytecode, constMethodConstant poolresolved refs, annotationsClass space (compressed klass ptrs)-XX:CompressedClassSpaceSize, default 1G reservedNon-class metaspaceeverything else, grows unbounded by defaultClassLoaderMetaspaceone arena per loaderChunksbuddy allocator (JEP 387)VirtualSpaceNodereserved, commit on demandLoader unreachable -> GCwhole arena freed at onceElastic Metaspace (16+)uncommit granules back to OSLeak a loader -> leak its entire arena -> OutOfMemoryError: Metaspace
Metaspace: class metadata lives in native memory, allocated from per-classloader arenas built from chunks; reclamation happens only when a whole classloader becomes unreachable.
Advertisement

End-to-end flow

Trace the life of one classloader in a service that evaluates user-supplied Groovy scripts — a rules engine, a reporting tool, the shape that generates classes forever. A request arrives with a script. The engine creates a fresh GroovyClassLoader, compiles the script into bytecode, and defines the resulting class.

Allocation. Defining the class calls into the JVM, which needs somewhere to put the Klass, the method metadata, and the constant pool. It finds the ClassLoaderMetaspace arena attached to this GroovyClassLoader — brand new, so empty — and requests a chunk. The arena takes a small chunk from the freelist, and under JDK 16+ the allocator commits only the granules actually touched. The metadata is written into the chunk. Separately, a java.lang.Class mirror object is allocated on the heap and cross-linked with the Klass. The script's compiled form now spans both memories, and that pairing is what makes the lifecycle subtle.

Growth. Groovy does not define one class per script — it defines the script class plus closures, plus synthetic call-site classes. The arena's small chunk fills; the allocator hands it a larger one. Meanwhile the JVM's total committed metaspace has been rising, and it crosses the MetaspaceSize high-water mark. This triggers a metadata GC threshold collection: the JVM walks the classloader graph looking for unreachable loaders whose arenas can be freed. Suppose the previous 200 scripts' loaders are genuinely dead — their arenas are freed wholesale, the freed chunks are coalesced by the buddy allocator, and the high-water mark is adjusted per MaxMetaspaceFreeRatio. Under JDK 16+ some granules are uncommitted and RSS drops. This is the healthy steady state: sawtooth growth, periodic collection, flat long-term.

Unloading — and the reference that prevents it. The request finishes and the engine drops its reference to the loader. For the arena to be reclaimed, the GroovyClassLoader, every Class it defined, and every instance of those classes must all be unreachable — because each instance points to its Class, which points to its loader, which points back to every class it defined. It is one cyclic island, collected together or not at all. Now suppose the script called SomeCache.register(this), where SomeCache is an application-level class with a static map. That static map holds an instance. The instance retains its Class. The Class retains the loader. The loader retains all 40 classes it defined. The whole arena is pinned by one entry in one map.

The leak, and why it hides. Nothing appears wrong. The heap is fine — the retained heap is one small object. GC logs are clean. Class unloading runs on schedule and reports unloading the other loaders successfully. But every request that touches this code path leaves another arena pinned. Metaspace climbs by a few hundred kilobytes per request, monotonically. With MaxMetaspaceSize unset — the default — there is no ceiling to hit and no OutOfMemoryError to catch. Native RSS grows past the container limit hours later and the pod is killed with exit code 137: no exception, no dump, no GC log entry. The postmortem shows a healthy heap and a dead process, which is exactly the signature that sends teams hunting through the wrong 2GB.

Diagnosis. The tell is the classloader count. jcmd <pid> VM.classloader_stats shows thousands of live GroovyClassLoader instances where there should be a handful, each holding its own arena. jcmd <pid> VM.metaspace confirms committed metaspace climbing with no corresponding live-class growth, and NMT attributes the native memory to Class. From there a heap dump — useful now that you know what to look for — traces the GC root path from one loader back to SomeCache's static map, and the fix is a line of code, not a flag.