Home January 8, 2024 5 min read By Arunkumar Ganesan

JVM OOM: When the Leak Is in a Library

This is the troubleshooting path I use when a JVM dies with OutOfMemoryError and the first heap dump does not point at my code. The case here is a real Netty classloader leak fixed in 4.1.98.Final.

The signals that mattered

Metaspace rose Heap dropped after GC, but Metaspace kept climbing after every plugin reload.
Full GC lied Full GC ran more often but did not recover the leaked class metadata.
JFR showed cycles Class loading, GC pauses, and allocation pressure lined up with reload bursts.
MAT found the root The retained path ended at a JNI global reference in an external library.

When I troubleshoot a JVM OOM, I try not to assume my code is the leak. I recommend following memory evidence through JFR, heap dumps, and retained ownership until the object graph proves the source.

The important detail was the dependency path. Reactor Netty 1.1.8 pulled Netty 4.1.94.Final, and the macOS DNS native resolver path used netty-resolver-dns-native-macos. Netty issue 13480 documents a classloader leak in that area: io.netty.resolver.dns.macos.DnsResolver could be retained as a JNI global reference.

Dependency path that mattered

Application Validation worker Creates isolated plugin classloaders for customer adapters.
HTTP client Reactor Netty 1.1.8 Runs the outbound API call used during validation.
Resolved library Netty 4.1.94.Final Brings in the macOS native DNS resolver path.
Leak root JNI global reference Retains DnsResolver and keeps plugin classloaders alive.

The lesson was not "Netty is bad." The lesson was: prove the owner of the retained object before changing application code.

Symptoms

The first wrong move would have been increasing -Xmx. Heap was not the main problem. Metaspace and process RSS were. These were the sanitized numbers from the reproduction that matched production behavior:

Signal Healthy baseline During leak Meaning
Metaspace used 82 MB to 96 MB 82 MB to 248 MB in 55 reloads Class metadata was not being unloaded.
Old generation after Full GC 360 MB to 390 MB 410 MB to 430 MB Heap was not growing enough to explain the crash.
Loaded class count Stable near 18,000 Grew past 63,000 Plugin classloaders were staying alive.
Failure No restart OOM after 3 to 4 hours The worker exhausted non heap memory.

Capture the evidence

I started with metrics, then captured runtime evidence. The dashboard had jvm.memory.used{area="nonheap",id="Metaspace"}, jvm.classes.loaded, GC pause count, process RSS, and container restarts. The pattern was a staircase: every reload increased Metaspace, and Full GC did not return it.

jcmd $PID JFR.start name=oom settings=profile duration=20m \
  filename=/tmp/oom.jfr path-to-gc-root=true

jcmd $PID GC.class_histogram > /tmp/classes-before.txt
jcmd $PID GC.heap_dump /tmp/before.hprof

# Run 30 to 50 plugin reloads, then capture again
jcmd $PID GC.class_histogram > /tmp/classes-after.txt
jcmd $PID GC.heap_dump /tmp/java_pid8763.hprof

JFR gave the timeline: class loading climbed during plugin reload, Metaspace thresholds were crossed, and Full GC pauses clustered near the end. The heap dumps gave ownership. That split matters. JFR told me when the JVM was hurting. Eclipse Memory Analyzer told me who was keeping memory alive.

Use MAT to prove the owner

When I talk about "Use MAT to prove the owner", I am looking for a measurable improvement in production code or runtime behavior.

In Eclipse Memory Analyzer, I opened java_pid8763.hprof, ran Leak Suspects, then used Class Loader Explorer and the Dominator Tree. The suspicious pattern was not one large business object. It was many copies of the same plugin classloader, each retaining classes that should have disappeared after validation.

The shortest path to GC roots was the decisive clue. The retained path went through io.netty.resolver.dns.macos.DnsResolver and ended at a JNI global reference. That changed the investigation. My code was creating classloaders, but it was not holding them. The external native resolver was.

Eclipse Memory Analyzer - java_pid8763.hprof
Leak Suspects Class Loader Explorer Dominator Tree Path to GC Roots
Object Retained Heap Shallow Heap
JNI Global Reference 166 MB 0 B
class io.netty.resolver.dns.macos.DnsResolver 166 MB 96 B
org.example.validation.PluginClassLoader @ 0x7f21... 3.3 MB 144 B
50 retained plugin loader instances 166 MB 7 KB
Sanitized MAT view recreated from the heap dump: the root path pointed outside application code.

Replicate and compare versions

The reproduction was small: create an isolated classloader, load the code path that initializes Reactor Netty DNS resolution, make one outbound call, close the client, drop all strong references, force GC, and repeat. Then compare versions.

Test Netty 4.1.94.Final Netty 4.1.98.Final
50 reload cycles Classloaders retained: 50 Classloaders retained: 1 to 2 transient
Metaspace growth +166 MB +12 MB, then flat
MAT root path JNI global reference No retained plugin loader path after GC
Outcome OOM under long run Stable under the same run

Remediate without guessing

My recommendation in "Remediate without guessing" is to test JVM OOM on one representative service before standardizing it.

First, list the real Netty version. For Maven, use mvn dependency:tree -Dincludes=io.netty. For Gradle, use ./gradlew dependencyInsight --dependency netty-resolver-dns-native-macos. If the runtime resolves Netty below 4.1.98.Final and uses dynamic classloaders with the macOS native resolver, treat it as affected.

The fix was to move the Netty family to 4.1.98.Final or newer through the platform BOM, the Spring Boot dependency set, or an explicit Netty override. As a short term containment, I reduced plugin reload churn and removed the macOS native resolver from that worker where native DNS behavior was not required. The real remediation was the library upgrade, followed by the same JFR and MAT comparison to prove the retained classloaders disappeared.

The process to reuse

Do not begin with blame. Begin with memory area. If heap grows, inspect object ownership. If Metaspace grows, inspect classloaders. If direct memory grows, inspect native buffers and reference counted objects. Use JFR for the timeline, heap dumps for ownership, MAT for retained paths, and dependency comparison for proof.

A JVM OOM is solved when the same load test stops producing the memory cycle that caused it. In this case, the cycle was classloader creation, JNI global retention, Metaspace growth, Full GC with no recovery, then OOM. Once Netty was upgraded, the cycle broke.

Validation worker leak example

I use "Validation worker leak example" to keep JVM OOM grounded in a real system, because abstract patterns are too easy to agree with and too hard to operate.

The real production shape was a validation worker that loaded customer adapters with isolated classloaders. The application code dropped its references, but the native DNS resolver path kept classloaders reachable. The useful lesson is to prove the retaining owner before changing business code.

Classloader reproduction loop

I use "Classloader reproduction loop" to separate useful platform movement from release note excitement.

The reproduction loop should create, use, close, and drop each classloader:

for (int run = 0; run < 100; run++) {
  try (URLClassLoader loader = new URLClassLoader(pluginUrls, null)) {
    runValidationPlugin(loader);
  }
  System.gc();
  recordMetaspace(run);
}

The loop recreates plugin classloaders, runs work, forces cleanup pressure, and records metaspace. If metaspace keeps growing, the leak is outside normal request objects.

What I learnt from this kind of leak hunt is that the heap tells the truth, but only if I resist jumping to the most convenient suspect.

#Java #JVM #JFR #MemoryLeak #SoftwareEngineering