Skip to content
Chapter 10 · Core Java

Java Memory Management and Garbage Collection ​

Why Every Java Developer Needs to Understand Memory ​

When you write a Java program, you create variables, objects, and references all the time. Somewhere underneath all of that, memory is being allocated and freed constantly. If you do not understand how this works, you will eventually write code that leaks memory, causes surprising slowdowns, or crashes with cryptic errors like OutOfMemoryError or StackOverflowError.

This is also one of the most frequently tested topics in technical interviews. Interviewers use memory management questions to find out whether you understand how the JVM actually operates, not just how to write syntactically correct Java. They want to know: can you reason about what your code does at runtime? Can you diagnose a memory problem in production? This article builds that understanding from the ground up.

The good news is that Java manages memory automatically. You do not call free() like you would in C. But automatic does not mean invisible. The JVM makes very specific decisions about where to store your data and when to clean it up. Understanding those decisions helps you write better code and debug harder problems.


The JVM Splits RAM into Two Regions ​

When your Java program runs, the JVM takes a portion of your machine's RAM and divides it into two distinct regions: the Stack and the Heap. Everything that happens in your program happens inside one of these two regions. Understanding what goes where is the foundation of everything else in this topic.

Think of the Stack like a notepad on your desk. You write things down quickly, use them briefly, and then erase them when you are done. Think of the Heap like a filing cabinet in the corner of the room. Things go in there when they need to persist beyond a single moment. The notepad is fast and temporary. The filing cabinet is large and longer lived.

Stack Memory: Temporary, Ordered, Fast ​

The Stack is where Java stores temporary working data. Every time a method is called, the JVM creates a new block of memory on the Stack specifically for that method. This block is called a frame. The frame holds everything that method needs while it runs: its local variables, its parameters, and references to any objects it works with.

When the method finishes and returns, the JVM pops that frame off the Stack. Everything in it disappears instantly. No garbage collector needed, no cleanup pass. The memory is simply reclaimed the moment the frame is gone.

This is called LIFO: Last In, First Out. The most recently added frame is always the first one to be removed. If main() calls helper(), then helper() finishes first and its frame is popped, then main() finishes and its frame is popped.

Here is what the Stack stores:

Primitive data types are stored directly in the Stack frame. When you write int a = 10, the value 10 lives inside the frame for whatever method you wrote that line in.

References to objects are stored in the Stack. When you write Person p = new Person(), the variable p holds an address, a memory location that points somewhere in the Heap. The address itself lives in the Stack frame. The actual Person object lives in the Heap.

One critical thing to know: every thread has its own Stack. If your application runs three threads simultaneously, there are three separate Stacks, one for each thread. The threads do not share Stack memory. This is what makes local variables thread safe by default: no other thread can see your method's frame.

When Stack memory runs out (usually because of infinite or very deep recursion), the JVM throws StackOverflowError.

Heap Memory: Shared, Large, Managed by GC ​

The Heap is where all objects live. Every time you write new SomeThing(), the JVM allocates memory in the Heap for that object. The object stays there until no part of your program can reach it anymore, at which point the Garbage Collector can reclaim its memory.

Unlike the Stack, there is only one Heap per JVM instance. All threads share it. This is what allows threads to pass objects to each other: the object sits in the Heap, and multiple threads can hold references to it.

The Heap is generally much larger than the Stack. It is designed to hold many objects for long periods of time. When the Heap runs out of space and the Garbage Collector cannot free enough of it, the JVM throws OutOfMemoryError: Java heap space.

Stack vs Heap at a Glance ​

CharacteristicStackHeap
What lives herePrimitive values, references, method framesAll objects created with new
One or manyOne per threadOne shared by all threads
LifetimeUntil method returns (frame popped)Until no references remain (GC cleans it)
OrderingLIFONo ordering, managed by GC
Error when fullStackOverflowErrorOutOfMemoryError

Tracing a Real Example Through Stack and Heap ​

The best way to truly understand Stack and Heap is to trace a real piece of code and watch exactly where each piece of data goes. Here is a simple example with two methods:

java
public class MemoryManagement {

    public static void main(String[] args) {
        int primitiveVar = 10;                    // primitive: stored in main's Stack frame
        Person personObj = new Person();          // Person object in Heap, personObj reference in Stack
        String stringLiteral = "24";             // "24" in String Constant Pool (inside Heap), reference in Stack
        MemoryManagement memObj = new MemoryManagement(); // MemoryManagement object in Heap, memObj reference in Stack

        memObj.memoryManagementTest(personObj);  // new Stack frame pushed for this call
        // after the call returns, control comes back here
    } // main's frame popped, all references in it are gone

    public void memoryManagementTest(Person personParam) {
        // personParam is a reference to the SAME Person object personObj pointed to in main
        Person personObj2 = personParam;          // personObj2 also points to the SAME Person object in Heap
        String stringLiteral2 = "24";            // "24" already exists in the Pool, reuses it, no new object created
        String stringObj3 = new String("24");    // new String object in Heap (OUTSIDE the Pool, separate object)
    } // this frame is popped first (LIFO), personParam, personObj2, stringLiteral2, stringObj3 references are gone

}

Let us walk through this step by step and see exactly what happens in memory.

Step 1: main is called. The JVM pushes a new frame onto the Stack for main. This frame will hold everything created inside main.

Step 2: primitiveVar = 10 is executed. The value 10 is stored directly inside main's frame on the Stack. No Heap involved.

Step 3: new Person() is executed. A Person object is created in the Heap. The variable personObj holds the address of that object, and that address lives inside main's frame on the Stack.

Step 4: "24" is assigned to stringLiteral. String literals go into a special area called the String Constant Pool, which lives inside the Heap. The Pool stores each unique string value exactly once. The variable stringLiteral in main's frame holds a reference to "24" in the Pool.

Step 5: new MemoryManagement() is executed. Another object lands in the Heap. memObj in main's frame holds a reference to it.

Step 6: memObj.memoryManagementTest(personObj) is called. The JVM pushes a brand new frame onto the Stack for memoryManagementTest. This frame exists on top of main's frame.

Step 7: Inside memoryManagementTest, personParam receives the same address that personObj held. Both now point to the exact same Person object in the Heap. No new Person object is created.

Step 8: personObj2 = personParam creates yet another reference variable in the frame, also pointing to the same Person object. One object in the Heap, now three references to it.

Step 9: stringLiteral2 = "24" is evaluated. The JVM checks the String Constant Pool. "24" is already there. It reuses it. No new object created. stringLiteral2 just holds another reference to the same pooled string.

Step 10: new String("24") creates a brand new String object in the Heap, outside the Pool. Even though its content is "24", it is a separate object. stringObj3 holds a reference to this new object.

Step 11: The closing brace of memoryManagementTest is reached. The frame for that method is popped off the Stack (LIFO: it was the last one added, so it is the first removed). The variables personParam, personObj2, stringLiteral2, and stringObj3 are all gone. The references are destroyed. But the Person object in the Heap? It still exists. The MemoryManagement object? Still exists. The String objects? Still exist. Popping a Stack frame does not destroy Heap objects.

Step 12: main's closing brace is reached. main's frame is popped. The references personObj, stringLiteral, and memObj are gone.

Step 13: Now the Heap contains objects that no Stack frame holds a reference to anymore. The Person object, the MemoryManagement object, the new String("24") object: all orphaned. This is where the Garbage Collector comes in.


The String Constant Pool ​

String literals are stored once in the String Constant Pool, which has lived inside the Heap since Java 7. That is why two equal literals are == while new String("24") is a separate object. The details (intern(), == vs equals(), immutability) are in Reference Types and Wrappers.


Garbage Collection: Automated Memory Cleanup ​

In languages like C, you allocate memory and then you are responsible for freeing it. Forget to free it, and it leaks. Java removes that burden. The Garbage Collector runs in the background and cleans up objects that are no longer reachable.

An object becomes eligible for garbage collection when no active thread can reach it through any chain of references. The simplest way this happens is when you set a reference to null:

java
Person p = new Person();   // Person object created in Heap, p holds reference
p = null;                  // p no longer points to the Person object
                           // Person object is now unreachable, eligible for GC

Another common way is when a reference is overwritten:

java
Person obj1 = new Person();     // obj1 points to Person object A
Person obj2 = new Person();     // obj2 points to Person object B
obj1 = obj2;                    // obj1 now points to Person object B
                                // Person object A has no references, eligible for GC

The JVM decides when to run the Garbage Collector. You can hint at it with System.gc(), but the JVM is free to ignore that hint completely. There is no guarantee GC will run when you call it:

java
System.gc(); // a suggestion, NOT a command. JVM may or may not honor it.

Never design code that depends on System.gc() running at a specific moment. This is a common mistake. The JVM monitors Heap usage on its own and runs GC when it decides the time is right: typically when a memory region starts filling up.


Different Types of References ​

Most Java code uses what is called a strong reference. This is simply the normal way you assign an object to a variable. A strong reference tells the GC: do not collect this object while I exist.

java
Person p = new Person();  // strong reference, GC will never collect this while p is alive

Java also provides weaker forms of reference for specific use cases.

A weak reference tells the GC: my existence alone does not keep this object alive. Once the only references left are weak, the next GC cycle that examines the object clears the reference and reclaims it, no matter how much memory is free. You access the value with .get(), which may return null if the GC has already collected it:

java
import java.lang.ref.WeakReference;

WeakReference<Person> weakRef = new WeakReference<>(new Person("Alice"));
// ... time passes, GC runs ...
Person p = weakRef.get(); // might return null if GC has collected the Person object
if (p != null) {
    // safe to use p here
}

Weak references are useful for caches where you want entries to disappear automatically when no other code is holding onto them. WeakHashMap in the standard library uses this pattern.

A soft reference is like a weak reference, but with more patience. It tells the GC: you are allowed to collect this object, but only if you are truly running out of memory. As long as there is space available, keep it alive. Soft references are ideal for memory sensitive caches where you want entries to stay as long as possible but not cause an OutOfMemoryError:

java
import java.lang.ref.SoftReference;

SoftReference<byte[]> cache = new SoftReference<>(loadExpensiveData());
// data will survive as long as memory is plentiful
// GC may collect it only under memory pressure

There is also a fourth kind, the phantom reference. It is rarely used; knowing that it exists is enough here.

In practice at most companies, you will almost always use strong references. The other types exist for specific caching and resource management scenarios.


The Heap Is Divided into Generations ​

Objects do not all live and die at the same rate. Most objects are created, used briefly, and then abandoned within milliseconds: a loop variable, a temporary result, a request object in a web server. A small number of objects live for the entire lifetime of the application: a configuration holder, a singleton service, a connection pool.

The JVM exploits this observation with generational garbage collection. Instead of scanning the entire Heap every time it needs to collect garbage, it divides the Heap into regions and collects the younger, shorter lived regions much more frequently. This makes GC dramatically faster on average.

The Heap is divided into two main regions: the Young Generation and the Old Generation (also called the Tenured Generation).

The Young Generation is further split into three spaces: Eden, Survivor 0 (S0), and Survivor 1 (S1).

There is also a separate nonheap region called Metaspace (previously called PermGen in older Java versions).

GenerationSpacesWhat Lives HereGarbage Collector
Young GenerationEden SpaceAll new objects start life here (new Object())Minor GC (Fast, frequent, runs when Eden fills up)
Survivor (S0 & S1)Objects that survive Minor GC; age counter increments (age++)
Old GenerationTenured SpaceLong-lived objects, promoted after surviving enough Minor GCs (at most MaxTenuringThreshold, default 15)Major GC (Slower, runs when Old Gen fills; algorithm depends on the collector)
Non-HeapMetaspaceLoaded class metadata and method bytecodeNative OS memory (replaces legacy PermGen)
1. Allocation  ──>  Every new object starts in Eden.
2. Minor GC    ──>  Reachable objects move to S0 (age = 1). Eden is emptied.
3. Next Cycle  ──>  Survivors from Eden + S0 move to S1 (age = 2). S0 is emptied.
4. Promotion   ──>  Objects that reach the tenuring threshold (max 15 by default) graduate to the Old Generation.
5. Major GC    ──>  When Old Gen fills up, Full GC marks, sweeps, and compacts memory.

Eden: Where Every Object Begins ​

Every object you create starts life in Eden. Eden is relatively small and fills up quickly because your program creates objects constantly.

Survivor Spaces: S0 and S1 ​

These two spaces act as a holding area for objects that survive their first GC cycle but are not old enough to be promoted to the Old Generation. At any point in time, one survivor space is in use and one is completely empty. They alternate with each GC cycle.

Old Generation: For Long Lived Objects ​

Objects that survive enough GC cycles graduate here. Once in the Old Generation, an object is assumed to be long lived. GC runs less frequently here, but when it does run, it takes longer.


Minor GC: Cleaning the Young Generation ​

When Eden fills up, the JVM triggers a Minor GC. Minor GC only operates on the Young Generation. Because the Young Generation is small and most objects in it are already dead by the time GC runs, Minor GC is fast.

Here is how it works step by step:

Mark: The GC finds every object that no longer has any reference and marks it as removable.

Sweep: Marked objects are removed. Surviving objects are moved out of Eden into a Survivor space, so Eden becomes empty again.

Age: Surviving objects from Eden are moved to one of the Survivor spaces (say S0). Their age counter is incremented by 1. Previously surviving objects in S1 are also moved to S0, and their age counters are incremented too.

After Minor GC: Eden is empty and ready for new objects. One of the Survivor spaces (S0) holds all survivors. The other (S1) is empty.

The next time Minor GC runs, survivors from Eden and from S0 are moved to S1, and their ages are incremented again. S0 becomes the empty one. This alternation continues with every GC cycle.

Promotion: Each object has an age counter. Every Minor GC that an object survives adds one to its age. When the age reaches a threshold, the object is promoted to the Old Generation. The threshold is configurable (the HotSpot maximum, -XX:MaxTenuringThreshold, is 15 by default). It has proven that it lives a long time, so keeping it in the Young Generation wastes GC time scanning it repeatedly.


Major GC: Cleaning the Old Generation ​

When the Old Generation fills up, the JVM triggers a Major GC (sometimes called Full GC). This operates on the Old Generation and is significantly slower than Minor GC for two reasons.

First, the Old Generation is large. It contains objects that have been alive a long time, which means they have survived many GC cycles and accumulated many references pointing to them and from them. Tracing all those references takes time.

Second, Old Generation GC runs much less frequently. Where Minor GC might run every few seconds, Major GC might run only every few minutes or even less. But when it runs, it must scan and process much more data.

The process is the same: mark reachable objects, sweep the unreachable ones, and compact the survivors.

Mark and Sweep with Compaction ​

The basic algorithm is mark and sweep: mark the objects that have no reference, then sweep (remove) them. After deleting objects, the remaining live objects may be scattered through memory with gaps between them. This fragmentation is a problem: when you need to allocate a large new object, there may not be a single contiguous free block large enough, even if total free memory is sufficient.

Mark and sweep with compaction solves this. After sweeping, the GC slides all surviving objects together to form one contiguous block of live data followed by one contiguous free region. New allocations can then happen efficiently at the end of the used region.


Stop the World: The Performance Cost of GC ​

Here is the thing that matters most for application performance. When the Garbage Collector runs, it needs a stable snapshot of which objects are reachable. If objects are being created and references are being changed while the GC is tracing, the GC might make incorrect decisions. So most GC implementations pause all application threads while they work.

This pause is called a stop the world pause. Your application freezes. No requests are processed. No data is written. Everything waits until the GC finishes. Then all threads resume.

For Minor GC, stop the world pauses are short because the Young Generation is small and collection is fast.

For Major GC, stop the world pauses can be long. If your Old Generation contains gigabytes of data, the GC may pause your application for hundreds of milliseconds or even seconds. For a web server handling thousands of requests per second, a one second pause is catastrophic.

This is why reducing GC pause time is so important in high performance applications.

When pause time decreases, your throughput increases. If your application could process 1000 requests per minute with frequent long pauses, reducing those pauses might let it process 1500 requests per minute. And your latency improves too: users no longer experience those sudden slowdowns when GC kicks in.


The Garbage Collectors ​

The JVM comes with several GC implementations, each making different tradeoffs:

Serial GC: Uses a single thread to do all GC work, for both minor and major GC. While it runs, all application threads pause, and with only one thread the pause is long.

Parallel GC: Uses multiple GC threads, typically one per CPU core. All application threads still pause during collection, but with multiple GC threads working simultaneously, the collection finishes faster, so pauses are shorter. This was the default in Java 8.

CMS (Concurrent Mark Sweep): Attempts to do most of its work concurrently with your application threads, so your application does not have to fully pause. The JVM does not guarantee zero pauses, but it tries hard to minimize them. The downside: CMS does not compact the Heap after collection. Over time, memory fragmentation accumulates.

G1 (Garbage First): An improvement over CMS. It does most of its marking concurrently and also compacts memory, keeping pauses short. It still has short stop-the-world pauses; it does not remove them completely.

Default collectors: Parallel GC in Java 8, G1 from Java 9 onward.


Metaspace: Where Class Information Lives ​

Outside the Heap, the JVM maintains a region called Metaspace. This is where it stores:

Class metadata: the structural information about every loaded class (its fields, methods, bytecode, hierarchy, and runtime constant pool).

Static variables (class variables) and static final constants belong to the class, but their values are stored on the Heap, inside the class's java.lang.Class object, not in Metaspace.

The JVM loads class data here when it needs a class and removes it when the class is no longer needed.

Before Java 8, this data lived in PermGen (Permanent Generation), which was managed as part of the Heap area and had a fixed size: when it filled up you got OutOfMemoryError. Java 8 replaced it with Metaspace, which is outside the Heap and grows as needed.


Check yourself ​

  1. What does the Stack store, and what does the Heap store? How many of each exist?
  2. When does an object become eligible for garbage collection? Does System.gc() force a collection?
  3. Explain strong, weak, and soft references in one line each.
  4. Walk an object through Eden, the Survivor spaces, and the Old Generation. What is the difference between minor and major GC?
  5. What does compaction add to mark and sweep?
  6. What is a stop-the-world pause? Compare Serial, Parallel, CMS, and G1.
  7. What replaced PermGen, and what is the difference?
Answers
  1. Stack: a frame per method call with local variables, primitive values and references to objects; one Stack per thread; StackOverflowError when full. Heap: all objects (and the String pool); one Heap shared by all threads.
  2. When nothing references it any more, for example after obj = null or after the variable is pointed at another object. System.gc() is only a request; the JVM decides whether and when to run GC.
  3. Strong: never collected while the reference exists. Weak: collected when GC runs, after which get() returns null. Soft: collected only when memory is really needed.
  4. New objects go to Eden. Each minor GC removes unreferenced objects, moves survivors to one Survivor space and increases their age. When the age reaches the threshold, the object is promoted to the Old Generation. Minor GC (young) is fast and frequent; major GC (old) is slower and less frequent.
  5. It slides the surviving objects together so free memory is one contiguous block, instead of scattered gaps.
  6. All application threads pause while GC works. Serial: one GC thread. Parallel: several GC threads, shorter pauses (Java 8 default). CMS: works concurrently with the application, tries to avoid pauses, no compaction. G1: concurrent marking plus compaction, short pauses (default since Java 9).
  7. Metaspace (Java 8). PermGen was fixed in size and part of the Heap area; Metaspace is outside the Heap and grows as needed.