Java Has Garbage Collection. So Why Can It Still Leak Memory?
When I first started learning Java, I had a very simple understanding of memory management:
Java Has Garbage Collection. So Why Can It Still Leak Memory?
When I first started learning Java, I had a very simple understanding of memory management:
Java has a Garbage Collector, so developers do not need to worry about memory.
That statement is only half true.
Java frees us from manually calling free() like in C or manually managing object lifetime in the same way as C++. But automatic garbage collection doesn't mean memory problems disappear.
A Java application can still:
- continuously consume more memory,
- retain objects that are no longer useful,
- run Garbage Collection more and more frequently,
- become slower over time,
- eventually crash with
OutOfMemoryError.
This is where an important concept comes in:
Garbage collection cannot remove an object just because the application no longer needs it. It can only remove an object when the JVM determines that the object is no longer reachable.
That difference is why Java applications can still have memory leaks.
In this article, I want to understand how Java uses memory, how the Garbage Collector decides what to remove, and why memory leaks still happen even in a language with automatic memory management.
First, Where Does Java Store Data?
When we run a Java program, the operating system starts a process for the JVM. The JVM then uses several areas of memory.
A simplified view looks like this:
Java Application
│
▼
┌─────────────────┐
│ JVM │
└────────┬────────┘
│
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Thread Stack │ │ Heap │ │ Metaspace │
│ │ │ │ │ │
│ Method calls │ │ Java objects │ │ Class │
│ Local vars │ │ Arrays │ │ metadata │
│ References │ │ Object data │ │ Runtime info │
└──────────────┘ └──────────────┘ └──────────────┘
There are other memory areas involved in the JVM, but for understanding ordinary Java objects and memory leaks, the Stack and Heap are the most important places to start.
Stack Memory: The Memory Used by Method Calls
Every Java thread has its own stack.
When a method is called, the JVM creates a stack frame for that method. That frame contains information related to the method execution, including local variables and references.
Consider this:
public void createUser() {
int age = 25;
User user = new User("Ashik");
process(user);
}
A simplified memory view might look like this:
STACK HEAP
────────────── ──────────────
createUser() frame
age = 25
user ───────────────────────► User Object
┌──────────────┐
│ name = Ashik │
└──────────────┘
The variable user is a reference.
The actual User object exists elsewhere in memory, and user points to it.
When createUser() finishes, its stack frame eventually disappears.
Before method returns:
Stack reference
│
▼
User Object
After method returns:
(no stack reference)
User Object
If there are no other references to that object, it becomes eligible for garbage collection.
Notice the important phrase here:
Eligible for garbage collection does not mean immediately destroyed.
The JVM and its garbage collector decide when reclamation actually happens.
Heap Memory: Where Objects Live
The Java heap is the main area used for dynamically allocated Java objects and arrays.
For example:
User user = new User("Ashik");
Order order = new Order();
int[] numbers = new int[1000];
The objects and array are allocated in heap-related memory.
A reference to an object can exist from:
- a local variable,
- an instance field,
- a static field,
- another object,
- a collection,
- a thread,
- or another JVM-managed structure.
The important point is this:
Reference ─────► Object
As long as the JVM considers an object reachable, its memory generally cannot simply be reclaimed.
Java Heap Is Not Just One Simple Bucket
In a typical generational garbage collection model, the heap is organized around the observation that most objects do not live very long.
A simplified structure looks like this:
┌───────────────────────────────────────────────┐
│ JAVA HEAP │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ Young Generation │ │
│ │ │ │
│ │ Eden Survivor Survivor │ │
│ │ S0 S1 S2 │ │
│ └───────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ Old Generation │ │
│ │ │ │
│ │ Long-lived surviving objects │ │
│ └───────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────┘
Conceptually:
New Object
│
▼
Eden Space
│
│ survives collections
▼
Survivor Space
│
│ survives long enough
▼
Old Generation
Most applications create many temporary objects. For example:
for (int i = 0; i < 1_000_000; i++) {
String result = "Value: " + i;
}
Many of these objects become unreachable very quickly.
That is one reason generational collection is useful: the JVM can focus collection work on areas where many short-lived objects are expected to exist. Oracle describes HotSpot's garbage collectors as using techniques such as generational scavenging and aging to concentrate work where reclaimable objects are likely to be found.
So How Does the Garbage Collector Know What Is Garbage?
This is the part that changed how I think about Java memory.
The Garbage Collector does not ask:
“Does the developer still need this object?”
The JVM cannot understand our business logic that way.
Instead, the key question is:
Can this object still be reached from a live reference?
Imagine our objects as a graph.
GC Root
│
▼
User
/ \
▼ ▼
Address Order
│
▼
Product
The Garbage Collector starts from certain known roots, commonly described as GC Roots, and traces references through the object graph.
If an object is reachable through that graph, it is considered live.
GC Roots
│
▼
┌────────┐
│ Object │
└────────┘
│
▼
┌────────┐
│ Object │
└────────┘
These objects are reachable.
Now consider this:
GC Roots
│
▼
┌────────┐
│ Object │
└────────┘
┌────────┐
│ Object │
└────────┘
The second object has no path from a live root.
It is unreachable.
Its memory can eventually be reclaimed.
Oracle's HotSpot documentation summarizes this idea directly: an object can be treated as garbage when it can no longer be reached from any reference of another live object in the running program.
A Simple Example of Garbage Collection
Consider:
User user = new User("Ashik");
Memory:
GC Root
│
▼
user
│
▼
┌─────────────┐
│ User Object │
└─────────────┘
Now:
user = null;
The previous object might now look like this:
GC Root
user = null
┌─────────────┐
│ User Object │
└─────────────┘
No reachable path
If no other reference points to that User object, it becomes eligible for collection.
Eventually, the Garbage Collector may reclaim that memory.
But now let's look at the real problem.
The Most Important Thing to Understand About Java Memory Leaks
Suppose I create this object:
User user = new User("Ashik");
Later, I finish using it.
From my perspective:
"I don't need this object anymore."
But Java does not collect objects based on what I think.
Suppose I accidentally store the object somewhere:
static List<User> users = new ArrayList<>();
users.add(user);
Now the object is still reachable.
Static Field
│
▼
users List
│
▼
User Object
Even if the original variable disappears:
user = null;
The object still exists because another reference is keeping it alive.
user = null
Static List
│
▼
User Object
From the Garbage Collector's perspective, the object is not garbage.
It is reachable.
So the GC is doing exactly what it is supposed to do.
But from the application's perspective, we may no longer need that object.
That is the essence of many Java memory leaks.
A Java memory leak happens when objects are no longer useful to the application but are still reachable, preventing the Garbage Collector from reclaiming their memory.
Oracle's troubleshooting documentation describes Java memory leaks in essentially this way: an application unintentionally holds references to objects or classes, preventing them from being garbage collected.
Garbage Collector Is Not a “Delete Everything Old” System
This is a common misunderstanding.
Some developers imagine the Garbage Collector like this:
Old Object
│
▼
Not used recently
│
▼
GC deletes it
That is not how Java object reachability works.
A more accurate mental model is:
Can I reach this object from a live reference?
│
┌────┴────┐
│ │
YES NO
│ │
▼ ▼
Keep it Reclaimable
This means you can have an object that:
- is one day old,
- has not been accessed for hours,
- is completely useless to the business logic,
and Java may still keep it alive because something still references it.
That is why a Garbage Collector does not magically prevent every memory leak.
Example 1: The Growing Collection
One of the simplest memory leaks looks like this:
public class UserService {
private final List<User> users = new ArrayList<>();
public void addUser(User user) {
users.add(user);
}
}
Now imagine:
while (true) {
User user = getUserFromRequest();
userService.addUser(user);
}
Memory usage may conceptually grow like this:
Time ───────────────────────────────►
Heap Usage
100 MB ██████
200 MB ████████████
400 MB ███████████████████████
800 MB ███████████████████████████████████
The Garbage Collector may run repeatedly.
But the User objects are still referenced by:
UserService
│
▼
users List
│
├────► User
├────► User
├────► User
├────► User
└────► User ...
GC cannot remove those objects simply because the list keeps growing.
The real bug is not:
GC is broken
The real bug is:
The application keeps references forever.
Example 2: Static Collections
Static references are especially dangerous in long-running applications.
Consider:
public class Cache {
private static final List<byte[]> DATA = new ArrayList<>();
public static void add(byte[] value) {
DATA.add(value);
}
}
Every object stored in DATA is connected to a static field.
Conceptually:
GC Root
│
▼
Static Field
│
▼
DATA List
│
├────► Object 1
├────► Object 2
├────► Object 3
└────► Object 4
As long as those objects remain in the list, they may remain reachable.
Now imagine storing large request payloads, uploaded files, images, or application data indefinitely.
The heap will eventually fill.
Static collections are not inherently bad. The problem is using them without a strategy for removing or limiting data.
Example 3: Caches That Never Evict Data
Caching is useful.
An unlimited cache is often just a slow memory leak.
Consider:
Map<String, User> cache = new HashMap<>();
public User getUser(String id) {
return cache.computeIfAbsent(
id,
this::loadUserFromDatabase
);
}
At first, this looks efficient.
User requests
│
▼
┌─────────────┐
│ Cache │
└─────────────┘
│
▼
Faster response
But what happens after millions of unique users?
Cache
│
├── User 1
├── User 2
├── User 3
├── User 4
├── User 5
│
└── ...
millions more
Nothing leaves the cache.
Eventually:
More requests
│
▼
More objects
│
▼
Bigger cache
│
▼
More heap usage
│
▼
More GC pressure
│
▼
OutOfMemoryError
A cache needs a lifecycle policy.
For example:
- maximum size,
- time-to-live,
- least recently used eviction,
- explicit invalidation.
For caches where appropriate, Java's reference APIs also provide weak and soft reference mechanisms, though they should not be treated as a universal replacement for proper cache design. Oracle documents these reference types as having different levels of reachability and different intended use cases.
Example 4: Listeners and Event Subscriptions
Consider:
eventBus.addListener(user -> {
System.out.println(user.getName());
});
Suppose a listener captures another object.
eventBus.addListener(event -> {
this.handle(event);
});
The event system may now keep the listener alive.
And the listener may indirectly keep the surrounding object alive.
Conceptually:
Event Bus
│
▼
Listener
│
▼
Service
│
▼
Other Objects
Even if we think the Service should disappear, the event listener may keep a reference path alive.
This is common in:
- GUI applications,
- event-driven systems,
- messaging systems,
- observer patterns,
- application frameworks.
The problem happens when something is registered but never unregistered.
Example 5: ThreadLocal Memory Leaks
ThreadLocal can also create surprising retention problems.
Suppose we use:
private static final ThreadLocal<byte[]> BUFFER =
new ThreadLocal<>();
And then:
BUFFER.set(new byte[10 * 1024 * 1024]);
If this is used inside a short-lived thread, the memory may disappear when the thread disappears.
But many server applications use thread pools.
The thread may stay alive for a long time:
Thread Pool
│
├── Worker Thread 1 ───► ThreadLocal Data
├── Worker Thread 2 ───► ThreadLocal Data
├── Worker Thread 3 ───► ThreadLocal Data
└── Worker Thread 4 ───► ThreadLocal Data
If the data is no longer needed, cleanup should happen.
For example:
try {
BUFFER.set(largeData);
// Work with data
} finally {
BUFFER.remove();
}
The key issue again is object reachability.
The thread is alive.
The thread's associated state remains reachable.
Therefore the memory may remain in use.
Example 6: Long-Lived Threads
Threads themselves can retain large object graphs.
Imagine:
Running Thread
│
▼
Runnable
│
▼
Service
│
▼
Large Object Graph
If a thread unexpectedly lives forever, objects referenced by its execution context may also remain reachable.
This is especially relevant in:
- server applications,
- thread pools,
- background workers,
- schedulers,
- executors.
A thread leak can therefore become a memory problem as well.
Memory Leaks Can Also Happen Through Classes
We often think only about objects.
But long-running Java applications can also retain classes and class loaders.
Oracle's troubleshooting documentation specifically notes that a memory leak can involve unintentionally holding references to Java objects or classes, preventing them from being reclaimed and eventually exhausting heap or Metaspace.
This can be particularly important in systems that:
- dynamically load classes,
- use plugin architectures,
- repeatedly reload applications,
- run containers with deployment and redeployment behavior.
A class loader leak can keep an entire class-related object graph alive.
Simplified:
Long-lived Object
│
▼
ClassLoader
│
▼
Class
│
▼
Static Fields
│
▼
Object Graph
One unexpected reference can retain far more memory than the reference itself appears to represent.
Java Object Memory and Operating System Resources Are Different Things
Another mistake is assuming:
“The Garbage Collector will clean up everything.”
Not everything belongs to the Java heap.
A Java application can use external resources such as:
- files,
- sockets,
- database connections,
- operating system handles.
For example:
InputStream input =
new FileInputStream("data.txt");
The Java object is not the same thing as the underlying operating system resource.
If the resource must be closed, close it explicitly.
Use:
try (
InputStream input =
new FileInputStream("data.txt")
) {
// Read file
}
When the block finishes, Java closes the resource.
This is why try-with-resources is so important.
Garbage collection handles Java object memory, but developers should not rely on it as the lifecycle mechanism for operating system resources. Oracle documentation explicitly distinguishes Java object memory from resources such as files and sockets and notes that the GC does not close those operating system constructs.
What Actually Happens During Garbage Collection?
A simplified conceptual flow looks like this:
Java Heap
│
▼
┌────────────────┐
│ Find GC Roots │
└───────┬────────┘
│
▼
┌────────────────┐
│ Trace reachable│
│ objects │
└───────┬────────┘
│
▼
┌────────────────┐
│ Reachable? │
└───────┬────────┘
│
┌─────┴─────┐
│ │
YES NO
│ │
▼ ▼
Keep Reclaim
object memory
Different garbage collectors implement collection differently.
The JVM may:
- use multiple generations,
- run some operations in parallel,
- perform work concurrently with application threads,
- compact memory,
- use different algorithms depending on the selected collector.
The exact implementation depends on the JVM version and garbage collector.
But the central idea remains:
Reachability determines whether an object can be reclaimed.
Why Does Memory Usage Sometimes Keep Growing Even After GC Runs?
Suppose our application uses:
1 GB
The Garbage Collector runs.
But after collection, memory usage is still:
950 MB
That means one important thing:
Most of that memory contains objects
that are still considered live.
Now imagine this repeatedly:
After GC #1 → 300 MB
After GC #2 → 400 MB
After GC #3 → 550 MB
After GC #4 → 700 MB
After GC #5 → 900 MB
That can be a serious warning sign.
The useful metric is often not simply:
How much memory did the process use?
But:
How much memory remains occupied by live objects after collection?
Oracle's troubleshooting guidance calls this the live set and notes that steady growth over time after full garbage collections can be a strong indication of a memory leak.
Garbage Collection Can Actually Make a Memory Leak Feel Worse
Suppose the application keeps retaining objects.
At first:
Heap: 2 GB
Used: 500 MB
Application: Fast
Then:
Heap: 2 GB
Used: 1.5 GB
Application: Slower
Eventually:
Heap: 2 GB
Used: 1.95 GB
GC runs repeatedly
The application may start spending more time trying to find reclaimable memory.
But if most objects are still reachable:
GC
│
▼
Find objects
│
▼
Most are reachable
│
▼
Very little memory recovered
│
▼
Application continues allocating
│
▼
GC runs again
This creates significant GC pressure.
In severe cases, the application may eventually throw:
java.lang.OutOfMemoryError
The JVM's OutOfMemoryError means that it cannot allocate an object because memory is exhausted and the garbage collector cannot make more memory available.
OutOfMemoryError Does Not Automatically Mean Memory Leak
This distinction is important.
Suppose we write:
int[] data = new int[1_000_000_000];
The JVM might fail because the requested memory is simply too large.
That is not necessarily a leak.
Or the application may legitimately need more memory than the configured heap provides.
For example:
java -Xmx512m MyApplication
If the application genuinely needs more than 512 MB of heap, increasing the maximum heap might solve the problem.
But increasing heap size is not a fix for an actual leak.
If the application continuously retains objects:
Old heap:
512 MB
Application crashes
You increase it:
New heap:
2 GB
The result might simply be:
Application crashes later.
Oracle's documentation specifically warns that an OutOfMemoryError does not necessarily prove a memory leak; the configured heap may simply be insufficient. But for long-running applications, unintentional object retention is another common cause.
A Small Memory Leak Example
Consider this application:
import java.util.ArrayList;
import java.util.List;
public class MemoryLeakExample {
private static final List<byte[]> cache =
new ArrayList<>();
public static void main(String[] args) {
while (true) {
byte[] data =
new byte[1024 * 1024];
cache.add(data);
}
}
}
Every iteration creates:
1 MB
Then stores it in:
static List<byte[]> cache
Memory:
GC Root
│
▼
Static cache
│
├──► 1 MB array
├──► 1 MB array
├──► 1 MB array
├──► 1 MB array
└──► ...
Eventually:
cache keeps growing
│
▼
objects remain reachable
│
▼
GC cannot reclaim them
│
▼
heap becomes full
│
▼
OutOfMemoryError
The Garbage Collector did not fail.
The program kept telling the JVM:
“These objects are still reachable. Keep them.”
A Better Version
The solution depends on what the application actually needs.
Maybe the cache should have a maximum size:
private static final int MAX_SIZE = 100;
private static final List<byte[]> cache =
new ArrayList<>();
public static void add(byte[] data) {
if (cache.size() >= MAX_SIZE) {
cache.remove(0);
}
cache.add(data);
}
Or perhaps entries should expire.
Or perhaps the data should not be cached at all.
The important thing is:
Every long-lived collection should have a reason for how data enters it and how data eventually leaves it.
A Better Mental Model for Java Memory
Instead of thinking:
Object created
│
▼
Object used
│
▼
GC automatically deletes it
Think:
Object created
│
▼
Who references it?
│
▼
Does a path from a live root still exist?
│
├──── YES ────► Object stays alive
│
└──── NO ─────► Object can be reclaimed
This model makes memory leaks much easier to understand.
Common Reasons Java Memory Leaks Occur
In my experience, these are some of the patterns worth checking first:
1. Collections that grow forever
list.add(data);
but never:
list.remove(data);
or never clear old data.
2. Static references
private static final Map<String, Object> CACHE =
new HashMap<>();
Static objects can live for the lifetime of the class and potentially much longer than ordinary method-local objects.
3. Caches without eviction
More users
│
▼
More cached objects
│
▼
Cache grows forever
4. Event listeners that are never removed
eventBus.register(listener);
but never:
eventBus.unregister(listener);
5. ThreadLocal values that are not cleaned up
Especially with thread pools.
6. Long-lived threads
A long-running thread can retain references to unexpectedly large object graphs.
7. Class loader leaks
Especially in systems involving dynamic loading, plugins, or redeployment.
8. External resources not being closed
Such as:
- database connections,
- streams,
- files,
- sockets.
This is not always a Java heap leak in the narrow sense, but it can still cause the application to exhaust memory or operating system resources.
How I Would Investigate a Suspected Java Memory Leak
I would not start by randomly increasing -Xmx.
I would start by collecting evidence.
A useful investigation flow looks like this:
Application memory increases
│
▼
Is heap usage growing over time?
│
▼
Force / observe GC behavior
│
▼
Does the live set keep increasing?
│
┌────┴────┐
│ │
YES NO
│ │
Possible Could be normal
leak allocation behavior
│
▼
Capture heap dump
│
▼
Find biggest retained objects
│
▼
Find who is keeping them alive
│
▼
Remove the unnecessary reference
Useful Tools
JConsole
JConsole can help monitor:
- heap memory,
- memory pools,
- garbage collection activity.
A steady increase in heap or old-generation usage over time can help reveal a leak pattern.
JDK Mission Control
JDK Mission Control and Java Flight Recorder can be useful for investigating memory behavior and object retention over time. Oracle specifically recommends analyzing recordings to detect slow memory leaks before they eventually become OutOfMemoryError failures.
Heap Dumps
A heap dump is one of the most useful tools for finding:
- what objects consume memory,
- how many instances exist,
- which objects retain other objects,
- the path that keeps an object reachable.
The most important question is often not:
“Why does this object exist?”
But:
“Who is still holding a reference to it?”
Retained Size Is More Important Than Object Size
Suppose an object itself is small:
CacheManager
Maybe it uses only a small amount of memory.
But it references:
CacheManager
│
▼
Map
│
├────► 100,000 Users
├────► 100,000 Orders
└────► 1,000,000 Cached Objects
The CacheManager itself might be tiny.
But removing that one reference could allow gigabytes of objects to be reclaimed.
That is why memory analysis often focuses on the object graph rather than simply looking for the largest individual object.
One Important Lesson: System.gc() Is Not a Memory Leak Fix
A common reaction to memory problems is:
System.gc();
But this does not solve the root problem.
Imagine:
GC
│
▼
Objects are still reachable
│
▼
Nothing important can be reclaimed
Running GC more often does not help if the application is still retaining the objects.
The real solution is:
Find the unnecessary reference
│
▼
Remove or limit it
│
▼
Object becomes unreachable
│
▼
GC can reclaim the memory
Java's Biggest Memory Management Advantage Is Also Its Biggest Misunderstanding
Java developers usually do not manually allocate and free ordinary object memory.
That is a huge advantage.
We don't have to write:
malloc(...)
free(...)
for ordinary Java objects.
But this does not mean:
No memory management knowledge required
It means the nature of the problem changes.
In languages with manual memory management, developers may forget to free memory.
In Java, a common problem is:
The application accidentally keeps a reference to something that should have been released.
So the question changes from:
Did I free this object?
to:
Why is this object still reachable?
That is one of the most useful questions a Java developer can learn to ask.
Final Thoughts
The Garbage Collector is not a magic cleanup system that removes everything old or unused.
It works based on object reachability.
If an object is still reachable:
GC Root
│
▼
Reference
│
▼
Object
the JVM must generally treat it as live.
Even if the application no longer needs it.
That is why Java can still have memory leaks.
The most common cause is not:
Java forgot to collect an object.
It is:
The application accidentally kept a reference to an object that it no longer needed.
My current mental model for Java memory management is therefore:
Java creates objects
│
▼
References create an object graph
│
▼
Garbage Collector traces reachability
│
├── Reachable ─────► Keep
│
└── Unreachable ───► Reclaim
And when a Java application keeps consuming more and more memory, the most important question is:
What is still holding a reference to these objects?
Because very often, that reference is where the real memory leak begins.
메타데이터
- post_id
- eb8ed7120769
- slug
- java-has-garbage-collection-so-why-can-it-still-leak-memory-eb8ed7120769
- url
- https://medium.com/@ashik.cse.ah/java-has-garbage-collection-so-why-can-it-still-leak-memory-eb8ed7120769
- canonical_url
- https://medium.com/@ashik.cse.ah/java-has-garbage-collection-so-why-can-it-still-leak-memory-eb8ed7120769
- author_url
- https://medium.com/@ashik.cse.ah
- status
- ok
- fetched_at
- 2026-09-04 22:40:54