← Back to list

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:

Ashik Ahammad · 2026-09-02 04:01 · 0 claps · 15.3 min read
#java #heap-memory #memory-leak #garbage-collection
Open on Medium ↗
Wiki topics: BIZ · Business Strategy EDU · Education & Learning

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