What replaced PermGen, and why?
Java 8 removed PermGen and moved class metadata to Metaspace, in native memory outside the heap. The failure did not go away — it moved. PermGen had a size you set yourself and failed predictably; Metaspace is unbounded by default, so the same leak now grows until the kernel kills the process, with no Java-side error and no heap dump.
The Answer
- PermGen was a fixed-size region inside the heap holding class metadata, static variables and interned strings. It was removed in Java 8 by JEP 122.
- Metaspace replaced it for class metadata and lives in native memory, outside the heap and outside
-Xmx. - Static variables and interned strings did not go to Metaspace. They went to the ordinary heap — statics inside the
java.lang.Classmirror object. - The motivation was the fixed ceiling: PermGen had to be sized by hand,
OutOfMemoryError: PermGen spacewas a routine redeploy failure, and the right size was unknowable in advance. - The failure moved rather than disappearing. Metaspace is unbounded by default, so a class-loader leak now grows until the container's limit and the process is OOMKilled — no Java error, no heap dump.
- Which is why
-XX:MaxMetaspaceSizeis worth setting even though nothing forces you to. A predictable failure beats an invisible one.
Understand It
Where the metadata actually lives now
Three facts, read from the running JVM rather than asserted:
System.out.println("a PermGen pool exists : " + hasPoolNamed("Perm"));
var metaspace = pool("Metaspace");
System.out.println("Metaspace is non-heap memory : "
+ (metaspace.getType() == java.lang.management.MemoryType.NON_HEAP));
System.out.println("Metaspace has a ceiling : "
+ (metaspace.getUsage().getMax() >= 0));a PermGen pool exists : false
Metaspace is non-heap memory : true
Metaspace has a ceiling : falseThe third line is the one that matters and the one nobody quotes. getMax() returning -1 means unbounded: this JVM will let class metadata grow until the operating system refuses. That is the trade JEP 122 made — no more guessing a size, and no more ceiling to catch a leak.
Pass -XX:MaxMetaspaceSize=64m and the same line prints true. Nothing else about the program changes.
It is not empty, and it only grows
var loading = classLoading();
long before = metaspaceUsed();
int loadedBefore = loading.getLoadedClassCount();
for (String name : List.of("java.util.regex.Pattern", "javax.crypto.Cipher",
"java.net.http.HttpClient", "java.util.zip.ZipFile",
"java.time.chrono.HijrahDate", "java.text.NumberFormat")) {
Class.forName(name);
}
System.out.println("classes loaded before : " + loadedBefore);
System.out.println("classes loaded after : " + loading.getLoadedClassCount());
System.out.println("metaspace grew by : " + (metaspaceUsed() - before) + " bytes");
System.out.println("classes unloaded ever : " + loading.getUnloadedClassCount());classes loaded before : 797
classes loaded after : 863
metaspace grew by : 156984 bytes
classes unloaded ever : 0Six Class.forName calls pulled in 66 classes — each one drags its supertypes, interfaces and field types with it — and about 150 KB of metadata. The exact numbers move between runs and JDK builds; the shape does not.
The last line is the important one. Zero classes have ever been unloaded, in a program that ran to completion. That is normal, and it is the key to the whole topic.
Class metadata is unloaded per class loader, not per class
This is the mechanism, and getting it right is what separates a real answer from a recited one.
A class is not collectable on its own. A class is reachable from its ClassLoader, the loader is reachable from every class it loaded, and every instance is reachable from its class. The whole thing is one cycle, and it is collectable only when the entire loader becomes unreachable — loader, all its classes, and all their static fields, together or not at all.
For the application class loader, that never happens while the JVM is up. So in an ordinary service, Metaspace fills once during warm-up and then sits flat. Metaspace is not a place leaks come from; it is a place one specific leak shows up.
That leak is redeploying an application in a long-running container. Each deploy creates a new loader and loads a complete second copy of every class. The old loader should become unreachable — and one reference from outside is enough to stop it:
- a
ThreadLocalset on a container-owned thread, holding a value whose class came from the old loader - a JDBC driver still registered in
DriverManager - a shutdown hook, or a thread the application started and never stopped
- a listener registered with a singleton the container owns
Any one of those pins the loader, and the loader pins every class and every static field. See memory-leaks — this is the same "unwanted reachability" rule, applied to loaders instead of objects.
Why the new failure is worse than the old one
This is the honest part, and it is the answer an interviewer is actually listening for.
PermGen was annoying: you had to size it, and OutOfMemoryError: PermGen space after three redeploys was a rite of passage. But look at what that failure gave you. A Java error, thrown by the JVM, at a ceiling you chose, with a stack trace, which -XX:+HeapDumpOnOutOfMemoryError turned into a dump you could open.
The Metaspace version of the same leak produces none of that. It grows into native memory until the container's limit, and then the kernel kills the process. No exception, no dump, nothing in the application log, and a heap dump taken beforehand looks healthy — because the retained objects are classes, not instances.
| PermGen before Java 8before 8 | Metaspace Java 8+8+ | |
|---|---|---|
| Where | inside the heap | native memory, outside -Xmx |
| Default size | ~64–82 MB | unbounded |
| Flag | -XX:MaxPermSize | -XX:MaxMetaspaceSize |
| Also held | statics, interned strings | class metadata only |
| On exhaustion | OutOfMemoryError: PermGen space | OutOfMemoryError: Metaspace if bounded — otherwise OOMKilled |
| Heap dump shows it | yes | no — it is not in the heap |
The diagnostic signature worth memorising: a container OOMKill with no OutOfMemoryError in the log is a native-memory story. Metaspace is one of the candidates, along with thread stacks, the code cache and direct byte buffers.
Reference
Flags, and what each is for
# Bound it. Nothing forces you to, and that is the problem.
-XX:MaxMetaspaceSize=256m # a real ceiling: you get OutOfMemoryError:
# Metaspace instead of an OOMKill
-XX:MetaspaceSize=128m # NOT an initial size — the threshold at
# which the first metadata GC is triggered.
# Set it above your steady state to avoid a
# pointless full GC during warm-up.
# Removed in 8. The JVM refuses to start if you pass them.
-XX:PermSize=128m # Unrecognized VM option
-XX:MaxPermSize=256m # Unrecognized VM option
# See it happening.
-Xlog:class+unload=info # every class unload, with the loader
-Xlog:class+load=info # every load — noisy, but conclusive
-Xlog:gc+metaspace=info
-XX:NativeMemoryTracking=summary # then jcmd <pid> VM.native_memory summary
-XX:MetaspaceSize being a GC threshold rather than an initial allocation is the most commonly misread flag in this area. Leaving it at the default on an application with a large class count means a full GC early in warm-up that achieves nothing.
Diagnosing it
# Is it growing at all?
jcmd <pid> GC.class_stats # needs -XX:+UnlockDiagnosticVMOptions
jcmd <pid> VM.metaspace summary # per-loader breakdown — the useful one
jcmd <pid> VM.classloader_stats # loaders, class counts, metadata bytes
# The signature of a redeploy leak: N copies of the same class name,
# one per loader that was never collected.
jcmd <pid> GC.class_histogram | grep YourApplicationClass
# And the one that names the problem outright.
jcmd <pid> VM.native_memory summary # Class (metadata) section
VM.classloader_stats is the fastest route to a diagnosis: seeing four WebappClassLoader instances in a container that has been deployed to four times is the entire finding.
From inside the JVM
// Metaspace is a memory pool like any other.
for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) {
if (pool.getType() == MemoryType.NON_HEAP) {
System.out.println(pool.getName() + " " + pool.getUsage());
}
}
// Loaded vs unloaded is the leak indicator in one line: in a service that
// redeploys, loaded should come back down after a deploy settles.
ClassLoadingMXBean loading = ManagementFactory.getClassLoadingMXBean();
loading.getLoadedClassCount(); // currently loaded
loading.getTotalLoadedClassCount(); // ever loaded
loading.getUnloadedClassCount(); // ever unloaded
Exporting loaded - unloaded as a metric is cheap and is the single most useful early warning for this class of problem.
Where each thing went in Java 8
| Lived in PermGen | Lives now |
|---|---|
| class metadata, method bytecode, constant pool | Metaspace (native) |
| static variables | the heap, inside the java.lang.Class mirror |
| interned strings | the heap — moved in Java 7, a release earlier |
| JIT-compiled code | the code cache (native), and always did |
The middle two are what the common answer gets wrong. "Statics live in Metaspace" is repeated everywhere and JEP 122 says otherwise — see heap-vs-stack.
Scenarios
Metaspace grows on every redeploy and the heap looks fine. The heap looking fine is the diagnosis: what is retained is classes, not instances, so a heap dump shows nothing unusual. Run jcmd VM.classloader_stats and count loaders — one per deploy that was never collected. Then find the single outside reference pinning the old one: threads the application started, JDBC drivers in DriverManager, ThreadLocals on container threads, listeners on container singletons.
A framework generates proxies at runtime and Metaspace creeps. Anything using CGLIB, ByteBuddy, or dynamic proxies creates classes at runtime, and if the generating loader is retained per-instance rather than shared, each one is permanent. Common with a badly-scoped mapper or an expression evaluator that compiles a class per expression. The give-away in GC.class_histogram is many classes with generated-looking names and an increasing numeric suffix. The fix is nearly always to cache and reuse rather than to raise the ceiling.
Somebody proposes removing -XX:MaxMetaspaceSize because it caused an outage. They are right that it caused the outage and wrong about the fix. An unbounded Metaspace does not remove the leak, it removes the signal — you trade a Java error naming the problem for a kernel OOMKill that names nothing. Raise the ceiling to something above the real steady state with headroom, keep it, and alert on loaded - unloaded. Removing it is choosing a worse failure mode for the same bug.
"We restart on every deploy, so who cares?" Genuinely reasonable, and worth saying out loud rather than treating as laziness. If you deploy by replacing containers, the class-loader leak has no time to matter and the ceiling protects you from nothing. The interesting question is what else in your estate does not restart — a long-running batch host, a legacy application server, anything with hot-reload in development. Development is where this bites most often now, because an IDE with hot-swap is exactly the redeploy loop that was the original problem.
Interviewer's Next Move
1. "What replaced PermGen, and why?"
Metaspace, in Java 8 via JEP 122, holding class metadata in native memory outside the heap. The motivation was the fixed ceiling: PermGen had to be sized by hand, the right size was unknowable, and OutOfMemoryError: PermGen space was a routine failure in anything that redeployed. Note also that only the metadata moved — statics and interned strings went to the heap.
2. "So is the PermGen problem solved?"
The ceiling is gone; the leak is not. Metaspace is unbounded by default, so the same class-loader leak now grows into native memory until the kernel kills the process — no Java exception, nothing in the log, and a heap dump that looks healthy because classes are not heap objects. Arguably a worse failure mode, which is why setting -XX:MaxMetaspaceSize is still worth doing.
3. "When is class metadata actually unloaded?" When the entire class loader becomes unreachable, not per class. A class holds its loader, the loader holds all its classes, and instances hold their class — so it is all-or-nothing per loader. For the application class loader that never happens while the JVM is running, which is why Metaspace normally fills during warm-up and then stays flat.
4. "What is a ClassLoader leak and how would you find one?"
A redeploy creates a new loader and a second copy of every class; one reference from outside — a ThreadLocal on a container thread, a registered JDBC driver, a thread that was never stopped — pins the old loader and everything it loaded. Find it with jcmd VM.classloader_stats and count loaders, or a class histogram showing N copies of one class name.
5. "Where do static variables live, then?"
On the heap, inside the java.lang.Class mirror object. JEP 122 moved them there along with interned strings when PermGen went; only the metadata went to Metaspace. Most people answer "Metaspace", and the distinction matters because it means a large static cache counts against -Xmx and shows up in a heap dump.
Code traps
// A service with a long uptime and a config system that compiles each
// expression rule into a class, using a fresh loader per compile.
for (Rule rule : rulesReloadedEveryMinute()) {
ClassLoader loader = new RuleClassLoader(parent);
Class<?> compiled = loader.defineRule(rule);
cache.put(rule.id(), compiled);
}
Answer
Metaspace grows without limit and nothing throws until the container is killed. The cache holds the Class, the Class holds its loader, and the loader holds every class it defined — so no loader is ever collectable, and there is a fresh one every minute. Note that fixing the cache alone is not enough: anything else retaining one of those Class objects, including a ThreadLocal typed by it, pins the loader just as effectively.
java -XX:MaxPermSize=256m -jar app.jar
Answer
On Java 8 it warns that the flag is ignored; from Java 9 onward the JVM refuses to start with Unrecognized VM option. It appears in migration incidents constantly, because the flag sits in a startup script nobody has read since 2014 and the application simply does not come up. Worth knowing as the answer to "what breaks when you move from 8 to 17" — see version-migration.
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "Metaspace is just PermGen renamed." | Different memory entirely — native, outside -Xmx, and unbounded by default. |
| "Removing PermGen fixed the OutOfMemoryError." | It removed the ceiling. The leak produces an OOMKill instead, which is harder to diagnose. |
| "Static variables live in Metaspace." | JEP 122 moved them to the heap, inside the java.lang.Class mirror. |
| "Interned strings live in Metaspace." | They moved to the heap in Java 7, a release before PermGen went. |
| "Classes are garbage collected individually." | Per class loader, all or nothing. |
| "Metaspace shows up in a heap dump." | It does not. That is exactly why this leak is hard to see. |
"-XX:MetaspaceSize is the initial size." | It is the threshold for the first metadata GC. |
Check Yourself
Q1. PermGen is gone. Is the failure it used to cause gone with it?
Answer
No — it moved and got quieter. Metaspace is unbounded by default, so a class-loader leak grows into native memory until the container's limit and the process is OOMKilled, with no Java exception, nothing in the log, and a heap dump that looks healthy because classes are not heap objects. Bounding it with -XX:MaxMetaspaceSize buys you back the loud version.
Q2. Your service is OOMKilled, there is no OutOfMemoryError in the log, and the last heap dump looks healthy. What does that combination point at?
Answer
Memory outside the heap, because the JVM only throws when the heap is exhausted. Metaspace is one candidate — and the "healthy heap dump" is a positive signal for it, since retained classes are not heap objects — alongside thread stacks, the code cache and direct byte buffers. Turn on native memory tracking and compare loaded against unloaded class counts.
Q3. When does the JVM actually unload a class?
Answer
Only when its entire class loader becomes unreachable, and then all of that loader's classes go together. A class references its loader, the loader references every class it loaded, and instances reference their class, so it is one cycle that collects all at once or not at all. For the application class loader that never happens while the JVM runs, which is why Metaspace fills during warm-up and then stays flat in a normal service.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Ask the JVM where its metadata lives | 5 min |
| Challenge | Watch a class loader refuse to die | 20 min |
| Production | The redeploy that ate the host | 45 min |
| Interview | Full round replay — Metaspace | 10 min |
What changed, and when
Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.
- Java 7
Interned strings move out of PermGen and onto the heap, so String.intern() stops being a way to exhaust it.
Before Java 7 (now gone): The string pool lived in PermGen at a fixed size, and interning user-supplied strings was a documented way to bring a server down.
- Java 8LTS
PermGen is removed (JEP 122). Class metadata moves to Metaspace in native memory; static variables move to the heap, inside the java.lang.Class mirror. -XX:PermSize and -XX:MaxPermSize are gone and the JVM refuses to start if you pass them.
Before Java 8 (now gone): Class metadata, static variables and interned strings shared one fixed region inside the heap, and java.lang.OutOfMemoryError: PermGen space was a routine redeploy failure.
- Java 8LTS
Metaspace is unbounded by default — it grows until the machine or the container says no.
Before Java 8 (now gone): PermGen defaulted to around 64-82 MB, so a leak hit a ceiling early, threw a Java error, and produced a heap dump you could read.
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up5 min
Ask the JVM where its metadata lives
One concept, guided. Near-impossible to fail.
- Challenge20 min
Watch a class loader refuse to die
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The redeploy that ate the host
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — Metaspace
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- How do you get a memory leak in a garbage-collected language?
- What lives on the heap and what lives on the stack?
- version migration — not written yet
Questions that lead here
Why is String immutable, and what is the string pool?
String is immutable because nothing in its API exposes a mutator — not because its byte[] field is final. The pool is a JVM-wide cache of literals that makes == accidentally work on literals and fail everywhere else.
Asked constantlyintermediate0–8 yrs9 min readStringsWhat lives on the heap and what lives on the stack?
Objects go on the heap, shared by every thread and collected by the GC. Frames go on the stack, one per thread, popped on return. The rule everyone recites — primitives on the stack, objects on the heap — holds only for locals: an int field lives inside its object, on the heap. And it is not absolute, because escape analysis deletes allocations the JIT proves never escape.
Asked constantlyintermediate1–10 yrs12 min readJvmHow do you get a memory leak in a garbage-collected language?
The collector frees what is unreachable, not what is finished with — so a leak in Java is unwanted reachability, not a missing free(). Four structures cause almost all of them: a static collection, a registered listener nobody unregisters, a ThreadLocal on a pooled thread, and a ClassLoader held by one stray reference. None of them throws anything until the heap is gone.
Asked constantlysenior3–15 yrs12 min readJvm
Every runnable example above was compiled and executed against openjdk 21.0.12 on this build, and its output diffed against what this page claims. Last updated 2026-09-13.