Java Memory Management
Java Memory Management: How to Find Memory Leaks in Production
Your Java application works fine after deployment. APIs respond quickly, database queries succeed, and everything looks healthy.
After a few hours, memory usage starts increas. Garbage collection runs more frequently, response times become slower, and eventually the application may run out of memory.
You restart the service. Memory usage drops, and everything looks normal.
I’ve seen why this type of problem can be difficult to debug: the code may not throw an exception or contain an obvious bug. Sometimes, objects remain in memory simply because something is still referencing them.
In this article, we’ll cover Java memory areas, garbage collection, common memory leaks, and the JVM tools that help investigate them.
The focus is practical: how to move from “memory is increasing” to finding the actual cause.
1. Understand the Different Types of Java Memory
Before debugging a memory issue, identify which memory area is growing. Increasing heap usage and increasing native memory require different investigations.
Heap memory
The heap stores most Java objects and arrays. It is shared between application threads.
public void createEmployee() {
int age = 30;
Employee employee = new Employee();
employee.setName("Rahul");
}Here, age and employee are local variables, while the Employee object is generally allocated on the heap.
When the method finishes, the object becomes eligible for garbage collection if nothing else references it.
Watch for: Increasing post-GC heap usage, growing collections, and OutOfMemoryError: Java heap space.
Stack memory
Each thread has its own stack, which holds method frames and execution information.
When a method returns, its stack frame is removed.
Deep recursion can cause StackOverflowError. Too many threads can also consume substantial memory through their individual stacks.
Watch for: StackOverflowError, excessive thread counts, and thread-related memory pressure.
Metaspace
Metaspace stores class metadata and uses native memory rather than Java heap memory.
Applications that continually load classes or retain class loaders unnecessarily can experience increasing metaspace usage.
Watch for: OutOfMemoryError: Metaspace and unexpected class-loader growth.
Direct and native memory
Java also uses memory outside the heap for direct buffers, thread stacks, JVM internals, and native libraries.
For example:
ByteBuffer buffer =
ByteBuffer.allocateDirect(1024 * 1024);This creates a direct buffer with approximately 1 MiB of capacity outside the ordinary Java heap.
Watch for: High process or container memory usage even when heap usage remains relatively stable.
Memory areaMain purposeTypical warning signHeapObjects and collectionsHeap usage keeps increasingStackMethod executionStack overflow or too many threadsMetaspaceClass metadataMetaspace exhaustionNative memoryDirect buffers and JVM/native allocationsHigh process memory with stable heap
Remember: the Java heap is only part of the memory used by a Java process.
2. How Garbage Collection Works
Java automatically reclaims objects that are no longer reachable.
The JVM starts from GC roots, such as references from active thread stacks and static fields, and follows the reference chains to determine which objects remain reachable.
Consider this example:
GC Root
|
v
EmployeeService
|
v
EmployeeCache
|
+----> Employee 101
+----> Employee 102
+----> Employee 103Even if the application no longer needs employee 101, the garbage collector cannot reclaim it while the cache still references it.
This is the key to understanding Java memory leaks.
Does System.gc() fix a memory leak?
System.gc();No. This requests that the JVM consider garbage collection, but it does not guarantee a collection or remove references that are keeping objects alive.
If a map retains millions of objects, the solution is to fix the retention problem, not repeatedly request garbage collection.
3. A Common Production Problem: An Unbounded Cache
Imagine a Spring Boot employee API that caches employee details to reduce database queries.
public class EmployeeCache {
private static final Map<Long, EmployeeDto> CACHE =
new HashMap<>();
public static void put(Long id, EmployeeDto employee) {
CACHE.put(id, employee);
}
}The problem is straightforward: entries are added, but nothing limits the cache size or removes expired data.
Because the map is held by a static field, it can remain reachable for the lifetime of its class loader.
As the number of distinct employee IDs grows, the cache may consume more and more memory.
How would you fix it?
Use a cache with a clear retention policy. For example, Caffeine supports size limits and expiration:
Cache<Long, EmployeeDto> employeeCache =
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.build();This limits the cache to 10,000 entries and expires entries ten minutes after they are written.
The Caffeine dependency is required.
One important point: a limit on entry count is not a strict memory limit. Large entries can retain much more memory than small ones.
The lesson is simple: caching can improve performance, but every cache needs an intentional retention policy.
4. Five Other Causes of Memory Leaks
1. Static collections
private static final List<EmployeeDto> EMPLOYEES =
new ArrayList<>();If objects are continually added and never removed, the list can grow indefinitely.
Use static collections carefully, especially when they retain request data or large object graphs.
2. ThreadLocal values
Thread pools reuse worker threads across requests. Values stored in a ThreadLocal can remain associated with a worker after a task finishes.
private static final ThreadLocal<byte[]> DATA =
new ThreadLocal<>();
public void process() {
try {
DATA.set(new byte[1024 * 1024]);
// Process request
} finally {
DATA.remove();
}
}Remove values when they should not survive the task. A finally block ensures cleanup even if processing throws an exception.
3. Listeners that are never removed
A long-lived event publisher may retain a listener after the listener’s component is no longer needed.
Remove listeners at the appropriate lifecycle boundary to avoid retaining unnecessary objects.
4. Spring singleton beans
Spring singleton beans generally live as long as their application context.
A service that stores every request in an instance-level list can retain data across thousands of requests.
Keep request-specific data local to the operation unless there is a deliberate reason to retain it.
5. Unbounded queues
If requests arrive faster than workers can process them, the queue grows. Pending tasks may retain payloads and other objects.
Use queue capacity limits, backpressure, and suitable rejection or persistence strategies.
5. Debugging a Memory Leak in Production
This is the most important part: don’t guess. Follow the evidence.
Step 1: Check memory metrics
Start with your monitoring dashboard.
Check:
- Heap usage before and after garbage collection.
- Old-generation occupancy.
- GC frequency and pause duration.
- Process RSS and container memory.
- Request latency, throughput, and CPU usage.
The most useful signal is often the post-GC heap baseline.
Get Coder_Ninja’s stories in your inbox
Join Medium for free to get updates from this writer.
For example, if heap usage after collection rises from 500 MB to 800 MB and then to 1.4 GB under similar workloads, objects may be accumulating.
This is a warning sign, not proof of a leak. Legitimate cache growth and changes in workload can produce similar behavior.
Step 2: Use jcmd to inspect the JVM
jcmd is a JDK diagnostic tool that sends commands to a running JVM.
Find Java processes:
jcmd -lInspect heap information:
jcmd <PID> GC.heap_infoInspect JVM flags:
jcmd <PID> VM.flagsReplace <PID> with the Java process ID.
These commands help you understand heap usage and runtime configuration. They do not identify the exact object retaining memory.
Step 3: Use jstat to monitor garbage collection
Run:
jstat -gcutil <PID> 1000This samples garbage-collection statistics approximately every second.
Look for increasing old-generation occupancy and frequent collections.
High GC activity does not automatically mean a memory leak. A high allocation rate or insufficient heap can produce similar symptoms.
Step 4: Enable GC logs
For Java 9 and later, you can enable unified GC logging using this JVM startup option:
-Xlog:gc*,safepoint:file=/var/log/app-gc.log:time,uptime,level,tagsCheck for frequent Full GC events, long pauses, and high heap usage after collection.
GC logs help explain how the JVM behaves, but they do not usually identify the exact reference responsible for a leak.
Configure log rotation and protect disk space.
Step 5: Capture a heap dump
A heap dump records objects in the Java heap and their references.
Run:
jcmd <PID> GC.heap_dump /secure-dumps/heap.hprofA heap dump can be large and may affect application performance. It can also contain sensitive data, so capture and store it carefully in production.
Use a heap dump when you need to investigate which objects are consuming memory and what is keeping them reachable.
Step 6: Analyze the dump with Eclipse MAT
Eclipse Memory Analyzer (MAT) is a tool for investigating Java heap dumps.
Focus on four features:
- Histogram: Shows instance counts and memory usage by class.
- Dominator Tree: Helps identify objects retaining large portions of the heap.
- Retained Heap: Estimates the memory that could become collectible if an object and its dominated objects were no longer reachable.
- Paths to GC Rots: Shows the reference chains preventing objects from being collected.
Suppose MAT shows millions of EmployeeDto objects.
The DTOs may not be the real problem. Follow their reference paths. If they lead back to an unbounded cache, you have a concrete lead.
This is how you move from identifying memory consumption to finding the code responsible for retaining it.
Step 7: Use JFR to find allocation hotspots
Java Flight Recorder (JFR) helps investigate allocations, garbage collection, CPU activity, and other runtime events.
Start a recording on a supported JDK:
jcmd <PID> JFR.start \
name=MemoryInvestigation \
settings=profile \
duration=5m \
filename=/secure-dumps/memory.jfrOpen the recording in Java Mission Control (JMC).
JFR helps answer questions such as:
- Which code paths allocate the most memory?
- Which operations create many temporary objects?
- When does allocation activity increase?
Keep this distinction in mind:
JFR helps investigate where objects are created. A heap dump helps investigate why objects remain in memory.
High allocation is not necessarily a leak. Temporary objects can be created frequently and collected successfully.
Step 8: Investigate native memory
What if container memory is increasing but heap usage is stable?
Investigate direct buffers, thread stacks, metaspace, JVM internals, and native libraries.
If Native Memory Tracking (NMT) was enabled at JVM startup, run:
jcmd <PID> VM.native_memory summaryNMT reports certain categories of JVM native-memory usage, but it does not account for every native allocation.
Compare the results with operating-system and container metrics. A container can exceed its memory limit even when the Java heap has not reached -Xmx.
6. Verify the Fix Before Calling It Solved
Suppose you replace the unbounded cache with a bounded cache.
How do you know the change worked?
Run comparable workloads against both versions. Keep the request rate, input data, JVM configuration, and test duration as consistent as possible.
Measure:
MetricWhat it tells youPost-GC heap usageWhether retained memory stabilizesPeak heap usageMaximum observed heap demandThroughputWhether processing capacity changesp95/p99 latencyImpact on slower requestsGC time and pausesGarbage-collection overheadCache hit ratePerformance cost of evictions
The goal is not simply to reduce memory usage. You want memory to remain stable under sustained load without unacceptable performance degradation.
Don’t claim a performance improvement until the measurements support it.
7. How to Prevent Memory Leaks
A few practical habits can prevent many common problems:
- Bound caches and queues: Define size limits, expiration, and overload behavior.
- Clean up resources: Remove listeners and
ThreadLocalvalues when their lifecycle ends. - Keep singleton services stateless where possible: Avoid accumulating request-specific data in fields.
- Use pagination and streaming: Avoid loading unnecessarily large datasets into memory.
- Monitor trends: Compare post-GC heap usage under similar workloads.
- Load-test realistic scenarios: Include repeated requests, large payloads, and many distinct IDs.
- Learn the diagnostic tools: Know how to inspect the JVM and safely collect evidence before an incident.
Increasing -Xmx can be appropriate when an application genuinely needs more heap. But it is not a substitute for fixing unintended object retention.
8. The Interview Answer
If an interviewer asks, “How do you debug a memory leak in Java?” I would answer:
First, I check heap usage, post-GC trends, process memory, and container metrics. Then I review GC logs and use jcmd or jstat to understand the JVM's behavior.
If the heap is retaining unnecessary objects, I capture a heap dump and analyze it using Eclipse MAT, especially the dominator tree and paths to GC roots. I identify the reference responsible, fix the underlying code, and validate the change under comparable load.
If heap usage is stable but process memory keeps growing, I investigate native memory and direct buffers.
The key is to identify the cause before changing JVM settings.
Final Thoughts
A Java memory leak is not always caused by a complicated bug. Sometimes, a cache never evicts entries. A singleton stores request data. A worker thread retains a value longer than expected.
The garbage collector can reclaim unreachable objects, but it cannot decide that a reachable object is no longer useful.
When memory starts increasing, check the metrics first. Use GC logs to understand collection behavior, JFR to investigate allocations, and heap dumps to follow the references retaining objects.
Then fix the cause and test the result.
The real skill in Java memory debugging is understanding why memory cannot be reclaimed, not simply knowing how to trigger garbage collection.









