Site icon yCrash

JVM Optimization in Real Systems

How we reduced JVM memory usage from 61GB to 4GB by fixing a proxy explosion in a Spring Boot application

The JVM optimization story I’m about to tell didn’t start with fancy dashboards or heroic tuning, but with an unexpected issue.

One morning, our Spring Boot service that was happily running normally with an 8GB heap suddenly started consuming 61GB of memory. No recent deployment. No config change. Nothing more than a JVM consuming up all of that customer-paid-for RAM like there’s no tomorrow.

Yes, in this article, we will describe how we used JVM tuning techniques as well as yCrash to reveal the actual reason. A proxy explosion hiding deep inside the Spring Boot app. We will demonstrate some targeted changes that were responsible for bringing memory consumption from 61GB down to 4GB – and nothing required hardware, scale pods or rewrites on the application itself. If you’ve ever stared at a JVM graph, slowly watched memory rise and decided let’s just give it more heap, this article is for you.

Fig: yCrash Dashboard for the JVM incident

When a Stable Spring Boot Service Suddenly Consumes 61GB of Memory

The strange thing is… nothing happened on the day things blew up.

No deployment. No traffic spike. No magical Friday-night batch job. One night, the graph appeared to be behaving normally: quiet and almost flat. Then the next morning I find it’s sitting at 61GB, as if it had been consuming RAM all night.For a moment, we suspected the usual suspects:

Maybe it’s a traffic burst? Users?

Some random cloud hiccup?

But we knew this couldn’t be the case we definitely had a leak and it was from the JVM somewhere.

Right above is what yCrash would have generated at that time of that issue. 

Fig: yCrash Incident Summary showing weird memory utilization

Using yCrash to Identify a Growing ZipFile$Source Memory Leak

 The summary alone didn’t verify the leak, but it led us down to something strange:

an object that was quietly growing in the background. The real work began then.

Fig: yCrash Issues Summary – 89 ZipFile$Source (21.49% of heap)

Heap Distribution Analysis Showing ZipFile$Source Dominating the JVM Heap 

Instead, after I saw the “89 ZipFile$Source instances” warning, I checked at the numbers in the heap distribution view to see how bad things were. And really, that’s when everything came together.

The Sunburst chart wasn’t scattered. It wasn’t noisy. And it was focusing on one place like a spotlight:

java.util.zip.ZipFile$Source, which was a whopping 21% of the heap.

For comparison, a healthy heap cross-cuts its memory between caches, collections/bytes and buffers/user objects (you know, regular app things) etc.

But here?

There was one little private class using a large portion of memory like it had the entire JVM on lease.

The longer I stared at the chart, the more obvious it became:

this wasn’t load… it wasn’t traffic… It was a leak right in front of; our eyes.

A leak with a name tag.

A leak that had been slowly building up with every re-deploy and every classpath scan.

Here is the heap view that made everything clear. 

Fig: yCrash Heap Distribution view highlighting ZipFile$Source retaining 21% of the heap

Object Retention Analysis Revealing Multiple ZipFile$Source Instances 

When I saw this large piece come out of the “pie” in the sunburst chart, I switched over to table view to confirm which were actually eating memory (following). And, that was just the place where everything matched perfectly.

Sitting at the very top of that list was the same person:

java. util. zip. ZipFile$Source (3.91MB) – 6.87% of the heap by itself.

And it wasn’t even just one.

Scraping the table revealed there to be dozens scattered throughout the report.

One was insignificant in itself, but they together devoured a huge segment of the old generation.

If a class that’s supposed to be one place at most shows up 89 places instead, you don’t need a profiler to know something is wrong. The table view basically screamed:

“This item shouldn’t be this much right there.

Here is the exact object table that validated the leak. 

Fig: yCrash table view revealing multiple ZipFile$Source instances retaining unexpected heap

GC Behavior Confirming a Slow-Burn JVM Memory Leak 

GC is generally your safety net. As the heap continues to grow, another collection cycle is executed in the background which reclaims unused objects and effectively stabilizes at a new baseline memory level.

That didn’t happen here.

In the GC chart, all collection cycles looked fine at a high level — young gen pauses, old gen sweeps; nothing out of sorts. But the baseline continued to crawl upward after every collection cycle. The pile never came back to the original position.

That’s when you stop thinking “high traffic” and start thinking “something is being held.”

A JVM under load fluctuates.

A leaking JVM can only blow one way: up.

Here’s the actual GC view which showed leak behavior:

Fig: yCrash GC behavior showing heap not dropping after collections, indicating a memory leak

Related Reading on Memory Leaks & JVM Diagnostics

If you’d like to dive deeper into memory leak patterns and how to diagnose them effectively, these resources will help:

Not-So-Common Memory Leaks & How to Fix Them
Common Memory Leaks in Java & Their Fixes
Symptoms of a Memory Leak: How to Recognize Them Early

Root Cause: How Spring Boot and JDK ZIP Caching Triggered the Leak 

Spring Boot wasn’t malfunctioning.

It was just carrying out its normal start up order of, scanning the classpath, opening compiled JARs and wiring it all together. However, an interesting thing occurred as part of that scanning: a new internal wrapper object, java, was created for every JAR scanned. util. zip. ZipFile$Source.

The problem wasn’t that these objects included-were made.

They just never left.

All of the little redeploy, restart locally and classpath refresh would all leave a few additional ZIpFIle$Source instances. None were being leaked by the framework or JVM’s ZIP metadata caching system.

The over time past and those “harmless” objects collected. Quietly. Predictably.

And the jvm held onto them because something upstream was still referring to them.

This combination produced a long-term, slow-burn memory leak that was unrelated to business logic or user traffic, or even application load. It was nothing more than ZIP resources that were opened over and over but never closed.

This time, though, the pattern was hard to ignore. It wasn’t coincidence that one internal class was hogging half the heap, it was the underlying issue.

How We Reduced JVM Memory Usage from 61GB to 4GB 

The fix was in fact rather anticlimactic once the source of the root cause was identified. It was, if anything, downright uncomfortable. It wasn’t our logic, it wasn’t our traffic, and it certainly wasn’t the server’s capacity. So only the ZIP-related proxy objects were leaking memory in the heap.

Before using yCrash, we had almost zero visibility into what was actually growing inside the JVM. All we could see was the heap climbing. But the moment we ran yCrash, the picture changed instantly. The tool highlighted the exact objects responsible for the leak,  the ZipFile$Source wrappers, and exposed the rising GC baseline. What would’ve taken hours or days with manual heap dump analysis became clear in just a few minutes.

And the resolution came down to three small words.

1. Stop Spring Boot From Caching ZIP Proxies

Spring Boot was eager to cache resources within nested JARs and never lets go.

And with the cache turned off those ZipFile$Source wrappers didn’t accumulate.

spring:
       web:
           resources:
                      cache:
                            cachecontrol:
                                         no-cache: true

This tiny tweak ensured that Spring wouldn’t hold unnecessary references long after startup.

2. Disable the JVM’s ZIP Metadata Caching

The JDK also kept ZIP metadata around longer than needed.
Turning off extra ZIP validation stopped those objects from accumulating silently.

-Djdk.util.zip.disableZip64ExtraFieldValidation=true

If you’re using startup scripts:

export JAVA_OPTS="$JAVA_OPTS -Djdk.util.zip.disableZip64ExtraFieldValidation=true"

3. Restart Only the Application, Not the Whole Server

Once both caches were disabled, the only thing left was to restart the app so the old proxies would finally disappear from the heap.

No full server reboot.
No Kubernetes pod recycling.
Just a normal application restart.

And the difference was immediate: the heap dropped from 61GB to about 4GB and stayed there.

How to Detect This Leak Using Standard JVM Flags

A memory leak like this can actually be spotted early using nothing more than a few standard JVM flags. Before we used yCrash, we had almost no visibility into what was growing inside the heap, but these flags quickly revealed that something wasn’t being released.

Useful JVM Flags for Detecting the Leak

  1. -XX:+HeapDumpOnOutOfMemoryError
  2. -XX:HeapDumpPath=/var/log/heapdump.hprof
  3. -verbose:gc
  4. -XX:+PrintGCDetails
  5. -XX:+PrintGCTimeStamps

Finding Duplicate ZipFile$Source Instances

jmap -histo <PID> | grep ZipFile\$Source

Which Java Versions Are Affected?

This issue appears most commonly in Java 8 and Java 11 applications packaged as Spring Boot fat JARs. Java 17 reduces some ZIP metadata caching behavior, but the leak can still occur when applications repeatedly scan nested JARs.

Key Learnings from This JVM Memory Leak Incident 

Some JVM leaks are not from code, traffic or business logic; they are due to little internal behaviors that don’t get noticed for months. In our case, a straight zip proxy buildup resulted in 61GB of memory usage. And when they found the solution, it wasn’t a week’s worth of work rather fixing a few lines of code and two minutes’ time. In this case, however, a timely reminder that good observability and the right tools can turn mysterious JVM spikes into easy troubleshooting wins. And by leveraging yCrash, we were able to identify the exact leaking objects within minutes, something that would’ve taken hours or even days with manual analysis. yCrash gave us immediate visibility into the ZipFile$Source accumulation and helped pinpoint the root cause without guesswork.

Exit mobile version